diff --git a/assets/css/checkout.css b/assets/css/checkout.css index b22d2e1a4..cbe87e2b5 100644 --- a/assets/css/checkout.css +++ b/assets/css/checkout.css @@ -260,3 +260,86 @@ outline: 2px solid var(--wu-accent-color); outline-offset: 2px; } + +/** + * Radio option card styling. + * + * Provides a consistent, visually polished radio button appearance for + * payment method and other radio fields. Works across classic, block, + * and page-builder themes by relying on the CSS custom properties + * defined at the top of this file. + * + * The custom radio dot is drawn via box-shadow so no extra markup or + * pseudo-elements are needed. + * + * @since 2.4.0 + */ + +/* Card wrapper for each radio option */ +.wu-styling .wu-radio-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1.5px solid var(--wu-input-border-color); + border-radius: var(--wu-input-border-radius); + cursor: pointer; + margin-bottom: 6px; + background: var(--wu-input-bg); + color: var(--wu-input-color); + font-weight: normal; + font-size: inherit; + line-height: 1.4; + transition: border-color 0.15s ease, background-color 0.15s ease; + user-select: none; + -webkit-user-select: none; +} + +.wu-styling .wu-radio-option:last-child { + margin-bottom: 0; +} + +.wu-styling .wu-radio-option:hover { + border-color: var(--wu-accent-color); +} + +/* Highlight the selected card — :has() is widely supported (Chrome 105+, Firefox 121+, Safari 15.4+) */ +.wu-styling .wu-radio-option:has(.wu-radio-input:checked) { + border-color: var(--wu-accent-color); + background-color: color-mix(in srgb, var(--wu-accent-color) 6%, var(--wu-input-bg)); +} + +/* Custom radio circle — replaces the native browser control */ +.wu-styling .wu-radio-input { + appearance: none; + -webkit-appearance: none; + flex-shrink: 0; + width: 18px; + height: 18px; + border: 2px solid var(--wu-input-border-color); + border-radius: 50%; + margin: 0; + cursor: pointer; + background-color: var(--wu-input-bg); + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.wu-styling .wu-radio-input:focus-visible { + outline: 2px solid var(--wu-accent-color); + outline-offset: 2px; +} + +/* Filled dot drawn via layered box-shadow — no pseudo-elements needed */ +.wu-styling .wu-radio-input:checked { + border-color: var(--wu-accent-color); + box-shadow: + inset 0 0 0 3px var(--wu-input-bg), + inset 0 0 0 10px var(--wu-accent-color); +} + +/* Ensure images (e.g. PayPal logo) inside a radio option align neatly */ +.wu-styling .wu-radio-option img { + vertical-align: middle; + max-height: 20px; +} diff --git a/assets/js/payment-status-poll.js b/assets/js/payment-status-poll.js new file mode 100644 index 000000000..425cc5c44 --- /dev/null +++ b/assets/js/payment-status-poll.js @@ -0,0 +1,72 @@ +(() => { +"use strict"; + +const config = window.wu_payment_poll; + +if (!config || !config.should_poll) { + return; +} + +let attempts = 0; +const max_attempts = parseInt(config.max_attempts, 10) || 20; +const poll_interval = parseInt(config.poll_interval, 10) || 3000; + +function show_status(message, css_class) { + const el = document.querySelector(config.status_selector); + if (!el) { + return; + } + el.textContent = message; + el.className = "wu-payment-status wu-p-3 wu-rounded wu-mt-3 wu-block wu-text-sm " + (css_class || ""); + el.style.display = "block"; +} + +async function check_payment_status() { + attempts++; + + if (attempts > max_attempts) { + show_status(config.messages.timeout, "wu-bg-yellow-100 wu-text-yellow-800"); + return; + } + + show_status(config.messages.checking, "wu-bg-gray-100 wu-text-gray-600"); + + try { + const params = new URLSearchParams({ + action: "wu_check_payment_status", + nonce: config.nonce, + payment_hash: config.payment_hash, + }); + + const response = await fetch(config.ajax_url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + const data = await response.json(); + + if (data.success && data.data.status === "completed") { + show_status(config.messages.completed, "wu-bg-green-100 wu-text-green-800"); + if (config.success_redirect) { + setTimeout(() => { window.location.href = config.success_redirect; }, 1500); + } else { + setTimeout(() => { window.location.reload(); }, 1500); + } + return; + } + + // Still pending — continue polling + show_status(config.messages.pending, "wu-bg-blue-100 wu-text-blue-800"); + setTimeout(check_payment_status, poll_interval); + + } catch (_e) { + show_status(config.messages.error, "wu-bg-red-100 wu-text-red-800"); + setTimeout(check_payment_status, poll_interval); + } +} + +document.addEventListener("DOMContentLoaded", () => { + setTimeout(check_payment_status, poll_interval); +}); +})(); diff --git a/assets/js/thank-you.js b/assets/js/thank-you.js index a39df1b7d..928a41626 100644 --- a/assets/js/thank-you.js +++ b/assets/js/thank-you.js @@ -138,11 +138,16 @@ document.addEventListener("DOMContentLoaded", () => { if (response.publish_status === "completed") { this.creating = false; this.site_ready = true; - // Reload with cache buster so CDN/cache plugins don't serve stale page - setTimeout(() => { - var sep = window.location.href.indexOf("?") > -1 ? "&" : "?"; - window.location.href = window.location.href.split("#")[0] + sep + "_t=" + Date.now(); - }, 1500); + // Only reload to bust cache if we actually watched the site transition + // through "running" during this page load. Without this guard, PayPal + // (and any other gateway that completes before the thank-you page loads) + // would trigger a reload on every poll, causing an infinite refresh loop. + if (this.running_count > 0) { + setTimeout(() => { + var sep = window.location.href.indexOf("?") > -1 ? "&" : "?"; + window.location.href = window.location.href.split("#")[0] + sep + "_t=" + Date.now(); + }, 1500); + } } else if (response.publish_status === "running") { this.creating = true; this.stopped_count = 0; diff --git a/assets/js/wu-password-strength.js b/assets/js/wu-password-strength.js index bc94e5a4a..59f2c322c 100644 --- a/assets/js/wu-password-strength.js +++ b/assets/js/wu-password-strength.js @@ -283,6 +283,15 @@ colorClass = 'wu-bg-green-300 wu-border-green-400'; label = this.getStrengthLabel('super_strong'); } + } else if (strength === 4 && strength !== 5) { + // Even when enforce_rules is off, show Super Strong if the password + // voluntarily meets all the super-strong criteria. + const password = this.options.pass1.val(); + + if (this.checkSuperStrongRules(password)) { + colorClass = 'wu-bg-green-300 wu-border-green-400'; + label = this.getStrengthLabel('super_strong'); + } } this.options.result.addClass(colorClass).html(label); @@ -394,6 +403,23 @@ }; }, + /** + * Check if a password meets all super-strong criteria unconditionally. + * + * Used to display "Super Strong" even when the site minimum is set below + * super_strong — a reward for users who go above and beyond. + * + * @param {string} password + * @return {boolean} + */ + checkSuperStrongRules(password) { + return password.length >= 12 + && /[A-Z]/.test(password) + && /[a-z]/.test(password) + && /[0-9]/.test(password) + && /[!@#$%^&*()_+\-={};:'",.<>?~\[\]\/|`\\]/.test(password); + }, + /** * Get failed rules for external access. * diff --git a/composer.lock b/composer.lock index dfef53f38..7d0ff3976 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8e1defa893f294810d0c11b03a06a5a6", + "content-hash": "18f34769e134da8fbdca41b881a8f77f", "packages": [ { "name": "amphp/amp", @@ -1463,16 +1463,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -1488,6 +1488,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -1559,7 +1560,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -1575,7 +1576,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "ifsnop/mysqldump-php", @@ -2158,16 +2159,16 @@ }, { "name": "mpdf/mpdf", - "version": "v8.2.7", + "version": "v8.3.1", "source": { "type": "git", "url": "https://github.com/mpdf/mpdf.git", - "reference": "b59670a09498689c33ce639bac8f5ba26721dab3" + "reference": "2a454ec334109911fdb323a284c19dbf3f049810" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mpdf/mpdf/zipball/b59670a09498689c33ce639bac8f5ba26721dab3", - "reference": "b59670a09498689c33ce639bac8f5ba26721dab3", + "url": "https://api.github.com/repos/mpdf/mpdf/zipball/2a454ec334109911fdb323a284c19dbf3f049810", + "reference": "2a454ec334109911fdb323a284c19dbf3f049810", "shasum": "" }, "require": { @@ -2191,6 +2192,7 @@ }, "suggest": { "ext-bcmath": "Needed for generation of some types of barcodes", + "ext-imagick": "Needed if developing the Mpdf library", "ext-xml": "Needed mainly for SVG manipulation", "ext-zlib": "Needed for compression of embedded resources, such as fonts" }, @@ -2235,7 +2237,7 @@ "type": "custom" } ], - "time": "2025-12-01T10:18:02+00:00" + "time": "2026-03-11T10:58:44+00:00" }, { "name": "mpdf/psr-http-message-shim", @@ -2522,16 +2524,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "5.6.7", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", "shasum": "" }, "require": { @@ -2580,9 +2582,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-18T20:47:46+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -3380,27 +3382,27 @@ }, { "name": "setasign/fpdi", - "version": "v2.6.4", + "version": "v2.6.6", "source": { "type": "git", "url": "https://github.com/Setasign/FPDI.git", - "reference": "4b53852fde2734ec6a07e458a085db627c60eada" + "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada", - "reference": "4b53852fde2734ec6a07e458a085db627c60eada", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/de0cf35911be3e9ea63b48e0f307883b1c7c48ac", + "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac", "shasum": "" }, "require": { "ext-zlib": "*", - "php": "^7.1 || ^8.0" + "php": ">=7.2 <=8.5.99999" }, "conflict": { "setasign/tfpdf": "<1.31" }, "require-dev": { - "phpunit/phpunit": "^7", + "phpunit/phpunit": "^8.5.52", "setasign/fpdf": "~1.8.6", "setasign/tfpdf": "~1.33", "squizlabs/php_codesniffer": "^3.5", @@ -3440,7 +3442,7 @@ ], "support": { "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.6.4" + "source": "https://github.com/Setasign/FPDI/tree/v2.6.6" }, "funding": [ { @@ -3448,7 +3450,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T09:57:14+00:00" + "time": "2026-03-13T08:38:20+00:00" }, { "name": "spatie/dns", @@ -5899,16 +5901,16 @@ }, { "name": "mck89/peast", - "version": "v1.17.4", + "version": "v1.17.5", "source": { "type": "git", "url": "https://github.com/mck89/peast.git", - "reference": "c6a63f32410d2e4ee2cd20fe94b35af147fb852d" + "reference": "e19a8bd896b7f04941a38fd38a140c9a6531c84f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mck89/peast/zipball/c6a63f32410d2e4ee2cd20fe94b35af147fb852d", - "reference": "c6a63f32410d2e4ee2cd20fe94b35af147fb852d", + "url": "https://api.github.com/repos/mck89/peast/zipball/e19a8bd896b7f04941a38fd38a140c9a6531c84f", + "reference": "e19a8bd896b7f04941a38fd38a140c9a6531c84f", "shasum": "" }, "require": { @@ -5921,7 +5923,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.17.4-dev" + "dev-master": "1.17.5-dev" } }, "autoload": { @@ -5942,9 +5944,9 @@ "description": "Peast is PHP library that generates AST for JavaScript code", "support": { "issues": "https://github.com/mck89/peast/issues", - "source": "https://github.com/mck89/peast/tree/v1.17.4" + "source": "https://github.com/mck89/peast/tree/v1.17.5" }, - "time": "2025-10-10T12:53:17+00:00" + "time": "2026-03-15T10:47:07+00:00" }, { "name": "nb/oxymel", @@ -6657,11 +6659,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.44", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", "shasum": "" }, "require": { @@ -6706,7 +6708,7 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-03-25T17:34:21+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7140,22 +7142,27 @@ }, { "name": "psr/container", - "version": "1.1.2", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { "php": ">=7.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { "Psr\\Container\\": "src/" @@ -7182,9 +7189,9 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2021-11-05T16:50:12+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "react/promise", @@ -7261,21 +7268,21 @@ }, { "name": "rector/rector", - "version": "2.3.8", + "version": "2.3.9", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" + "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4", + "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.38" + "phpstan/phpstan": "^2.1.40" }, "conflict": { "rector/rector-doctrine": "*", @@ -7309,7 +7316,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.8" + "source": "https://github.com/rectorphp/rector/tree/2.3.9" }, "funding": [ { @@ -7317,7 +7324,7 @@ "type": "github" } ], - "time": "2026-02-22T09:45:50+00:00" + "time": "2026-03-16T09:43:55+00:00" }, { "name": "sebastian/cli-parser", @@ -9228,37 +9235,29 @@ }, { "name": "symfony/service-contracts", - "version": "v2.5.4", + "version": "v1.1.2", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f37b419f7aea2e9abf10abd261832cace12e3300" + "reference": "191afdcb5804db960d26d8566b7e9a2843cab3a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f37b419f7aea2e9abf10abd261832cace12e3300", - "reference": "f37b419f7aea2e9abf10abd261832cace12e3300", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/191afdcb5804db960d26d8566b7e9a2843cab3a0", + "reference": "191afdcb5804db960d26d8566b7e9a2843cab3a0", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" + "php": "^7.1.3" }, "suggest": { + "psr/container": "", "symfony/service-implementation": "" }, "type": "library", "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, "branch-alias": { - "dev-main": "2.5-dev" + "dev-master": "1.1-dev" } }, "autoload": { @@ -9291,23 +9290,9 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.4" + "source": "https://github.com/symfony/service-contracts/tree/v1.1.2" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:11:13+00:00" + "time": "2019-05-28T07:50:59+00:00" }, { "name": "symfony/string", @@ -10322,16 +10307,16 @@ }, { "name": "wp-cli/eval-command", - "version": "v2.2.7", + "version": "v2.2.9", "source": { "type": "git", "url": "https://github.com/wp-cli/eval-command.git", - "reference": "2fb2a9d40861741eafaa1df86ed0dbd62de6e5ca" + "reference": "827c7208c74ebd6ab81c6051f515381d4f276e32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/eval-command/zipball/2fb2a9d40861741eafaa1df86ed0dbd62de6e5ca", - "reference": "2fb2a9d40861741eafaa1df86ed0dbd62de6e5ca", + "url": "https://api.github.com/repos/wp-cli/eval-command/zipball/827c7208c74ebd6ab81c6051f515381d4f276e32", + "reference": "827c7208c74ebd6ab81c6051f515381d4f276e32", "shasum": "" }, "require": { @@ -10374,22 +10359,22 @@ "homepage": "https://github.com/wp-cli/eval-command", "support": { "issues": "https://github.com/wp-cli/eval-command/issues", - "source": "https://github.com/wp-cli/eval-command/tree/v2.2.7" + "source": "https://github.com/wp-cli/eval-command/tree/v2.2.9" }, - "time": "2025-12-02T18:17:50+00:00" + "time": "2026-03-18T09:03:46+00:00" }, { "name": "wp-cli/export-command", - "version": "v2.1.15", + "version": "v2.1.16", "source": { "type": "git", "url": "https://github.com/wp-cli/export-command.git", - "reference": "84a335ca6e4296aff130659642818473a9b0d90d" + "reference": "cf85ae0105617c106a0c8d6b9f77bc4983140707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/export-command/zipball/84a335ca6e4296aff130659642818473a9b0d90d", - "reference": "84a335ca6e4296aff130659642818473a9b0d90d", + "url": "https://api.github.com/repos/wp-cli/export-command/zipball/cf85ae0105617c106a0c8d6b9f77bc4983140707", + "reference": "cf85ae0105617c106a0c8d6b9f77bc4983140707", "shasum": "" }, "require": { @@ -10437,9 +10422,9 @@ "homepage": "https://github.com/wp-cli/export-command", "support": { "issues": "https://github.com/wp-cli/export-command/issues", - "source": "https://github.com/wp-cli/export-command/tree/v2.1.15" + "source": "https://github.com/wp-cli/export-command/tree/v2.1.16" }, - "time": "2026-02-12T12:26:09+00:00" + "time": "2026-03-17T08:25:40+00:00" }, { "name": "wp-cli/extension-command", @@ -10541,16 +10526,16 @@ }, { "name": "wp-cli/i18n-command", - "version": "v2.6.6", + "version": "v2.7.0", "source": { "type": "git", "url": "https://github.com/wp-cli/i18n-command.git", - "reference": "94f72ddc4be8919f2cea181ba39cd140dd480d64" + "reference": "e91e6903d212486e32ed2c916171f661bfc539ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/i18n-command/zipball/94f72ddc4be8919f2cea181ba39cd140dd480d64", - "reference": "94f72ddc4be8919f2cea181ba39cd140dd480d64", + "url": "https://api.github.com/repos/wp-cli/i18n-command/zipball/e91e6903d212486e32ed2c916171f661bfc539ce", + "reference": "e91e6903d212486e32ed2c916171f661bfc539ce", "shasum": "" }, "require": { @@ -10572,6 +10557,7 @@ "bundled": true, "commands": [ "i18n", + "i18n audit", "i18n make-pot", "i18n make-json", "i18n make-mo", @@ -10604,22 +10590,22 @@ "homepage": "https://github.com/wp-cli/i18n-command", "support": { "issues": "https://github.com/wp-cli/i18n-command/issues", - "source": "https://github.com/wp-cli/i18n-command/tree/v2.6.6" + "source": "https://github.com/wp-cli/i18n-command/tree/v2.7.0" }, - "time": "2025-11-21T04:23:34+00:00" + "time": "2026-03-16T17:13:39+00:00" }, { "name": "wp-cli/import-command", - "version": "v2.0.15", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/wp-cli/import-command.git", - "reference": "277de5a245cbf846ec822e23067703c7e3b9cb48" + "reference": "64033264b9f4b9c9a32d14e33b365a58de6f3bf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/import-command/zipball/277de5a245cbf846ec822e23067703c7e3b9cb48", - "reference": "277de5a245cbf846ec822e23067703c7e3b9cb48", + "url": "https://api.github.com/repos/wp-cli/import-command/zipball/64033264b9f4b9c9a32d14e33b365a58de6f3bf6", + "reference": "64033264b9f4b9c9a32d14e33b365a58de6f3bf6", "shasum": "" }, "require": { @@ -10665,9 +10651,9 @@ "homepage": "https://github.com/wp-cli/import-command", "support": { "issues": "https://github.com/wp-cli/import-command/issues", - "source": "https://github.com/wp-cli/import-command/tree/v2.0.15" + "source": "https://github.com/wp-cli/import-command/tree/v2.0.16" }, - "time": "2025-12-09T15:41:55+00:00" + "time": "2026-03-16T15:17:43+00:00" }, { "name": "wp-cli/language-command", @@ -10812,16 +10798,16 @@ }, { "name": "wp-cli/media-command", - "version": "v2.2.4", + "version": "v2.2.5", "source": { "type": "git", "url": "https://github.com/wp-cli/media-command.git", - "reference": "1e896733998450f3cb8c1baba4de64804c3d549e" + "reference": "5696bba2e8c7d5c373fa20024edb1a4b682d1511" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/media-command/zipball/1e896733998450f3cb8c1baba4de64804c3d549e", - "reference": "1e896733998450f3cb8c1baba4de64804c3d549e", + "url": "https://api.github.com/repos/wp-cli/media-command/zipball/5696bba2e8c7d5c373fa20024edb1a4b682d1511", + "reference": "5696bba2e8c7d5c373fa20024edb1a4b682d1511", "shasum": "" }, "require": { @@ -10837,6 +10823,7 @@ "bundled": true, "commands": [ "media", + "media fix-orientation", "media import", "media regenerate", "media image-size" @@ -10868,9 +10855,9 @@ "homepage": "https://github.com/wp-cli/media-command", "support": { "issues": "https://github.com/wp-cli/media-command/issues", - "source": "https://github.com/wp-cli/media-command/tree/v2.2.4" + "source": "https://github.com/wp-cli/media-command/tree/v2.2.5" }, - "time": "2026-01-27T02:54:42+00:00" + "time": "2026-03-04T13:53:32+00:00" }, { "name": "wp-cli/mustache", @@ -10926,16 +10913,16 @@ }, { "name": "wp-cli/mustangostang-spyc", - "version": "0.6.3", + "version": "0.6.6", "source": { "type": "git", "url": "https://github.com/wp-cli/spyc.git", - "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7" + "reference": "30f25baaaba939caaff1f4b8c7ed998632f59fe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/spyc/zipball/6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", - "reference": "6aa0b4da69ce9e9a2c8402dab8d43cf32c581cc7", + "url": "https://api.github.com/repos/wp-cli/spyc/zipball/30f25baaaba939caaff1f4b8c7ed998632f59fe2", + "reference": "30f25baaaba939caaff1f4b8c7ed998632f59fe2", "shasum": "" }, "require": { @@ -10971,9 +10958,9 @@ "description": "A simple YAML loader/dumper class for PHP (WP-CLI fork)", "homepage": "https://github.com/mustangostang/spyc/", "support": { - "source": "https://github.com/wp-cli/spyc/tree/autoload" + "source": "https://github.com/wp-cli/spyc/tree/0.6.6" }, - "time": "2017-04-25T11:26:20+00:00" + "time": "2026-03-12T12:30:41+00:00" }, { "name": "wp-cli/package-command", @@ -11042,16 +11029,16 @@ }, { "name": "wp-cli/php-cli-tools", - "version": "v0.12.7", + "version": "v0.12.8", "source": { "type": "git", "url": "https://github.com/wp-cli/php-cli-tools.git", - "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a" + "reference": "9cbf9946ebe3462005b642de69ccd65753981517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", - "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a", + "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/9cbf9946ebe3462005b642de69ccd65753981517", + "reference": "9cbf9946ebe3462005b642de69ccd65753981517", "shasum": "" }, "require": { @@ -11099,9 +11086,9 @@ ], "support": { "issues": "https://github.com/wp-cli/php-cli-tools/issues", - "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.7" + "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.8" }, - "time": "2026-01-20T20:31:49+00:00" + "time": "2026-03-16T15:18:29+00:00" }, { "name": "wp-cli/rewrite-command", @@ -11298,16 +11285,16 @@ }, { "name": "wp-cli/search-replace-command", - "version": "v2.1.9", + "version": "v2.1.11", "source": { "type": "git", "url": "https://github.com/wp-cli/search-replace-command.git", - "reference": "14aea81eca68effbc651d5fca4891a89c0667b2e" + "reference": "a04ff12b2077aae88ebb4075f8bab7f959c08927" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/search-replace-command/zipball/14aea81eca68effbc651d5fca4891a89c0667b2e", - "reference": "14aea81eca68effbc651d5fca4891a89c0667b2e", + "url": "https://api.github.com/repos/wp-cli/search-replace-command/zipball/a04ff12b2077aae88ebb4075f8bab7f959c08927", + "reference": "a04ff12b2077aae88ebb4075f8bab7f959c08927", "shasum": "" }, "require": { @@ -11352,9 +11339,9 @@ "homepage": "https://github.com/wp-cli/search-replace-command", "support": { "issues": "https://github.com/wp-cli/search-replace-command/issues", - "source": "https://github.com/wp-cli/search-replace-command/tree/v2.1.9" + "source": "https://github.com/wp-cli/search-replace-command/tree/v2.1.11" }, - "time": "2025-11-11T13:31:01+00:00" + "time": "2026-03-18T08:50:38+00:00" }, { "name": "wp-cli/server-command", @@ -11534,16 +11521,16 @@ }, { "name": "wp-cli/widget-command", - "version": "v2.2.0", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/wp-cli/widget-command.git", - "reference": "6f04d7e0129e0fb280cfc4931bbd40478e743871" + "reference": "d5faa8f5b47828b2c103e9411fb52d4a63b53b99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/widget-command/zipball/6f04d7e0129e0fb280cfc4931bbd40478e743871", - "reference": "6f04d7e0129e0fb280cfc4931bbd40478e743871", + "url": "https://api.github.com/repos/wp-cli/widget-command/zipball/d5faa8f5b47828b2c103e9411fb52d4a63b53b99", + "reference": "d5faa8f5b47828b2c103e9411fb52d4a63b53b99", "shasum": "" }, "require": { @@ -11563,9 +11550,12 @@ "widget delete", "widget list", "widget move", + "widget patch", "widget reset", "widget update", "sidebar", + "sidebar exists", + "sidebar get", "sidebar list" ], "branch-alias": { @@ -11595,9 +11585,9 @@ "homepage": "https://github.com/wp-cli/widget-command", "support": { "issues": "https://github.com/wp-cli/widget-command/issues", - "source": "https://github.com/wp-cli/widget-command/tree/v2.2.0" + "source": "https://github.com/wp-cli/widget-command/tree/v2.2.1" }, - "time": "2026-02-12T12:26:33+00:00" + "time": "2026-03-17T12:28:44+00:00" }, { "name": "wp-cli/wp-cli", @@ -11744,16 +11734,16 @@ }, { "name": "wp-cli/wp-config-transformer", - "version": "v1.4.4", + "version": "v1.4.5", "source": { "type": "git", "url": "https://github.com/wp-cli/wp-config-transformer.git", - "reference": "b0fda07aac51317404f5e56dc8953ea899bc7bce" + "reference": "8f5e66c717a7371dfb6559086880bee528aee858" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/wp-config-transformer/zipball/b0fda07aac51317404f5e56dc8953ea899bc7bce", - "reference": "b0fda07aac51317404f5e56dc8953ea899bc7bce", + "url": "https://api.github.com/repos/wp-cli/wp-config-transformer/zipball/8f5e66c717a7371dfb6559086880bee528aee858", + "reference": "8f5e66c717a7371dfb6559086880bee528aee858", "shasum": "" }, "require": { @@ -11787,9 +11777,9 @@ "homepage": "https://github.com/wp-cli/wp-config-transformer", "support": { "issues": "https://github.com/wp-cli/wp-config-transformer/issues", - "source": "https://github.com/wp-cli/wp-config-transformer/tree/v1.4.4" + "source": "https://github.com/wp-cli/wp-config-transformer/tree/v1.4.5" }, - "time": "2026-01-22T09:07:20+00:00" + "time": "2026-03-20T07:28:10+00:00" }, { "name": "wp-coding-standards/wpcs", diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index 58b162526..ce70bb318 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -3000,6 +3000,7 @@ public function to_payment_data() { // Creates the pending payment $payment_data = [ 'status' => 'pending', + 'currency' => $this->get_currency(), 'tax_total' => $this->get_total_taxes(), 'fees' => $this->get_total_fees(), 'discounts' => $this->get_total_discounts(), diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 3e5d54124..4a9b4962d 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -833,18 +833,21 @@ public function maybe_enqueue_payment_status_poll(): void { $gateway_id = $membership ? $membership->get_gateway() : ''; } - // Only poll for Stripe payments that are still pending - $is_stripe_payment = in_array($gateway_id, ['stripe', 'stripe-checkout'], true); - $is_pending = $payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::PENDING; + $is_pending = $payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::PENDING; - if (! $is_stripe_payment) { + // Ask the gateway itself — no hardcoded list here, third-party gateways can opt in. + $gateway = wu_get_gateway($gateway_id); + + if (! $gateway || ! $gateway->supports_payment_polling()) { return; } + $pending_message = __('Verifying your payment...', 'ultimate-multisite'); + wp_register_script( 'wu-payment-status-poll', wu_get_asset('payment-status-poll.js', 'js'), - ['jquery'], + [], wu_get_version(), true ); @@ -863,7 +866,7 @@ public function maybe_enqueue_payment_status_poll(): void { 'success_redirect' => '', 'messages' => [ 'completed' => __('Payment confirmed! Refreshing page...', 'ultimate-multisite'), - 'pending' => __('Verifying your payment with Stripe...', 'ultimate-multisite'), + 'pending' => $pending_message, 'timeout' => __('Payment verification is taking longer than expected. Your payment may still be processing. Please refresh the page or contact support if you believe payment was made.', 'ultimate-multisite'), 'error' => __('Error checking payment status. Retrying...', 'ultimate-multisite'), 'checking' => __('Checking payment status...', 'ultimate-multisite'), diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 8f9baaf77..357072e5a 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -2997,6 +2997,19 @@ public function maybe_display_checkout_errors(): void { if (wu_request('status') !== 'error') { return; } + + $message = wu_request('wu_error_msg'); + + if (empty($message)) { + return; + } + + $message = sanitize_text_field(rawurldecode($message)); + + printf( + '
wp mcp-adapter serve --server=mcp-adapter-default-server --user=admin',
- 'require' => [
+ 'title' => __('STDIO Command', 'ultimate-multisite'),
+ 'desc' => '',
+ 'tooltip' => __('This is the WP-CLI command to run the MCP server via STDIO transport.', 'ultimate-multisite'),
+ 'copy' => true,
+ 'type' => 'text-display',
+ 'display_value' => 'wp mcp-adapter serve --server=mcp-adapter-default-server --user=admin',
+ 'require' => [
'enable_mcp' => 1,
],
]
diff --git a/inc/class-scripts.php b/inc/class-scripts.php
index 8f3943bc9..5a7feeb46 100644
--- a/inc/class-scripts.php
+++ b/inc/class-scripts.php
@@ -201,7 +201,7 @@ public function register_default_scripts(): void {
'currency_position' => wu_get_setting('currency_position', '%s %v'),
'decimal_separator' => wu_get_setting('decimal_separator', '.'),
'thousand_separator' => wu_get_setting('thousand_separator', ','),
- 'precision' => wu_get_setting('precision', 2),
+ 'precision' => wu_currency_decimal_filter(wu_get_setting('precision', 2)),
'use_container' => get_user_setting('wu_use_container', false),
'disable_image_zoom' => wu_get_setting('disable_image_zoom', false),
]
@@ -529,6 +529,7 @@ protected function get_password_requirements(): array {
// Map setting to zxcvbn score.
$strength_map = [
+ 'weak' => 2,
'medium' => 3,
'strong' => 4,
'super_strong' => 4,
diff --git a/inc/class-settings.php b/inc/class-settings.php
index f682a6e6b..171abfc26 100644
--- a/inc/class-settings.php
+++ b/inc/class-settings.php
@@ -904,10 +904,11 @@ public function default_sections(): void {
'minimum_password_strength',
[
'title' => __('Minimum Password Strength', 'ultimate-multisite'),
- 'desc' => __('Set the minimum password strength required during registration and password reset. "Super Strong" requires at least 12 characters, including uppercase, lowercase, numbers, and special characters.', 'ultimate-multisite'),
+ 'desc' => __('Set the minimum password strength required during registration and password reset. "Weak" allows most passwords; "Medium" rejects common patterns (e.g. P@ssw0rd); "Super Strong" requires 12+ characters with mixed case, numbers, and symbols.', 'ultimate-multisite'),
'type' => 'select',
'default' => 'medium',
'options' => [
+ 'weak' => __('Weak', 'ultimate-multisite'),
'medium' => __('Medium', 'ultimate-multisite'),
'strong' => __('Strong', 'ultimate-multisite'),
'super_strong' => __('Super Strong (12+ chars, mixed case, numbers, symbols)', 'ultimate-multisite'),
diff --git a/inc/functions/currency.php b/inc/functions/currency.php
index 6ecd133e4..72dc7c4e9 100644
--- a/inc/functions/currency.php
+++ b/inc/functions/currency.php
@@ -425,7 +425,11 @@ function wu_format_currency($value, $currency = null, $format = null, $thousands
$currency_symbol = wu_get_currency_symbol($atts['currency']);
- $value = number_format($value, $atts['precision'], $atts['decimal_sep'], $atts['thousands_sep']);
+ // Let wu_currency_decimal_filter override precision for zero-decimal currencies
+ // (and give the multi-currency addon a chance to set 3 decimals for e.g. KWD).
+ $precision = wu_currency_decimal_filter($atts['precision'], $atts['currency']);
+
+ $value = number_format($value, $precision, $atts['decimal_sep'], $atts['thousands_sep']);
$format = str_replace('%v', $value, (string) $atts['format']);
$format = str_replace('%s', $currency_symbol, $format);
@@ -448,35 +452,45 @@ function wu_is_zero_decimal_currency($currency = 'USD') {
'CLP', // Chilean Peso
'DJF', // Djiboutian Franc
'GNF', // Guinean Franc
+ 'HUF', // Hungarian Forint (PayPal treats as zero-decimal)
+ 'ISK', // Icelandic Króna
'JPY', // Japanese Yen
'KMF', // Comorian Franc
'KRW', // South Korean Won
+ 'IRR', // Iranian Rial
'MGA', // Malagasy Ariary
'PYG', // Paraguayan Guarani
'RWF', // Rwandan Franc
+ 'UGX', // Ugandan Shilling
'VND', // Vietnamese Dong
'VUV', // Vanuatu Vatu
'XAF', // Central African CFA Franc
'XOF', // West African CFA Franc
'XPF', // CFP Franc
- 'IRR', // Iranian Rial
];
- return apply_filters('wu_is_zero_decimal_currency', in_array($currency, $zero_dec_currencies, true));
+ return apply_filters('wu_is_zero_decimal_currency', in_array(strtoupper($currency), $zero_dec_currencies, true), $currency);
}
/**
- * Sets the number of decimal places based on the currency.
+ * Returns the correct number of decimal places for the given currency.
*
- * @param int $decimals The number of decimal places. Default is 2.
+ * Zero-decimal currencies always return 0 regardless of the $decimals argument.
+ * The `wu_currency_decimal_filter` filter lets the multi-currency addon (or any
+ * other code) override this further — e.g. to support 3-decimal currencies like
+ * KWD, BHD, OMR.
*
- * @todo add the missing currency parameter?
- * @since 2.0.0
+ * @param int $decimals Default number of decimal places (typically the site setting).
+ * @param string $currency ISO 4217 currency code. Defaults to the site currency setting.
+ *
+ * @since 2.0.0
* @return int The number of decimal places.
*/
-function wu_currency_decimal_filter($decimals = 2) {
+function wu_currency_decimal_filter($decimals = 2, $currency = '') {
- $currency = 'USD';
+ if (empty($currency)) {
+ $currency = wu_get_setting('currency_symbol', 'USD');
+ }
if (wu_is_zero_decimal_currency($currency)) {
$decimals = 0;
diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php
index fb7d5ec50..ca1bd6596 100644
--- a/inc/gateways/class-base-gateway.php
+++ b/inc/gateways/class-base-gateway.php
@@ -555,6 +555,49 @@ public function process_webhooks() {}
*/
public function process_confirmation() {}
+ /**
+ * Whether this gateway supports client-side payment status polling.
+ *
+ * When this returns true, the thank-you page will poll the gateway to
+ * confirm a pending payment without relying on webhooks. Useful for
+ * environments where the webhook endpoint is not publicly reachable
+ * (e.g. local development) and for production resilience.
+ *
+ * Gateways that return true must also implement verify_and_complete_payment().
+ *
+ * @since 2.0.0
+ * @return bool
+ */
+ public function supports_payment_polling(): bool {
+
+ return false;
+ }
+
+ /**
+ * Verify a pending payment directly with the gateway and mark it complete if confirmed.
+ *
+ * Called by the AJAX payment-status polling endpoint as a webhook fallback.
+ * Only invoked when supports_payment_polling() returns true.
+ *
+ * Return array keys:
+ * - success (bool) — true if the payment was confirmed as complete.
+ * - status (string) — 'completed' or 'pending'.
+ * - message (string) — human-readable status description.
+ *
+ * @since 2.0.0
+ *
+ * @param int $payment_id The local payment ID to verify.
+ * @return array{success: bool, status: string, message: string}
+ */
+ public function verify_and_complete_payment(int $payment_id): array {
+
+ return [
+ 'success' => false,
+ 'status' => 'pending',
+ 'message' => __('This gateway does not support payment verification.', 'ultimate-multisite'),
+ ];
+ }
+
/**
* Returns the external link to view the payment on the payment gateway.
*
@@ -765,6 +808,31 @@ public function get_confirm_url() {
);
}
+ /**
+ * Redirect back to the checkout page with an error message.
+ *
+ * @since 2.5.0
+ *
+ * @param string $message The error message.
+ * @return void
+ */
+ public function redirect_with_error(string $message): void {
+
+ $url = remove_query_arg(['wu-confirm', 'payment', 'token', 'PayerID', 'ba_token', 'subscription_id', 'status'], $this->return_url ?: wu_get_current_url());
+
+ $url = add_query_arg(
+ [
+ 'status' => 'error',
+ 'wu_error_msg' => rawurlencode($message),
+ ],
+ $url
+ );
+
+ wp_safe_redirect($url);
+
+ exit;
+ }
+
/**
* Returns the webhook url for the listener of this gateway events.
*
diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php
index 7bd534684..430ca74e9 100644
--- a/inc/gateways/class-base-stripe-gateway.php
+++ b/inc/gateways/class-base-stripe-gateway.php
@@ -1089,6 +1089,24 @@ public function fix_saving_settings($settings, $settings_to_save, $saved_setting
$id = wu_replace_dashes($this->get_id());
+ // Always preserve OAuth tokens so they are not wiped when unrelated settings are saved.
+ $oauth_keys = [
+ "{$id}_test_access_token",
+ "{$id}_test_refresh_token",
+ "{$id}_test_account_id",
+ "{$id}_test_publishable_key",
+ "{$id}_live_access_token",
+ "{$id}_live_refresh_token",
+ "{$id}_live_account_id",
+ "{$id}_live_publishable_key",
+ ];
+
+ foreach ($oauth_keys as $key) {
+ if (array_key_exists($key, $saved_settings)) {
+ $settings[ $key ] = $saved_settings[ $key ];
+ }
+ }
+
$active_gateways = (array) wu_get_isset($settings_to_save, 'active_gateways', []);
if ( ! in_array($this->get_id(), $active_gateways, true)) {
@@ -1413,7 +1431,7 @@ public function process_membership_update(&$membership, $customer) {
$credit = $credits[0];
}
- $s_amount = - round($credit['amount'] * wu_stripe_get_currency_multiplier());
+ $s_amount = - round($credit['amount'] * wu_stripe_get_currency_multiplier(strtoupper($membership->get_currency())));
if ($s_amount >= 1) {
$currency = strtolower($membership->get_currency());
@@ -1903,7 +1921,7 @@ protected function get_credit_coupon($cart) {
return false;
}
- $s_amount = - round($amount * wu_stripe_get_currency_multiplier());
+ $s_amount = - round($amount * wu_stripe_get_currency_multiplier(strtoupper($cart->get_currency())));
$currency = strtolower($cart->get_currency());
$coupon_data = [
@@ -1996,7 +2014,7 @@ protected function build_non_recurring_cart($cart, $include_recurring_products =
continue;
}
- $unit_amount = round($line_item->get_unit_price() * wu_stripe_get_currency_multiplier());
+ $unit_amount = round($line_item->get_unit_price() * wu_stripe_get_currency_multiplier(strtoupper($cart->get_currency())));
/*
* Skip zero-amount items.
@@ -2472,7 +2490,7 @@ public function process_refund($amount, $payment, $membership, $customer): bool
* for Stripe, which usually works
* in cents.
*/
- $normalize_amount = $amount * wu_stripe_get_currency_multiplier();
+ $normalize_amount = $amount * wu_stripe_get_currency_multiplier(strtoupper($payment->get_currency()));
$this->get_stripe_client()->refunds->create(
[
@@ -2749,7 +2767,7 @@ public function process_webhooks() {
* Last ditch effort to retrieve a valid membership.
*/
if (empty($membership) && ! empty($invoice)) {
- $amount = $invoice->amount_paid / wu_stripe_get_currency_multiplier();
+ $amount = $invoice->amount_paid / wu_stripe_get_currency_multiplier(strtoupper($invoice->currency ?? 'USD'));
$membership = wu_get_membership_by_customer_gateway_id($payment_event->customer, ['stripe', 'stripe-checkout'], $amount);
}
@@ -2847,16 +2865,17 @@ public function process_webhooks() {
* Successful one-time payment
*/
if (empty($payment_event->invoice)) {
- $payment_data['total'] = $payment_event->amount / wu_stripe_get_currency_multiplier();
+ $payment_data['total'] = $payment_event->amount / wu_stripe_get_currency_multiplier(strtoupper($payment_event->currency ?? 'USD'));
$payment_data['gateway_payment_id'] = $payment_event->id;
/*
* Subscription payment received.
*/
} else {
- $payment_data['total'] = $invoice->total / wu_stripe_get_currency_multiplier();
- $payment_data['subtotal'] = ($invoice->total_excluding_tax / wu_stripe_get_currency_multiplier()) - $payment_data['discount_total'];
- $payment_data['tax_total'] = $invoice->tax / wu_stripe_get_currency_multiplier();
+ $invoice_currency = strtoupper($invoice->currency ?? 'USD');
+ $payment_data['total'] = $invoice->total / wu_stripe_get_currency_multiplier($invoice_currency);
+ $payment_data['subtotal'] = ($invoice->total_excluding_tax / wu_stripe_get_currency_multiplier($invoice_currency)) - $payment_data['discount_total'];
+ $payment_data['tax_total'] = $invoice->tax / wu_stripe_get_currency_multiplier($invoice_currency);
$payment_data['gateway_payment_id'] = $payment_event->id;
if ( ! empty($payment_event->discount)) {
@@ -3093,7 +3112,7 @@ public function process_webhooks() {
/*
* Let's address the type.
*/
- $amount = $payment_event->amount_refunded / wu_stripe_get_currency_multiplier();
+ $amount = $payment_event->amount_refunded / wu_stripe_get_currency_multiplier(strtoupper($payment_event->currency ?? 'USD'));
/*
* Actually process the refund
@@ -3490,7 +3509,7 @@ public function maybe_create_plan($args) {
}
// Convert price to Stripe format.
- $price = round($args['price'] * wu_stripe_get_currency_multiplier(), 0);
+ $price = round($args['price'] * wu_stripe_get_currency_multiplier(strtoupper($args['currency'] ?? 'USD')), 0);
// First check to see if a plan exists with this ID. If so, return that.
try {
@@ -3648,7 +3667,7 @@ public function maybe_create_price($title, $amount, $currency, $quantity = 1, $d
$name = 1 === $quantity ? $title : "x$quantity $title";
$currency = strtolower($currency);
- $s_amount = round($amount * wu_stripe_get_currency_multiplier());
+ $s_amount = round($amount * wu_stripe_get_currency_multiplier(strtoupper($currency)));
$s_product = $this->maybe_create_product($name);
@@ -3835,6 +3854,15 @@ public function get_customer_url_on_gateway($gateway_customer_id): string {
return sprintf('https://dashboard.stripe.com%s/customers/%s', $route, $gateway_customer_id);
}
+ /**
+ * @inheritdoc
+ * @since 2.0.0
+ */
+ public function supports_payment_polling(): bool {
+
+ return true;
+ }
+
/**
* Verify and complete a pending payment by checking Stripe directly.
*
diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php
index 9f80355fc..f8d885998 100644
--- a/inc/gateways/class-paypal-rest-gateway.php
+++ b/inc/gateways/class-paypal-rest-gateway.php
@@ -178,6 +178,9 @@ public function hooks(): void {
// Initialize OAuth handler
PayPal_OAuth_Handler::get_instance()->init();
+ // Preserve hidden OAuth connection settings during general settings saves
+ add_filter('wu_pre_save_settings', [$this, 'preserve_oauth_settings'], 10, 3);
+
// Handle webhook installation after settings save
add_action('wu_after_save_settings', [$this, 'maybe_install_webhook'], 10, 3);
@@ -255,9 +258,9 @@ public function maybe_remove_for_unsupported_currency(array $gateways): array {
public function get_checkout_label_html(string $title): string {
return sprintf(
- '%s',
- esc_url('https://www.paypalobjects.com/webstatic/mktg/Logo/pp-logo-100px.png'),
- esc_html__('PayPal', 'ultimate-multisite')
+ '%s%s
', - esc_html__('No application fee — thank you for your support!', 'ultimate-multisite') - ); - } +// if (! \WP_Ultimo::get_instance()->get_addon_repository()->has_addon_purchase()) { +// printf( +// '%s
', +// esc_html__('No application fee — thank you for your support!', 'ultimate-multisite') +// ); +// } } /** @@ -1804,6 +1909,180 @@ function () use ($nonce, $sandbox) { ); } + /** + * Preserves PayPal OAuth connection settings during a general settings save. + * + * The settings save mechanism only persists registered fields. OAuth tokens and + * merchant connection data are stored separately via wu_save_setting() and must + * be carried forward so they are not wiped when unrelated settings are saved. + * + * @since 2.0.0 + * + * @param array $settings The settings being saved (built from registered fields only). + * @param array $settings_to_save Raw POST data. + * @param array $saved_settings The full settings array before this save. + * @return array + */ + public function preserve_oauth_settings(array $settings, array $settings_to_save, array $saved_settings): array { + + $oauth_keys = [ + 'paypal_rest_sandbox_merchant_id', + 'paypal_rest_sandbox_merchant_email', + 'paypal_rest_sandbox_payments_receivable', + 'paypal_rest_sandbox_email_confirmed', + 'paypal_rest_live_merchant_id', + 'paypal_rest_live_merchant_email', + 'paypal_rest_live_payments_receivable', + 'paypal_rest_live_email_confirmed', + 'paypal_rest_connected', + 'paypal_rest_connection_date', + 'paypal_rest_connection_mode', + ]; + + foreach ($oauth_keys as $key) { + if (array_key_exists($key, $saved_settings)) { + $settings[ $key ] = $saved_settings[ $key ]; + } + } + + return $settings; + } + + /** + * @inheritdoc + * @since 2.0.0 + */ + public function supports_payment_polling(): bool { + + return true; + } + + /** + * Verify and complete a pending payment by polling PayPal directly. + * + * Fallback for environments where webhooks cannot reach the server (e.g. local dev). + * Checks the subscription status on PayPal; if ACTIVE, marks the local payment COMPLETED + * and stamps the gateway_payment_id from the latest transaction. + * + * @since 2.0.0 + * + * @param int $payment_id The local payment ID to verify. + * @return array{success: bool, message: string, status?: string} + */ + public function verify_and_complete_payment(int $payment_id): array { + + $payment = wu_get_payment($payment_id); + + if (! $payment) { + return [ + 'success' => false, + 'message' => __('Payment not found.', 'ultimate-multisite'), + ]; + } + + if ($payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::COMPLETED) { + return [ + 'success' => true, + 'message' => __('Payment already completed.', 'ultimate-multisite'), + 'status' => 'completed', + ]; + } + + $membership = $payment->get_membership(); + + if (! $membership) { + return [ + 'success' => false, + 'message' => __('Membership not found.', 'ultimate-multisite'), + 'status' => $payment->get_status(), + ]; + } + + $subscription_id = $membership->get_gateway_subscription_id(); + + if (empty($subscription_id)) { + return [ + 'success' => false, + 'message' => __('No PayPal subscription ID found.', 'ultimate-multisite'), + 'status' => $payment->get_status(), + ]; + } + + // Ask PayPal for the current subscription status. + $subscription = $this->api_request('/v1/billing/subscriptions/' . $subscription_id, [], 'GET'); + + if (is_wp_error($subscription)) { + return [ + 'success' => false, + 'message' => $subscription->get_error_message(), + 'status' => $payment->get_status(), + ]; + } + + $status = $subscription['status'] ?? ''; + + if ('ACTIVE' !== $status && 'APPROVED' !== $status) { + return [ + 'success' => false, + 'message' => sprintf( + // translators: %s is the PayPal subscription status. + __('PayPal subscription status: %s', 'ultimate-multisite'), + $status + ), + 'status' => $payment->get_status(), + ]; + } + + if ('APPROVED' === $status) { + // First payment not yet captured by PayPal — still waiting. + return [ + 'success' => false, + 'message' => __('PayPal subscription approved, waiting for first payment to process.', 'ultimate-multisite'), + 'status' => 'pending', + ]; + } + + // Subscription is ACTIVE — try to find the transaction ID for the first payment. + $gateway_payment_id = ''; + + $start_time = gmdate('Y-m-d\TH:i:s\Z', strtotime('-1 day')); + $end_time = gmdate('Y-m-d\TH:i:s\Z'); + $transactions = $this->api_request( + sprintf('/v1/billing/subscriptions/%s/transactions?start_time=%s&end_time=%s', $subscription_id, $start_time, $end_time), + [], + 'GET' + ); + + if (! is_wp_error($transactions) && ! empty($transactions['transactions'])) { + $gateway_payment_id = $transactions['transactions'][0]['id'] ?? ''; + } + + // Mark the pending payment as completed. + if (! empty($gateway_payment_id)) { + $payment->set_gateway_payment_id($gateway_payment_id); + } + + $payment->set_status(\WP_Ultimo\Database\Payments\Payment_Status::COMPLETED); + $payment->save(); + + // Ensure membership customer ID is populated from the subscription if missing. + if (empty($membership->get_gateway_customer_id())) { + $payer_id = $subscription['subscriber']['payer_id'] ?? ''; + if (! empty($payer_id)) { + $membership->set_gateway_customer_id($payer_id); + $membership->save(); + } + } + + $this->log(sprintf('Payment %d verified and completed via polling. Subscription: %s', $payment_id, $subscription_id)); + + return [ + 'success' => true, + 'message' => __('Payment confirmed.', 'ultimate-multisite'), + 'status' => 'completed', + ]; + } + /** * Maybe install webhook after settings save. * diff --git a/inc/gateways/class-paypal-webhook-handler.php b/inc/gateways/class-paypal-webhook-handler.php index e571db1d0..1f67547f9 100644 --- a/inc/gateways/class-paypal-webhook-handler.php +++ b/inc/gateways/class-paypal-webhook-handler.php @@ -314,10 +314,10 @@ protected function handle_subscription_activated(array $event_data): void { return; } - // Update membership status if needed + // Update membership status if needed — use renew() so wu_membership_post_renew + // fires and triggers site creation / all post-activation hooks. if ($membership->get_status() !== Membership_Status::ACTIVE) { - $membership->set_status(Membership_Status::ACTIVE); - $membership->save(); + $membership->renew(true); $this->log(sprintf('Membership %d activated via webhook', $membership->get_id())); } @@ -454,7 +454,7 @@ protected function handle_payment_completed(array $event_data): void { return; } - // Check if this is a renewal payment (not initial) + // Check if we already recorded this exact payment $existing_payment = wu_get_payment_by('gateway_payment_id', $sale_id); if ($existing_payment) { @@ -462,31 +462,51 @@ protected function handle_payment_completed(array $event_data): void { return; } - // Create renewal payment - $payment_data = [ - 'customer_id' => $membership->get_customer_id(), - 'membership_id' => $membership->get_id(), - 'gateway' => 'paypal-rest', - 'gateway_payment_id' => $sale_id, - 'currency' => $currency, - 'subtotal' => (float) $amount, - 'total' => (float) $amount, - 'status' => Payment_Status::COMPLETED, - 'product_id' => $membership->get_plan_id(), - ]; + // Check for the original pending payment created during checkout (first payment only). + // When a subscription returns APPROVED, we leave the initial payment as PENDING and + // expect this webhook to confirm it rather than creating a duplicate. + $pending_payments = wu_get_payments([ + 'membership_id' => $membership->get_id(), + 'status' => Payment_Status::PENDING, + 'number' => 1, + ]); + + $payment = ! empty($pending_payments) ? $pending_payments[0] : null; + + if ($payment) { + // Confirm the existing pending payment + $payment->set_gateway_payment_id($sale_id); + $payment->set_status(Payment_Status::COMPLETED); + $payment->save(); - $payment = wu_create_payment($payment_data); + $this->log(sprintf('Confirmed pending payment %d for membership %d', $payment->get_id(), $membership->get_id())); + } else { + // No pending payment — create a renewal payment record + $payment_data = [ + 'customer_id' => $membership->get_customer_id(), + 'membership_id' => $membership->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_payment_id' => $sale_id, + 'currency' => $currency, + 'subtotal' => (float) $amount, + 'total' => (float) $amount, + 'status' => Payment_Status::COMPLETED, + 'product_id' => $membership->get_plan_id(), + ]; + + $payment = wu_create_payment($payment_data); + + if (is_wp_error($payment)) { + $this->log(sprintf('Failed to create renewal payment: %s', $payment->get_error_message()), LogLevel::ERROR); + return; + } - if (is_wp_error($payment)) { - $this->log(sprintf('Failed to create renewal payment: %s', $payment->get_error_message()), LogLevel::ERROR); - return; + $this->log(sprintf('Renewal payment created: %d for membership %d', $payment->get_id(), $membership->get_id())); } // Update membership $membership->add_to_times_billed(1); $membership->renew(false); - - $this->log(sprintf('Renewal payment created: %d for membership %d', $payment->get_id(), $membership->get_id())); } /** diff --git a/inc/gateways/class-stripe-checkout-gateway.php b/inc/gateways/class-stripe-checkout-gateway.php index 39c1e627a..c2953b13d 100644 --- a/inc/gateways/class-stripe-checkout-gateway.php +++ b/inc/gateways/class-stripe-checkout-gateway.php @@ -179,7 +179,7 @@ public function settings(): void { 'tooltip' => __('This is the URL Stripe should send webhook calls to.', 'ultimate-multisite'), 'type' => 'text-display', 'copy' => true, - 'default' => $this->get_webhook_listener_url(), + 'display_value' => $this->get_webhook_listener_url(), 'wrapper_classes' => '', 'require' => [ 'active_gateways' => 'stripe-checkout', diff --git a/inc/gateways/class-stripe-gateway.php b/inc/gateways/class-stripe-gateway.php index 0d158228b..7871ee558 100644 --- a/inc/gateways/class-stripe-gateway.php +++ b/inc/gateways/class-stripe-gateway.php @@ -287,10 +287,11 @@ public function settings(): void { 'tooltip' => __('This is the URL Stripe should send webhook calls to.', 'ultimate-multisite'), 'type' => 'text-display', 'copy' => true, - 'default' => $this->get_webhook_listener_url(), + 'display_value' => $this->get_webhook_listener_url(), 'wrapper_classes' => '', 'require' => [ 'active_gateways' => 'stripe', + 'stripe_show_direct_keys' => 1, ], ] ); diff --git a/inc/managers/class-gateway-manager.php b/inc/managers/class-gateway-manager.php index 79a87d118..a4086e06a 100644 --- a/inc/managers/class-gateway-manager.php +++ b/inc/managers/class-gateway-manager.php @@ -638,7 +638,6 @@ public function ajax_check_payment_status(): void { ); } - // Only try to verify Stripe payments $gateway_id = $payment->get_gateway(); if (empty($gateway_id)) { @@ -647,23 +646,14 @@ public function ajax_check_payment_status(): void { $gateway_id = $membership ? $membership->get_gateway() : ''; } - if (! in_array($gateway_id, ['stripe', 'stripe-checkout'], true)) { - wp_send_json_success( - [ - 'status' => $payment->get_status(), - 'message' => __('Non-Stripe payment, cannot verify.', 'ultimate-multisite'), - ] - ); - } - - // Get the gateway instance and verify + // Ask the gateway itself whether it supports polling — no hardcoded list. $gateway = wu_get_gateway($gateway_id); - if (! $gateway || ! method_exists($gateway, 'verify_and_complete_payment')) { + if (! $gateway || ! $gateway->supports_payment_polling()) { wp_send_json_success( [ 'status' => $payment->get_status(), - 'message' => __('Gateway does not support verification.', 'ultimate-multisite'), + 'message' => __('Gateway does not support payment verification.', 'ultimate-multisite'), ] ); } diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index dfec565ee..5c23f4a35 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -1899,9 +1899,18 @@ public function get_sites($include_pending = true) { $pending_site = $include_pending ? $this->get_pending_site() : false; if ($pending_site) { - $pending_site->set_type('pending'); + /* + * Skip the pending site if a real site already exists for this + * membership. This avoids a race condition where the async + * publish job has created the real blog but hasn't yet deleted + * the pending_site metadata, causing duplicates on the Thank + * You page. + */ + if (empty($sites)) { + $pending_site->set_type('pending'); - $sites[] = $pending_site; + $sites[] = $pending_site; + } } return $sites; @@ -2080,7 +2089,7 @@ public function publish_pending_site() { } if (is_wp_error($saved)) { - if ($saved->get_error_code() === 'site_taken') { + if (in_array($saved->get_error_code(), ['site_taken', 'blog_taken'], true)) { /* * If the site is already taken, we just delete the pending site. * This is a workaround for cases where the publish is called twice @@ -2500,14 +2509,18 @@ protected function has_gateway_changes() { $has_change = false; - $current_gateway = [ - 'gateway' => $this->get_gateway(), - 'gateway_customer_id' => $this->get_gateway_customer_id(), - 'gateway_subscription_id' => $this->get_gateway_subscription_id(), - ]; + // Only compare gateway and gateway_subscription_id — these identify + // whether an old external subscription needs to be cancelled. + // gateway_customer_id is excluded because it may be populated for the + // first time on return from PayPal (empty → payer_id) and should NOT + // trigger cancellation of the subscription we just confirmed. + $keys_to_compare = ['gateway', 'gateway_subscription_id']; + + foreach ($keys_to_compare as $key) { + $current_value = $key === 'gateway' ? $this->get_gateway() : $this->get_gateway_subscription_id(); + $snapshot_value = $this->gateway_info[ $key ] ?? null; - foreach ($this->gateway_info as $key => $value) { - if ($current_gateway[ $key ] !== $value) { + if ($current_value !== $snapshot_value) { $has_change = true; break; diff --git a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php index 926ea72eb..cf28961cf 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php @@ -573,7 +573,7 @@ public function test_render_oauth_connection_connected(): void { } /** - * Test render_oauth_connection includes fee notice. + * Test render_oauth_connection renders connect button. */ public function test_render_oauth_connection_fee_notice(): void { @@ -583,8 +583,8 @@ public function test_render_oauth_connection_fee_notice(): void { $this->gateway->render_oauth_connection(); $output = ob_get_clean(); - // Fee notice should be present (unless addon is purchased) - $this->assertStringContainsString('fee', strtolower($output)); + // Should render the connect button + $this->assertStringContainsString('wu-paypal-connect', strtolower($output)); } /** diff --git a/views/checkout/fields/field-payment-methods.php b/views/checkout/fields/field-payment-methods.php index ca6684e6b..ce6d96473 100644 --- a/views/checkout/fields/field-payment-methods.php +++ b/views/checkout/fields/field-payment-methods.php @@ -48,7 +48,7 @@ -