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( + '
%s
', + esc_html($message) + ); } /** diff --git a/inc/checkout/signup-fields/class-signup-field-billing-address.php b/inc/checkout/signup-fields/class-signup-field-billing-address.php index 95a91e638..2297a0c70 100644 --- a/inc/checkout/signup-fields/class-signup-field-billing-address.php +++ b/inc/checkout/signup-fields/class-signup-field-billing-address.php @@ -266,34 +266,37 @@ public function to_fields_array($attributes) { } } + /* + * Gateways that collect billing address (country, zip) natively on their + * own checkout surface, so we do not need to ask for it here. + * + * - Stripe / Stripe Checkout – Payment Element collects address. + * - PayPal (legacy + REST) – PayPal collects address on its own page. + */ + $self_billing_gateways = "gateway && (gateway.startsWith('stripe') || gateway === 'paypal' || gateway === 'paypal-rest')"; + foreach ($fields as $field_key => &$field) { $field['wrapper_classes'] = trim(wu_get_isset($field, 'wrapper_classes', '') . ' ' . $attributes['element_classes']); $field['wrapper_html_attr']['v-cloak'] = 1; /* - * billing_country uses v-if so the input is removed from the DOM - * when payment is not required. This prevents the server-side - * required_with:billing_country rule from firing on a field - * the user cannot see or edit. + * billing_country uses v-if (not v-show) so the input is removed + * from the DOM when payment is not required. This prevents the + * server-side required_with:billing_country rule from firing on a + * field the user cannot see or edit. + * + * For gateways that handle their own address collection (Stripe, + * PayPal) we also remove the field from the DOM so that it is never + * submitted and server-side validation cannot fire. */ - if ('billing_country' === $field_key) { - $field['wrapper_html_attr']['v-if'] = 'order === false || order.should_collect_payment'; + if ('billing_country' === $field_key || 'billing_zip_code' === $field_key) { + $field['wrapper_html_attr']['v-if'] = "(order === false || order.should_collect_payment) && !($self_billing_gateways)"; + } elseif ($zip_only) { + // Other fields in zip_only mode share the same gateway exclusion. + $field['wrapper_html_attr']['v-if'] = "!($self_billing_gateways)"; } else { $field['wrapper_html_attr']['v-show'] = 'order === false || order.should_collect_payment'; } - - /* - * When zip_and_country is enabled, remove the billing address fields - * from the DOM when any Stripe gateway is selected. Stripe's Payment - * Element and Stripe Checkout both collect Country and ZIP natively. - * - * Uses v-if (not v-show) so the inputs are removed from the DOM - * entirely, preventing them from being submitted with the form - * and triggering server-side required validation. - */ - if ($zip_only) { - $field['wrapper_html_attr']['v-if'] = "!(gateway && gateway.startsWith('stripe'))"; - } } uasort($fields, 'wu_sort_by_order'); diff --git a/inc/class-api.php b/inc/class-api.php index e29993035..9033876db 100644 --- a/inc/class-api.php +++ b/inc/class-api.php @@ -180,13 +180,13 @@ public function add_settings(): void { 'api', 'api_url', [ - 'title' => __('API URL', 'ultimate-multisite'), - 'desc' => '', - 'tooltip' => '', - 'copy' => true, - 'type' => 'text-display', - 'default' => network_site_url('wp-json'), - 'require' => [ + 'title' => __('API URL', 'ultimate-multisite'), + 'desc' => '', + 'tooltip' => '', + 'copy' => true, + 'type' => 'text-display', + 'display_value' => network_site_url('wp-json'), + 'require' => [ 'enable_api' => true, ], ] @@ -201,7 +201,7 @@ public function add_settings(): void { 'tooltip' => '', 'type' => 'text-display', 'copy' => true, - 'default' => wp_generate_password(24, false), + 'display_value' => wp_generate_password(24, false), 'wrapper_classes' => 'sm:wu-w-1/2 wu-float-left', 'require' => [ 'enable_api' => true, @@ -217,7 +217,7 @@ public function add_settings(): void { 'tooltip' => '', 'type' => 'text-display', 'copy' => true, - 'default' => wp_generate_password(24, false), + 'display_value' => wp_generate_password(24, false), 'wrapper_classes' => 'sm:wu-border-l-0 sm:wu-w-1/2 wu-float-left', 'require' => [ 'enable_api' => 1, diff --git a/inc/class-mcp-adapter.php b/inc/class-mcp-adapter.php index ebc364b4a..5c88640ec 100644 --- a/inc/class-mcp-adapter.php +++ b/inc/class-mcp-adapter.php @@ -193,13 +193,13 @@ public function add_settings(): void { 'api', 'mcp_serveer_urel', [ - 'title' => __('MCP Server URL', 'ultimate-multisite'), - 'desc' => '', - 'tooltip' => __('This is the URL where the MCP server is accessible via HTTP.', 'ultimate-multisite'), - 'copy' => true, - 'type' => 'text-display', - 'default' => rest_url('mcp/mcp-adapter-default-server'), - 'require' => [ + 'title' => __('MCP Server URL', 'ultimate-multisite'), + 'desc' => '', + 'tooltip' => __('This is the URL where the MCP server is accessible via HTTP.', 'ultimate-multisite'), + 'copy' => true, + 'type' => 'text-display', + 'display_value' => rest_url('mcp/mcp-adapter-default-server'), + 'require' => [ 'enable_mcp' => 1, ], ] @@ -208,13 +208,13 @@ public function add_settings(): void { 'api', 'mcp_stdio_commande', [ - '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', - 'default' => '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') + '%sPayPal', + esc_html__('PayPal', 'ultimate-multisite'), + esc_url('https://www.paypalobjects.com/webstatic/mktg/Logo/pp-logo-100px.png') ); } @@ -465,7 +468,7 @@ protected function create_order_with_platform_fee(array $order_data, string $cur [ 'amount' => [ 'currency_code' => $currency, - 'value' => number_format($fee_amount, 2, '.', ''), + 'value' => $this->format_amount($fee_amount, $currency), ], ], ], @@ -510,6 +513,26 @@ protected function create_order_with_platform_fee(array $order_data, string $cur return $body; } + /** + * Format a monetary amount as a string for the PayPal API. + * + * PayPal requires zero-decimal currencies (JPY, KRW, etc.) to be sent as + * whole integers. Sending "4767.00" for JPY causes INVALID_PARAMETER_VALUE. + * + * @since 2.0.0 + * + * @param float $amount The amount to format. + * @param string $currency ISO 4217 currency code. + * @return string + */ + protected function format_amount(float $amount, string $currency): string { + + // Delegate to the shared check so the list stays in one place. + $decimals = wu_is_zero_decimal_currency($currency) ? 0 : 2; + + return number_format($amount, $decimals, '.', ''); + } + /** * Get an access token for API requests. * @@ -760,7 +783,7 @@ protected function create_subscription($payment, $membership, $customer, $cart, $subscription_data['plan'] = [ 'payment_preferences' => [ 'setup_fee' => [ - 'value' => number_format($setup_fee, 2, '.', ''), + 'value' => $this->format_amount($setup_fee, $currency), 'currency_code' => $currency, ], ], @@ -944,7 +967,7 @@ protected function get_or_create_plan($cart, string $currency) { 'total_cycles' => 0, // 0 = unlimited 'pricing_scheme' => [ 'fixed_price' => [ - 'value' => number_format($cart->get_recurring_total(), 2, '.', ''), + 'value' => $this->format_amount($cart->get_recurring_total(), $currency), 'currency_code' => $currency, ], ], @@ -1009,15 +1032,15 @@ protected function create_order($payment, $membership, $customer, $cart, $type): 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), 'amount' => [ 'currency_code' => $currency, - 'value' => number_format($payment->get_total(), 2, '.', ''), + 'value' => $this->format_amount($payment->get_total(), $currency), 'breakdown' => [ 'item_total' => [ 'currency_code' => $currency, - 'value' => number_format($payment->get_subtotal(), 2, '.', ''), + 'value' => $this->format_amount($payment->get_subtotal(), $currency), ], 'tax_total' => [ 'currency_code' => $currency, - 'value' => number_format($payment->get_tax_total(), 2, '.', ''), + 'value' => $this->format_amount($payment->get_tax_total(), $currency), ], ], ], @@ -1115,7 +1138,7 @@ protected function build_order_items($cart, string $currency): array { 'description' => substr($line_item->get_description(), 0, 127) ?: null, 'unit_amount' => [ 'currency_code' => $currency, - 'value' => number_format($line_item->get_unit_price(), 2, '.', ''), + 'value' => $this->format_amount($line_item->get_unit_price(), $currency), ], 'quantity' => (string) $line_item->get_quantity(), 'category' => 'DIGITAL_GOODS', @@ -1163,21 +1186,13 @@ protected function confirm_subscription(string $subscription_id): void { $subscription = $this->api_request('/v1/billing/subscriptions/' . $subscription_id, [], 'GET'); if (is_wp_error($subscription)) { - wp_die( - esc_html($subscription->get_error_message()), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] - ); + $this->redirect_with_error($subscription->get_error_message()); } // Parse custom_id to get our IDs $custom_parts = explode('|', $subscription['custom_id'] ?? ''); if (count($custom_parts) !== 3) { - wp_die( - esc_html__('Invalid subscription data', 'ultimate-multisite'), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] - ); + $this->redirect_with_error(__('Invalid subscription data', 'ultimate-multisite')); } [$payment_id, $membership_id, $customer_id] = $custom_parts; @@ -1186,11 +1201,7 @@ protected function confirm_subscription(string $subscription_id): void { $membership = wu_get_membership($membership_id); if (! $payment || ! $membership) { - wp_die( - esc_html__('Payment or membership not found', 'ultimate-multisite'), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] - ); + $this->redirect_with_error(__('Payment or membership not found', 'ultimate-multisite')); } // Check subscription status @@ -1205,12 +1216,16 @@ protected function confirm_subscription(string $subscription_id): void { if ('ACTIVE' === $subscription['status']) { // Payment already processed $payment->set_status(Payment_Status::COMPLETED); - $membership->renew(false); } else { - // Will be activated on first payment webhook + // First payment being processed — activate membership immediately so the + // site is created now. Payment will be confirmed via PAYMENT.SALE.COMPLETED webhook. $payment->set_status(Payment_Status::PENDING); } + // Always renew regardless of ACTIVE vs APPROVED — this activates the membership + // and fires wu_membership_post_renew which triggers site creation. + $membership->renew(false); + $payment->set_gateway('paypal-rest'); $payment->save(); $membership->save(); @@ -1222,11 +1237,9 @@ protected function confirm_subscription(string $subscription_id): void { exit; } - wp_die( + $this->redirect_with_error( // translators: %s is the subscription status - esc_html(sprintf(__('Subscription not approved. Status: %s', 'ultimate-multisite'), $subscription['status'])), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] + sprintf(__('Subscription not approved. Status: %s', 'ultimate-multisite'), $subscription['status']) ); } @@ -1240,23 +1253,18 @@ protected function confirm_subscription(string $subscription_id): void { */ protected function confirm_order(string $token): void { + // Capture the order (Prefer header ensures full response with capture details) // Capture the order (Prefer header ensures full response with capture details) $capture = $this->api_request('/v2/checkout/orders/' . $token . '/capture', [], 'POST', ['Prefer' => 'return=representation']); if (is_wp_error($capture)) { - wp_die( - esc_html($capture->get_error_message()), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] - ); + $this->redirect_with_error($capture->get_error_message()); } if ('COMPLETED' !== $capture['status']) { - wp_die( + $this->redirect_with_error( // translators: %s is the order status - esc_html(sprintf(__('Order not completed. Status: %s', 'ultimate-multisite'), $capture['status'])), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] + sprintf(__('Order not completed. Status: %s', 'ultimate-multisite'), $capture['status']) ); } @@ -1265,11 +1273,7 @@ protected function confirm_order(string $token): void { $custom_parts = explode('|', $purchase_unit['payments']['captures'][0]['custom_id'] ?? ''); if (count($custom_parts) !== 3) { - wp_die( - esc_html__('Invalid order data', 'ultimate-multisite'), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] - ); + $this->redirect_with_error(__('Invalid order data', 'ultimate-multisite')); } [$payment_id, $membership_id, $customer_id] = $custom_parts; @@ -1278,11 +1282,7 @@ protected function confirm_order(string $token): void { $membership = wu_get_membership($membership_id); if (! $payment || ! $membership) { - wp_die( - esc_html__('Payment or membership not found', 'ultimate-multisite'), - esc_html__('PayPal Error', 'ultimate-multisite'), - ['back_link' => true] - ); + $this->redirect_with_error(__('Payment or membership not found', 'ultimate-multisite')); } // Get transaction ID from capture @@ -1353,8 +1353,22 @@ public function process_refund($amount, $payment, $membership, $customer): void $capture_id = $payment->get_gateway_payment_id(); + /* + * For subscription payments, the capture ID is not stored on the + * payment because PayPal handles the charge internally. Look it up + * from the subscription's transaction history. + */ + if (empty($capture_id) && $membership) { + $capture_id = $this->find_capture_id_for_payment($payment, $membership); + + if ($capture_id) { + $payment->set_gateway_payment_id($capture_id); + $payment->save(); + } + } + if (empty($capture_id)) { - throw new \Exception(esc_html__('No capture ID found for this payment.', 'ultimate-multisite')); + throw new \Exception(esc_html__('No capture ID found for this payment. PayPal subscription payments require an active subscription to look up the transaction.', 'ultimate-multisite')); } $refund_data = []; @@ -1362,18 +1376,108 @@ public function process_refund($amount, $payment, $membership, $customer): void // Only include amount for partial refunds if ($amount < $payment->get_total()) { $refund_data['amount'] = [ - 'value' => number_format($amount, 2, '.', ''), + 'value' => $this->format_amount($amount, strtoupper($payment->get_currency())), 'currency_code' => strtoupper($payment->get_currency()), ]; } + /* + * Try the captures endpoint first (for one-time orders), then fall + * back to the sale endpoint (for subscription payments). PayPal uses + * different transaction types depending on the payment flow. + */ $result = $this->api_request('/v2/payments/captures/' . $capture_id . '/refund', $refund_data); + if (is_wp_error($result)) { + // Try the v1 sale refund endpoint as fallback for subscription payments. + $result = $this->api_request('/v1/payments/sale/' . $capture_id . '/refund', $refund_data); + } + if (is_wp_error($result)) { throw new \Exception(esc_html($result->get_error_message())); } - $this->log(sprintf('Refund processed: %s for capture %s', $result['id'] ?? 'unknown', $capture_id)); + $this->log(sprintf('Refund processed: %s for transaction %s', $result['id'] ?? 'unknown', $capture_id)); + } + + /** + * Find the PayPal capture ID for a payment by querying the subscription transactions. + * + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @return string|false The capture ID or false if not found. + */ + protected function find_capture_id_for_payment($payment, $membership) { + + $subscription_id = $membership->get_gateway_subscription_id(); + + if (empty($subscription_id)) { + return false; + } + + $start_time = gmdate('Y-m-d\TH:i:s.000\Z', strtotime($payment->get_date_created()) - 86400); + $end_time = gmdate('Y-m-d\TH:i:s.000\Z', strtotime($payment->get_date_created()) + 86400); + + $transactions = $this->api_request( + '/v1/billing/subscriptions/' . $subscription_id . '/transactions?start_time=' . $start_time . '&end_time=' . $end_time, + [], + 'GET' + ); + + if (is_wp_error($transactions) || empty($transactions['transactions'])) { + $this->log('Failed to fetch subscription transactions for refund lookup.', LogLevel::WARNING); + + return false; + } + + /* + * PayPal subscription transactions may have statuses like COMPLETED + * or UNCLAIMED (sandbox). The amounts may also be split into a setup + * fee and a recurring charge. Try to match by amount first, then fall + * back to the largest transaction (typically the setup fee). + */ + $payment_total = (float) $payment->get_total(); + $payment_currency = strtoupper($payment->get_currency()); + $best_match = null; + $best_amount = 0; + + foreach ($transactions['transactions'] as $transaction) { + $txn_status = $transaction['status'] ?? ''; + + // Skip failed or refunded transactions. + if (in_array($txn_status, ['DECLINED', 'REFUNDED', 'PARTIALLY_REFUNDED'], true)) { + continue; + } + + $txn_amount = (float) ($transaction['amount_with_breakdown']['gross_amount']['value'] ?? 0); + $txn_currency = strtoupper($transaction['amount_with_breakdown']['gross_amount']['currency_code'] ?? ''); + + if ($txn_currency !== $payment_currency) { + continue; + } + + // Exact match on amount — best case. + if (abs($txn_amount - $payment_total) < 0.01) { + $this->log(sprintf('Found exact capture ID %s for payment %d', $transaction['id'], $payment->get_id())); + + return $transaction['id']; + } + + // Track the largest transaction as fallback (likely the setup fee). + if ($txn_amount > $best_amount) { + $best_amount = $txn_amount; + $best_match = $transaction['id']; + } + } + + // Fall back to the largest transaction if no exact match. + if ($best_match) { + $this->log(sprintf('Found capture ID %s (amount fallback) for payment %d', $best_match, $payment->get_id())); + + return $best_match; + } + + return false; } /** @@ -1603,10 +1707,11 @@ public function settings(): void { 'tooltip' => __('This is the URL PayPal 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' => 'paypal-rest', + 'active_gateways' => 'paypal-rest', + 'paypal_rest_show_manual_keys' => 1, ], ] ); @@ -1683,25 +1788,25 @@ public function render_oauth_connection(): void { $this->enqueue_connect_scripts(); // Fee notice (mirrors Stripe Connect fee notice) - if (! \WP_Ultimo::get_instance()->get_addon_repository()->has_addon_purchase()) { - printf( - '
%s
%s
', - esc_html( - sprintf( - /* translators: %s: the fee percentage */ - __('There is a %s%% fee per-transaction to use the PayPal integration included in the free Ultimate Multisite plugin.', 'ultimate-multisite'), - number_format_i18n($this->get_platform_fee_percent(), 0) - ) - ), - esc_url(network_admin_url('admin.php?page=wp-ultimo-addons')), - esc_html__('Remove this fee by purchasing any addon and connecting your store.', 'ultimate-multisite') - ); - } else { - printf( - '

%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
%s
', +// esc_html( +// sprintf( +// /* translators: %s: the fee percentage */ +// __('There is a %s%% fee per-transaction to use the PayPal integration included in the free Ultimate Multisite plugin.', 'ultimate-multisite'), +// number_format_i18n($this->get_platform_fee_percent(), 0) +// ) +// ), +// esc_url(network_admin_url('admin.php?page=wp-ultimo-addons')), +// esc_html__('Remove this fee by purchasing any addon and connecting your store.', 'ultimate-multisite') +// ); +// } else { +// 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 @@ -