From c409ca6064a890e77de5ec67e50a11b8f39c6a67 Mon Sep 17 00:00:00 2001 From: hdelaforce-payplug Date: Tue, 20 Oct 2026 14:28:05 +0200 Subject: [PATCH 1/3] PRE-3677: PPRO display eligibility by amount and country --- CHANGELOG.md | 25 + README.md | 4 + UPGRADE.md | 55 ++ .../ppro-eligibility_controller.js | 65 ++ .../ppro-eligibility_validation.js | 59 ++ .../ppro-eligibility_validation.test.js | 64 ++ assets/controllers.json | 4 + assets/package.json | 8 +- config/twig_hooks/admin.yaml | 8 +- doc/ppro_display_eligibility.md | 77 +++ .../admin/ppro/illustration-min-max.svg | 15 + .../assets/admin/ppro/illustration-pays.svg | 14 + .../Auth/UnifiedAuthenticationController.php | 5 + .../Handler/CapturePaymentRequestHandler.php | 76 ++- .../RefreshPproConfigurationCommand.php | 63 ++ .../PostSavePaymentMethodEventListener.php | 25 + ...proConfigRefreshOnAdminDisplayListener.php | 87 +++ .../Extension/PaymentMethodTypeExtension.php | 48 +- .../PproEligibilityTypeExtension.php | 99 ++++ ...lapayGatewayConfigurationTypeExtension.php | 43 -- .../Form/Type/PproCountryChoiceType.php | 97 +++ src/Gateway/GatewayCredentials.php | 27 + src/Gateway/PproPaymentMethods.php | 36 ++ src/Gateway/ScalapayGatewayFactory.php | 2 + .../IsScalapayAmountRangeValid.php | 22 - .../IsScalapayAmountRangeValidValidator.php | 212 ------- src/Ppro/PproConfig.php | 180 ++++++ src/Ppro/PproConfigurationRefresher.php | 113 ++++ .../PproConfigurationRefresherInterface.php | 16 + src/Ppro/PproConfigurationValidator.php | 122 ++++ src/Ppro/PproEligibilityChecker.php | 66 +++ src/Ppro/PproRefreshResult.php | 19 + src/Ppro/PproRefusalClassifier.php | 43 ++ src/Ppro/PproSettingsAdjuster.php | 60 ++ src/Ppro/StoredPproConfigReader.php | 107 ++++ src/Provider/SupportedMethodsProvider.php | 199 +++---- .../AccountAllowedCountriesResolver.php | 59 ++ src/Validator/PaymentMethodValidator.php | 10 +- .../form/ppro_eligibility.html.twig | 68 +++ .../form/scalapay_amount_range.html.twig | 9 - .../UnifiedAuthenticationControllerTest.php | 69 ++- .../CapturePaymentRequestHandlerTest.php | 257 ++++++++ tests/PHPUnit/Config/PproTranslationsTest.php | 111 ++++ .../Config/PproTwigHooksConfigTest.php | 49 ++ .../RefreshPproConfigurationCommandTest.php | 131 ++++ ...PostSavePaymentMethodEventListenerTest.php | 115 ++++ ...onfigRefreshOnAdminDisplayListenerTest.php | 198 +++++++ ...tMethodTypeExtensionFormSubmissionTest.php | 134 ++++- .../PaymentMethodTypeExtensionTest.php | 3 + .../PproEligibilityTypeExtensionTest.php | 278 +++++++++ ...yGatewayConfigurationTypeExtensionTest.php | 71 --- .../Gateway/GatewayCredentialsTest.php | 47 ++ .../Gateway/PproPaymentMethodsTest.php | 51 ++ ...sScalapayAmountRangeValidValidatorTest.php | 255 -------- tests/PHPUnit/Ppro/PproConfigTest.php | 223 +++++++ .../Ppro/PproConfigurationRefresherTest.php | 560 ++++++++++++++++++ .../Ppro/PproConfigurationValidatorTest.php | 142 +++++ .../Ppro/PproEligibilityCheckerTest.php | 110 ++++ .../Ppro/PproRefusalClassifierTest.php | 73 +++ .../Provider/SupportedMethodsProviderTest.php | 424 +++++++------ .../AccountAllowedCountriesResolverTest.php | 76 +++ .../Validator/PaymentMethodValidatorTest.php | 29 +- translations/flashes.en.yml | 4 + translations/flashes.fr.yml | 4 + translations/flashes.it.yml | 4 + translations/messages.en.yml | 17 +- translations/messages.fr.yml | 17 +- translations/messages.it.yml | 17 +- translations/validators.en.yml | 11 +- translations/validators.fr.yml | 11 +- translations/validators.it.yml | 11 +- 71 files changed, 4847 insertions(+), 966 deletions(-) create mode 100644 assets/admin/controllers/ppro-eligibility_controller.js create mode 100644 assets/admin/controllers/ppro-eligibility_validation.js create mode 100644 assets/admin/controllers/ppro-eligibility_validation.test.js create mode 100644 doc/ppro_display_eligibility.md create mode 100644 public/assets/admin/ppro/illustration-min-max.svg create mode 100644 public/assets/admin/ppro/illustration-pays.svg create mode 100644 src/Console/Command/RefreshPproConfigurationCommand.php create mode 100644 src/EventListener/PproConfigRefreshOnAdminDisplayListener.php create mode 100644 src/Gateway/Form/Extension/PproEligibilityTypeExtension.php delete mode 100644 src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php create mode 100644 src/Gateway/Form/Type/PproCountryChoiceType.php create mode 100644 src/Gateway/GatewayCredentials.php create mode 100644 src/Gateway/PproPaymentMethods.php delete mode 100644 src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php delete mode 100644 src/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidator.php create mode 100644 src/Ppro/PproConfig.php create mode 100644 src/Ppro/PproConfigurationRefresher.php create mode 100644 src/Ppro/PproConfigurationRefresherInterface.php create mode 100644 src/Ppro/PproConfigurationValidator.php create mode 100644 src/Ppro/PproEligibilityChecker.php create mode 100644 src/Ppro/PproRefreshResult.php create mode 100644 src/Ppro/PproRefusalClassifier.php create mode 100644 src/Ppro/PproSettingsAdjuster.php create mode 100644 src/Ppro/StoredPproConfigReader.php create mode 100644 src/Resolver/AccountAllowedCountriesResolver.php create mode 100644 templates/admin/payment_method/form/ppro_eligibility.html.twig delete mode 100644 templates/admin/payment_method/form/scalapay_amount_range.html.twig create mode 100644 tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php create mode 100644 tests/PHPUnit/Config/PproTranslationsTest.php create mode 100644 tests/PHPUnit/Config/PproTwigHooksConfigTest.php create mode 100644 tests/PHPUnit/Console/RefreshPproConfigurationCommandTest.php create mode 100644 tests/PHPUnit/EventListener/PostSavePaymentMethodEventListenerTest.php create mode 100644 tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php create mode 100644 tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php delete mode 100644 tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php create mode 100644 tests/PHPUnit/Gateway/GatewayCredentialsTest.php create mode 100644 tests/PHPUnit/Gateway/PproPaymentMethodsTest.php delete mode 100644 tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php create mode 100644 tests/PHPUnit/Ppro/PproConfigTest.php create mode 100644 tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php create mode 100644 tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php create mode 100644 tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php create mode 100644 tests/PHPUnit/Ppro/PproRefusalClassifierTest.php create mode 100644 tests/PHPUnit/Resolver/AccountAllowedCountriesResolverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 617f5883..7f8a31f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or partially) from the admin order screen, with the remaining amount and capture deadline shown - The Hosted Fields JS SDK URL is configurable through `PAYPLUG_HOSTED_FIELDS_SDK_URL` and defaults to the production SDK (`v2.2.0`): QA, staging and local setups must set it to their own SDK URL +- **PPRO display eligibility** for Bancontact, Wero and Scalapay: each method is shown at checkout + only for the cart amounts and billing countries PayPlug accepts, and the merchant can narrow both + from the payment method page (minimum/maximum in euros inside the PayPlug limits, billing + countries among those PayPlug allows), with live validation in the admin form +- `payplug:ppro:refresh` console command, to store the PayPlug limits of every PPRO payment method + once after the upgrade > [!IMPORTANT] > Merchants will need to contact support to switch to the new authentication method. @@ -37,12 +43,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`refundable_after` / `refundable_until`); outside that window the back-office message states the date and time, with its timezone, from which the refund is possible or at which the refund period ended +- The PayPlug amount and country limits of Bancontact, Wero and Scalapay are stored per payment + method and refreshed when the admin page is opened, after an OAuth connection, after a save that + switches the LIVE mode and after a payment PayPlug refuses for its country or amount: the checkout + no longer calls the PayPlug API for these methods, and a method without stored limits stays visible +- A Bancontact, Wero or Scalapay payment refused by PayPlug for its billing country or amount sends + the customer back to the order page with a message to choose another payment method +- A PPRO amount outside the PayPlug limits, or an empty country selection, is refused when saving + the payment method instead of being saved with the payment method disabled +- **The Scalapay minimum/maximum amount override is not carried over**: the merchant range is empty + after the upgrade, so it follows the PayPlug limits, and must be entered again (see + [UPGRADE.md](UPGRADE.md)) ### Removed - Drop Payum support - Drop Sylius 1.x support - Drop usage of Secret key - Use OAuth2 instead - The fixed 48h waiting time on Oney refunds, counted from the last payment or refund +- Scalapay-only minimum/maximum amount fields and their save-time constraint, replaced by the PPRO + settings shared by Bancontact, Wero and Scalapay ### Fixed - Integrated Payment no longer accepts a payment method id that does not belong to the order's @@ -61,6 +80,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hosted Fields deferred capture and cancel keep working with `payplug/unified-plugin-core` 1.2.1: the plugin now passes the payment currency to UPC's `AmountHelper`, which requires it since PRE-3724 +- A PayPlug account that cannot be read (expired credentials, network error) no longer breaks the + payment method list at checkout: the Apple Pay, American Express and card methods are kept and the + error is logged ### Breaking changes for anyone extending the plugin @@ -81,6 +103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | `OneyGatewayFactory::REFUND_WAIT_TIME_IN_HOURS` | none, the PayPlug API refund window is the only rule | | Translation key `ui.oney_transaction_less_than_forty_eight_hours` | `ui.oney_refund_not_available_before` / `ui.oney_refund_period_expired` (`%date%`) | | `RefundPaymentGeneratedHandler::__construct()` — `$orderRepository` and `$translator` dropped | container-built, no replacement needed | +| `ScalapayGatewayConfigurationTypeExtension`, `IsScalapayAmountRangeValid` and its validator | `PproEligibilityTypeExtension`, `Ppro\PproConfigurationValidator` | +| `ScalapayGatewayFactory::MIN_AMOUNT` / `MAX_AMOUNT` — **deprecated**, no longer read | `payment_method.configured_amount` in the gateway config | +| `SupportedMethodsProvider`, `CapturePaymentRequestHandler`, `UnifiedAuthenticationController`, `PostSavePaymentMethodEventListener` constructors | new PPRO collaborators, see [UPGRADE.md](UPGRADE.md) | Requires `payplug/unified-plugin-core ^1.2.1` (for the nullable `TokenOutput::$idToken` and the Unified API payment contract: `paymentMethod.hfToken`, `paymentMethod.storedId`). diff --git a/README.md b/README.md index d71a5990..b33d0098 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,10 @@ For better Oney integration, you can check the [Oney enhancement documentation]( Since 1.11.0, the plugin supports the authorized payment feature. You can check the [Authorized Payment documentation](doc/authorized_payment.md). +## PPRO display eligibility + +Bancontact, Wero and Scalapay are shown only for the cart amounts and billing countries PayPlug and the merchant accept. You can check the [PPRO display eligibility documentation](doc/ppro_display_eligibility.md). + ## Doc - [Development](doc/development.md) - [Release Process](RELEASE.md) diff --git a/UPGRADE.md b/UPGRADE.md index 311a214a..95da5432 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -30,6 +30,61 @@ way** — none of the above runs today, and there is nothing to migrate. If you *do* import it to keep using Payum, that escape hatch stops working at the removal date. Move to the command handlers in `src/Command/Handler/` before then. +# PPRO display eligibility: Bancontact, Wero, Scalapay (PRE-3677) + +Bancontact, Wero and Scalapay are now shown at checkout only for the cart amounts and billing +countries PayPlug accepts for them, narrowed by the merchant on the payment method page. The +PayPlug limits are stored in each payment method's gateway config (`payment_method` key) instead of +being read from the PayPlug API on every checkout page. + +What changes for a merchant already in production, in this order: + +1. **The Scalapay minimum/maximum amount override is not carried over.** The `min_amount` and + `max_amount` keys of a Scalapay gateway config are no longer read. After the upgrade the merchant + range is empty, so it follows the PayPlug limits; a merchant who had narrowed the Scalapay range + must enter it again on the payment method page. +2. **Until the PayPlug limits are first captured, these methods are not filtered** by amount or + billing country (PayPlug still refuses what it does not accept at payment time). The limits are + captured when the payment method page is opened in the admin, after an OAuth (re)connection, after + a save that switches the LIVE mode, or after a payment refused by PayPlug for its country or + amount. To capture them right after the upgrade, run once: + + ```bash + bin/console payplug:ppro:refresh + ``` + + Payment methods not connected to a PayPlug account yet are reported as skipped. The command exits + with a non-zero code if a connected payment method could not be refreshed (see the `payplug` log + channel); run it again once PayPlug is reachable. +3. Every country allowed by PayPlug is accepted by default, and the merchant range is empty, which + means "the PayPlug limits" (shown as the placeholder of the minimum and maximum fields). At least one country must stay selected, and an amount outside the PayPlug limits + is refused when saving (the payment method is no longer saved and disabled instead). +4. **Fallback.** Without stored limits, or with stored limits that cannot be read, the payment method + is shown and PayPlug arbitrates at payment time: this feature never blocks the checkout. When + PayPlug refuses a payment for its billing country or amount, the limits are refreshed and the + customer is sent back to the order page to choose another payment method; the payment request + response data carries `redirect_url` and `error` for headless frontends. +5. The stored limits move only on the events listed in point 2, not on a schedule. An empty minimum + or maximum, like a selection of every country, follows PayPlug's changes. A value the merchant + entered is kept, and only adjusted when PayPlug narrows its limits below it. + +For anyone extending the plugin: + +| Removed / changed | Replacement | +| --- | --- | +| `Gateway\Form\Extension\ScalapayGatewayConfigurationTypeExtension` | `Gateway\Form\Extension\PproEligibilityTypeExtension`, on the Bancontact, Wero and Scalapay configuration types | +| `Gateway\Validator\Constraints\IsScalapayAmountRangeValid` and its validator | `Ppro\PproConfigurationValidator`, run by `PaymentMethodTypeExtension` before the save | +| `ScalapayGatewayFactory::MIN_AMOUNT`, `ScalapayGatewayFactory::MAX_AMOUNT` | **deprecated**, no longer read; removal planned with the next major | +| Twig hook `amount_range` and `admin/payment_method/form/scalapay_amount_range.html.twig` | hook `ppro_eligibility`, `admin/payment_method/form/ppro_eligibility.html.twig` | +| Translation keys `ui.scalapay_gateway_config.*` (messages), `payplug_scalapay.min_amount_greater_than_max` and `payplug_scalapay.amount_out_of_authorized_range` (validators) | `ui.ppro.*` (messages), `ppro.*` (validators) | +| `SupportedMethodsProvider::__construct()` | now also takes a `PproEligibilityChecker` | +| `CapturePaymentRequestHandler::__construct()` | now also takes the PPRO refresher, the refusal classifier, `sylius_shop.provider.order_pay.final_url` and the request stack | +| `UnifiedAuthenticationController::__construct()`, `PostSavePaymentMethodEventListener::__construct()` | now also take a `PproConfigurationRefresherInterface` | + +The admin form uses a new Stimulus controller, `@payplug/sylius-payplug-plugin/ppro-eligibility` +(declared in `assets/package.json` and `assets/controllers.json`): rebuild the admin assets after the +upgrade. + # Upgrading from 1.0.0 1. Skip the faulty migration diff --git a/assets/admin/controllers/ppro-eligibility_controller.js b/assets/admin/controllers/ppro-eligibility_controller.js new file mode 100644 index 00000000..4c4b0afe --- /dev/null +++ b/assets/admin/controllers/ppro-eligibility_controller.js @@ -0,0 +1,65 @@ +import { Controller } from '@hotwired/stimulus'; + +import { validateAmounts, validateCountries } from './ppro-eligibility_validation.js'; + +/* + * Live feedback on the PPRO amount and country settings. It never blocks the submit: the + * server-side validation is the one that refuses an invalid save. + */ +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static values = { + minBound: Number, + maxBound: Number, + messages: Object, + }; + + static targets = ['minField', 'maxField', 'minError', 'maxError', 'countriesField', 'countriesError']; + + connect() { + this.minInput = this.minFieldTarget.querySelector('input'); + this.maxInput = this.maxFieldTarget.querySelector('input'); + this.countriesSelect = this.countriesFieldTarget.querySelector('select'); + + this.onAmountInput = () => this.validateAmounts(); + this.onCountriesChange = () => this.validateCountries(); + + [this.minInput, this.maxInput].forEach((input) => input?.addEventListener('input', this.onAmountInput)); + this.countriesSelect?.addEventListener('change', this.onCountriesChange); + } + + disconnect() { + [this.minInput, this.maxInput].forEach((input) => input?.removeEventListener('input', this.onAmountInput)); + this.countriesSelect?.removeEventListener('change', this.onCountriesChange); + } + + validateAmounts() { + if (!this.minInput || !this.maxInput || this.minInput.disabled) { + return; + } + + const errors = validateAmounts({ + min: this.minInput.value, + max: this.maxInput.value, + minBound: this.hasMinBoundValue ? this.minBoundValue : undefined, + maxBound: this.hasMaxBoundValue ? this.maxBoundValue : undefined, + }); + + this.showError(this.minInput, this.minErrorTarget, errors.min); + this.showError(this.maxInput, this.maxErrorTarget, errors.max); + } + + validateCountries() { + if (!this.countriesSelect || this.countriesSelect.disabled) { + return; + } + + this.showError(this.countriesSelect, this.countriesErrorTarget, validateCountries(this.countriesSelect.selectedOptions.length)); + } + + showError(field, errorElement, messageKey) { + errorElement.textContent = messageKey ? this.messagesValue[messageKey] ?? '' : ''; + errorElement.hidden = !messageKey; + field.classList.toggle('is-invalid', Boolean(messageKey)); + } +} diff --git a/assets/admin/controllers/ppro-eligibility_validation.js b/assets/admin/controllers/ppro-eligibility_validation.js new file mode 100644 index 00000000..634785ae --- /dev/null +++ b/assets/admin/controllers/ppro-eligibility_validation.js @@ -0,0 +1,59 @@ +const AMOUNT_PATTERN = /^\d+(\.\d{1,2})?$/; + +/** + * Euros typed by the merchant to cents: null when empty, NaN when not a positive amount with at + * most two decimals. Both "." and "," are accepted as the decimal separator. + */ +export function parseAmount(value) { + const normalised = String(value ?? '') + .replace(/[\s  ]/g, '') + .replace(',', '.'); + + if (normalised === '') { + return null; + } + + if (!AMOUNT_PATTERN.test(normalised)) { + return Number.NaN; + } + + return Math.round(Number.parseFloat(normalised) * 100); +} + +const isKnown = (amount) => amount !== null && amount !== undefined && !Number.isNaN(amount); + +/** + * Same rules as the server-side validator, with the PayPlug bounds in cents (undefined when + * unknown). Returns the message key to show under each field, or null. + */ +export function validateAmounts({ min, max, minBound, maxBound }) { + const minCents = parseAmount(min); + const maxCents = parseAmount(max); + const errors = { min: null, max: null }; + + if (Number.isNaN(minCents)) { + errors.min = 'invalidAmount'; + } else if (isKnown(minCents) && isKnown(minBound) && minCents < minBound) { + errors.min = 'belowMin'; + } + + if (Number.isNaN(maxCents)) { + errors.max = 'invalidAmount'; + } else if (isKnown(maxCents) && isKnown(maxBound) && maxCents > maxBound) { + errors.max = 'aboveMax'; + } + + const effectiveMin = isKnown(minCents) ? minCents : minBound; + const effectiveMax = isKnown(maxCents) ? maxCents : maxBound; + if (errors.min === null && errors.max === null && isKnown(effectiveMin) && isKnown(effectiveMax)) { + if (effectiveMin > effectiveMax) { + errors.min = 'minGreaterThanMax'; + } + } + + return errors; +} + +export function validateCountries(selectedCount) { + return selectedCount === 0 ? 'noCountry' : null; +} diff --git a/assets/admin/controllers/ppro-eligibility_validation.test.js b/assets/admin/controllers/ppro-eligibility_validation.test.js new file mode 100644 index 00000000..7884b3ff --- /dev/null +++ b/assets/admin/controllers/ppro-eligibility_validation.test.js @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseAmount, validateAmounts, validateCountries } from './ppro-eligibility_validation.js'; + +const bounds = { minBound: 30, maxBound: 2000000 }; + +test('belowMinReturnsError', () => { + assert.deepEqual(validateAmounts({ ...bounds, min: '0.10', max: '' }), { min: 'belowMin', max: null }); +}); + +test('aboveMaxReturnsError', () => { + assert.deepEqual(validateAmounts({ ...bounds, min: '', max: '20000.01' }), { min: null, max: 'aboveMax' }); +}); + +test('minGreaterThanMaxReturnsError', () => { + assert.deepEqual(validateAmounts({ ...bounds, min: '50', max: '10' }), { min: 'minGreaterThanMax', max: null }); +}); + +test('minGreaterThanMaxAgainstTheBoundOfAnEmptyField', () => { + assert.deepEqual(validateAmounts({ minBound: 500, maxBound: 200000, min: '', max: '3' }), { + min: 'minGreaterThanMax', + max: null, + }); +}); + +test('boundsAreInclusive', () => { + assert.deepEqual(validateAmounts({ ...bounds, min: '0.30', max: '20000' }), { min: null, max: null }); +}); + +test('emptyIsValid', () => { + assert.deepEqual(validateAmounts({ ...bounds, min: '', max: ' ' }), { min: null, max: null }); +}); + +test('unknownBoundsOnlyCheckMinAgainstMax', () => { + assert.deepEqual(validateAmounts({ min: '0.01', max: '999999' }), { min: null, max: null }); + assert.deepEqual(validateAmounts({ min: '10', max: '5' }), { min: 'minGreaterThanMax', max: null }); +}); + +test('invalidAmountReturnsError', () => { + assert.deepEqual(validateAmounts({ ...bounds, min: 'abc', max: '12.345' }), { + min: 'invalidAmount', + max: 'invalidAmount', + }); +}); + +test('noCountryReturnsError', () => { + assert.equal(validateCountries(0), 'noCountry'); + assert.equal(validateCountries(1), null); +}); + +test('commaDecimalIsParsed', () => { + assert.equal(parseAmount('12,50'), 1250); + assert.equal(parseAmount('12.5'), 1250); + assert.equal(parseAmount('1 500'), 150000); + assert.equal(parseAmount('0,1'), 10); +}); + +test('parseAmountDistinguishesEmptyFromInvalid', () => { + assert.equal(parseAmount(''), null); + assert.equal(parseAmount(undefined), null); + assert.ok(Number.isNaN(parseAmount('-5'))); + assert.ok(Number.isNaN(parseAmount('1.2.3'))); +}); diff --git a/assets/controllers.json b/assets/controllers.json index 9d9dfddd..706d2886 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -41,6 +41,10 @@ "apple-pay": { "enabled": true, "fetch": "lazy" + }, + "ppro-eligibility": { + "enabled": true, + "fetch": "lazy" } } }, diff --git a/assets/package.json b/assets/package.json index c17e1363..7783818e 100644 --- a/assets/package.json +++ b/assets/package.json @@ -11,7 +11,7 @@ "dist" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test 'admin/controllers/*.test.js'" }, "symfony": { "controllers": { @@ -69,6 +69,12 @@ "webpackMode": "lazy", "fetch": "lazy", "enabled": true + }, + "ppro-eligibility": { + "main": "admin/controllers/ppro-eligibility_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": true } } }, diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index 0ad4fb89..a340c203 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -35,6 +35,9 @@ sylius_twig_hooks: priority: 0 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_bancontact': &bancontactGateway live_checkbox: *liveCheckbox + ppro_eligibility: &pproEligibility + template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/ppro_eligibility.html.twig' + priority: -10 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_apple_pay': &applePayGateway live_checkbox: *liveCheckbox @@ -43,12 +46,11 @@ sylius_twig_hooks: 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_scalapay': &scalapayGateway live_checkbox: *liveCheckbox - amount_range: - template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/scalapay_amount_range.html.twig' - priority: 0 + ppro_eligibility: *pproEligibility 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_wero': &weroGateway live_checkbox: *liveCheckbox + ppro_eligibility: *pproEligibility 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug': <<: *payplugGateway diff --git a/doc/ppro_display_eligibility.md b/doc/ppro_display_eligibility.md new file mode 100644 index 00000000..ddd42990 --- /dev/null +++ b/doc/ppro_display_eligibility.md @@ -0,0 +1,77 @@ +# PPRO display eligibility (Bancontact, Wero, Scalapay) + +Bancontact, Wero and Scalapay are shown at checkout only when the cart amount and the customer's +billing country are accepted by PayPlug for that payment method, and by the merchant's own settings. + +> [!NOTE] +> Oney, Apple Pay, American Express and the card payment method are not concerned. + +## Merchant settings + +On the payment method page (**Configuration > Payment methods**, edit Bancontact, Wero or Scalapay), +two blocks appear once the payment method is connected to a PayPlug account: + +- **Cart amount**: a minimum and a maximum, in euros. The payment method is displayed only if the + cart amount is between these two values. Both values must stay within the limits PayPlug authorizes + for the payment method, shown under the block title and as the placeholder of each field. Leaving a + field empty uses the PayPlug limit, including when PayPlug changes it later. +- **Billing countries**: the countries the payment method is displayed for, among the countries + PayPlug allows for it. The list can be searched, and selected countries are shown as tags. At least + one country must stay selected. + +Errors (an amount outside the PayPlug limits, a minimum above the maximum, no country selected) are +shown while typing, and the save is refused with the same message. + +### Defaults + +- Every country allowed by PayPlug is selected. When every country stays selected, countries PayPlug + allows later are included automatically. +- The minimum and maximum are empty, which means the PayPlug limits. + +## When the PayPlug limits are refreshed + +The PayPlug limits are stored with the payment method. They are refreshed: + +- when the payment method page is opened in the admin; +- after connecting (or reconnecting) the payment method to PayPlug; +- after a save that switches the LIVE mode on or off; +- when PayPlug refuses a payment for its billing country or its amount; +- with the console command `bin/console payplug:ppro:refresh`, which refreshes every Bancontact, + Wero and Scalapay payment method connected to PayPlug (run it once after upgrading the plugin); + payment methods not connected yet are reported as skipped. + +The checkout itself never calls PayPlug to decide whether to show these payment methods. + +An empty minimum or maximum, like a selection of every country, follows PayPlug's changes. A value +the merchant entered is kept; when PayPlug narrows its limits below it, it is adjusted at the next +refresh and a notice is shown on the payment method page. + +If PayPlug reports the payment method as not enabled on the account, it is hidden at checkout and a +warning is shown on the payment method page. The payment method itself stays enabled in Sylius. + +## When PayPlug cannot be reached + +- On the payment method page: the last stored limits are shown, with a warning. +- At checkout: nothing changes, the stored limits are used. +- If no limits have ever been stored (payment method never refreshed since the upgrade), Bancontact, + Wero and Scalapay are shown for every amount and country, and PayPlug accepts or refuses the payment. + +The checkout is never blocked by this feature. + +## What the customer sees after a refusal + +If PayPlug refuses a payment because of the billing country or the amount, the customer is sent back +to the order page with the message "This payment method is not available for your billing country or +for this amount. Please choose another payment method." The stored limits are refreshed at the same +time, so the payment method is no longer offered where PayPlug does not accept it. + +For a headless frontend, the payment request response data carries `redirect_url` (the order page) +and `error` (the translation key `payplug_sylius_payplug_plugin.error.ppro_not_available`). + +## Adding a PPRO payment method + +The list of PPRO payment methods is `PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods`. Once the +gateway of a new PPRO payment method exists (gateway factory, configuration type, resolver decorator, +refund provider, translations), adding it means one line in `PproPaymentMethods::API_KEY_BY_FACTORY`, +its configuration type in `PproEligibilityTypeExtension::getExtendedTypes()` and the +`ppro_eligibility` hook entries in `config/twig_hooks/admin.yaml`. diff --git a/public/assets/admin/ppro/illustration-min-max.svg b/public/assets/admin/ppro/illustration-min-max.svg new file mode 100644 index 00000000..61b5e328 --- /dev/null +++ b/public/assets/admin/ppro/illustration-min-max.svg @@ -0,0 +1,15 @@ + + Cart amount range + + + + + + + + + 250 € + 1500 € + 100 € + 3000 € + diff --git a/public/assets/admin/ppro/illustration-pays.svg b/public/assets/admin/ppro/illustration-pays.svg new file mode 100644 index 00000000..a5b7c9ec --- /dev/null +++ b/public/assets/admin/ppro/illustration-pays.svg @@ -0,0 +1,14 @@ + + Billing countries + + + + + + + + + + + + diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index 43bd0b2a..94fef380 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -8,6 +8,7 @@ use Payplug\Authentication; use Payplug\Payplug; use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationRefresherInterface; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; @@ -46,6 +47,7 @@ public function __construct( private LoggerInterface $logger, private IOAuthHttpClient $oauthHttpClient, private IdTokenEmailExtractor $idTokenEmailExtractor, + private PproConfigurationRefresherInterface $pproConfigurationRefresher, private string $payplugOauthBaseUrl, private string $payplugOauthAudience, ) { @@ -137,6 +139,9 @@ public function oauthCallback(Request $request): Response $gatewayConfig->setConfig($config); $this->entityManager->flush(); + // After the credentials are persisted, so a PayPlug outage here cannot lose them. A no-op + // for non-PPRO gateways; never throws. PaymentMethodValidator::process() flushes it. + $this->pproConfigurationRefresher->refresh($paymentMethod, false); $this->cleanSession($request); $request->getSession()->getFlashBag()->add('success', 'payplug_sylius_payplug_plugin.admin.oauth_callback_success'); diff --git a/src/Command/Handler/CapturePaymentRequestHandler.php b/src/Command/Handler/CapturePaymentRequestHandler.php index cda95705..743d9aaf 100644 --- a/src/Command/Handler/CapturePaymentRequestHandler.php +++ b/src/Command/Handler/CapturePaymentRequestHandler.php @@ -9,18 +9,29 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Command\CapturePaymentRequest; use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator; +use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationRefresherInterface; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproRefusalClassifier; use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; +use Sylius\Bundle\CoreBundle\OrderPay\Provider\FinalUrlProviderInterface; use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; +use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[AsMessageHandler] final class CapturePaymentRequestHandler { + private const PPRO_NOT_AVAILABLE_MESSAGE = 'payplug_sylius_payplug_plugin.error.ppro_not_available'; + public function __construct( private PaymentRequestProviderInterface $paymentRequestProvider, private StateMachineInterface $stateMachine, @@ -29,6 +40,11 @@ public function __construct( #[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')] private UrlProviderInterface $afterPayUrlProvider, private LoggerInterface $logger, + private PproConfigurationRefresherInterface $pproConfigurationRefresher, + private PproRefusalClassifier $pproRefusalClassifier, + #[Autowire(service: 'sylius_shop.provider.order_pay.final_url')] + private FinalUrlProviderInterface $finalUrlProvider, + private RequestStack $requestStack, ) {} public function __invoke(CapturePaymentRequest $capturePaymentRequest): void @@ -76,13 +92,7 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void try { $payplugPayment = $client->createPayment($data); } catch (HttpException $exception) { - $paymentRequest->setResponseData(\json_decode($exception->getHttpResponse(), true)); // @phpstan-ignore-line - $this->logger->error('[PayPlug] Scalapay capture failed', ['response' => $exception->getHttpResponse()]); - $this->stateMachine->apply( - $paymentRequest, - PaymentRequestTransitions::GRAPH, - PaymentRequestTransitions::TRANSITION_FAIL, - ); + $this->failPaymentRequest($paymentRequest, $payment, $method, $exception); return; } @@ -107,4 +117,56 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void PaymentRequestTransitions::TRANSITION_COMPLETE, ); } + + /** + * A PPRO payment refused for its billing country or its amount means the stored PayPlug limits + * let the method be shown where PayPlug does not accept it: they are refreshed, and the customer + * is sent back to the order page to pick another payment method. No PayPlug payment exists after + * a refusal, so there is nothing to cancel. + */ + private function failPaymentRequest( + PaymentRequestInterface $paymentRequest, + PaymentInterface $payment, + PaymentMethodInterface $method, + HttpException $exception, + ): void { + $responseBody = $exception->getHttpResponse(); + $decoded = \json_decode($responseBody, true); + /** @var array $responseData */ + $responseData = \is_array($decoded) ? $decoded : []; + $factoryName = $method->getGatewayConfig()?->getFactoryName(); + + $refusal = PproPaymentMethods::isPpro($factoryName) ? $this->pproRefusalClassifier->classify($responseBody) : null; + + if (null === $refusal) { + $this->logger->error('[PayPlug] Payment creation failed', ['factory_name' => $factoryName, 'response' => $responseBody]); + } else { + $this->logger->info('[PayPlug] PPRO payment refused for its billing country or amount; limits refreshed.', [ + 'factory_name' => $factoryName, + 'refusal' => $refusal, + ]); + // Flushed by the doctrine_transaction middleware of the payment request command bus. + $this->pproConfigurationRefresher->refresh($method, false); + $responseData['redirect_url'] = $this->finalUrlProvider->getUrl($payment, UrlGeneratorInterface::ABSOLUTE_URL); + $responseData['error'] = self::PPRO_NOT_AVAILABLE_MESSAGE; + $this->addErrorFlash(); + } + + $paymentRequest->setResponseData($responseData); + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + } + + private function addErrorFlash(): void + { + $request = $this->requestStack->getCurrentRequest(); + $session = null !== $request && $request->hasSession() ? $request->getSession() : null; + + if ($session instanceof FlashBagAwareSessionInterface) { + $session->getFlashBag()->add('error', self::PPRO_NOT_AVAILABLE_MESSAGE); + } + } } diff --git a/src/Console/Command/RefreshPproConfigurationCommand.php b/src/Console/Command/RefreshPproConfigurationCommand.php new file mode 100644 index 00000000..f514b92c --- /dev/null +++ b/src/Console/Command/RefreshPproConfigurationCommand.php @@ -0,0 +1,63 @@ +paymentMethodRepository->findAll() as $paymentMethod) { + $gatewayConfig = $paymentMethod->getGatewayConfig(); + if (null === $gatewayConfig || !PproPaymentMethods::isPpro($gatewayConfig->getFactoryName())) { + continue; + } + + ++$handled; + if (!GatewayCredentials::isConnected($gatewayConfig->getConfig())) { + $output->writeln(sprintf('Skipped "%s": not connected to a PayPlug account.', (string) $paymentMethod->getCode())); + + continue; + } + + if ($this->pproConfigurationRefresher->refresh($paymentMethod)->refreshed) { + $output->writeln(sprintf('Refreshed the PayPlug limits of "%s".', (string) $paymentMethod->getCode())); + + continue; + } + + ++$failures; + $output->writeln(sprintf('Could not refresh the PayPlug limits of "%s"; see the payplug log channel.', (string) $paymentMethod->getCode())); + } + + if (0 === $handled) { + $output->writeln('No PPRO payment method found.'); + } + + return 0 === $failures ? Command::SUCCESS : Command::FAILURE; + } +} diff --git a/src/EventListener/PostSavePaymentMethodEventListener.php b/src/EventListener/PostSavePaymentMethodEventListener.php index 8a9a087c..8c227dc0 100644 --- a/src/EventListener/PostSavePaymentMethodEventListener.php +++ b/src/EventListener/PostSavePaymentMethodEventListener.php @@ -5,6 +5,9 @@ namespace PayPlug\SyliusPayPlugPlugin\EventListener; use Payplug\Authentication; +use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfig; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationRefresherInterface; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use Psr\Log\LoggerInterface; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; @@ -23,6 +26,7 @@ public function __construct( private RouterInterface $router, private PaymentMethodValidator $paymentMethodValidator, private LoggerInterface $logger, + private PproConfigurationRefresherInterface $pproConfigurationRefresher, ) { } @@ -61,6 +65,7 @@ public function onUpdate(ResourceControllerEvent $event): void if (true !== $isRenewal) { // No need to renew the oauth token, let's validate the payment method with already existing config $this->paymentMethodValidator->process($paymentMethod); + $this->refreshPproLimitsOnEnvironmentChange($paymentMethod); return; } @@ -94,6 +99,26 @@ private function startOAuth(PaymentMethodInterface $paymentMethod, ResourceContr } } + /** + * The stored PPRO limits belong to the environment (live or test) they were fetched in. + */ + private function refreshPproLimitsOnEnvironmentChange(PaymentMethodInterface $paymentMethod): void + { + $gatewayConfig = $paymentMethod->getGatewayConfig(); + if (!PproPaymentMethods::isPpro($gatewayConfig?->getFactoryName())) { + return; + } + + $config = $gatewayConfig?->getConfig() ?? []; + $stored = PproConfig::fromArray($config[PproConfig::CONFIG_KEY] ?? null); + + if (null === $stored?->live || $stored->live === (true === ($config['live'] ?? false))) { + return; + } + + $this->pproConfigurationRefresher->refresh($paymentMethod); + } + private function isPayplugPaymentMethod(PaymentMethodInterface $paymentMethod): bool { $gateway = $paymentMethod->getGatewayConfig(); diff --git a/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php b/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php new file mode 100644 index 00000000..98c6bd88 --- /dev/null +++ b/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php @@ -0,0 +1,87 @@ + $paymentMethodRepository + */ + public function __construct( + private RepositoryInterface $paymentMethodRepository, + private PproConfigurationRefresherInterface $pproConfigurationRefresher, + ) { + } + + public function __invoke(ControllerEvent $event): void + { + $request = $event->getRequest(); + if (!$event->isMainRequest() || !$request->isMethod(Request::METHOD_GET) || self::ROUTE !== $request->attributes->get('_route')) { + return; + } + + $paymentMethod = $this->findPproPaymentMethod($request); + if (null === $paymentMethod) { + return; + } + + $result = $this->pproConfigurationRefresher->refresh($paymentMethod, true); + + if (!$result->refreshed) { + $this->addFlash($request, 'warning', 'payplug_sylius_payplug_plugin.ppro.refresh_failed'); + } elseif ($result->adjusted) { + $this->addFlash($request, 'info', 'payplug_sylius_payplug_plugin.ppro.settings_adjusted'); + } + } + + private function findPproPaymentMethod(Request $request): ?PaymentMethodInterface + { + $id = $request->attributes->get('id'); + if (!\is_scalar($id)) { + return null; + } + + $paymentMethod = $this->paymentMethodRepository->find($id); + if (!$paymentMethod instanceof PaymentMethodInterface) { + return null; + } + + $gatewayConfig = $paymentMethod->getGatewayConfig(); + $isConnectedPpro = null !== $gatewayConfig && + PproPaymentMethods::isPpro($gatewayConfig->getFactoryName()) && + GatewayCredentials::isConnected($gatewayConfig->getConfig()); + + return $isConnectedPpro ? $paymentMethod : null; + } + + private function addFlash(Request $request, string $type, string $message): void + { + $session = $request->hasSession() ? $request->getSession() : null; + + if ($session instanceof FlashBagAwareSessionInterface) { + $session->getFlashBag()->add($type, $message); + } + } +} diff --git a/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php b/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php index b6e00330..807a1dba 100644 --- a/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php +++ b/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php @@ -6,6 +6,9 @@ use PayPlug\SyliusPayPlugPlugin\Checker\GatewayChannelConflictChecker; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\AbstractGatewayConfigurationType; +use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfig; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationValidator; use Sylius\Bundle\ChannelBundle\Form\Type\ChannelChoiceType; use Sylius\Bundle\PaymentBundle\Form\Type\PaymentMethodType; use Sylius\Component\Core\Model\ChannelInterface; @@ -27,7 +30,9 @@ * - POST_SET_DATA guards the picker, rendering the channels another enabled gateway of the same * factory already holds as unselectable (PRE-3629) — see `disableClaimedChannelChoices()`. * - POST_SUBMIT enforces the rule, turning a channel conflict or a base-currency violation into a - * form error (PRE-3628) — see `addChannelConflictErrors()` and `addBaseCurrencyErrors()`. + * form error (PRE-3628) — see `addChannelConflictErrors()` and `addBaseCurrencyErrors()`. It + * also refuses PPRO amount and country settings outside the stored PayPlug limits — see + * `addPproEligibilityErrors()`. * * Both sit on the *root* payment-method form. Sylius adds `channels` from `CoreBundle`'s own type * extension, i.e. after `gatewayConfig` (`PaymentMethodType` adds `enabled` then `gatewayConfig`); @@ -42,6 +47,7 @@ final class PaymentMethodTypeExtension extends AbstractTypeExtension public function __construct( private GatewayChannelConflictChecker $conflictChecker, private TranslatorInterface $translator, + private PproConfigurationValidator $pproConfigurationValidator, ) { } @@ -78,6 +84,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $this->addChannelConflictErrors($form, $paymentMethod, (string) $gatewayConfig->getFactoryName()); $this->addBaseCurrencyErrors($form, $paymentMethod, $gatewayConfig, $configurationType); + $this->addPproEligibilityErrors($form, $gatewayConfig); }); } @@ -274,4 +281,43 @@ private function addBaseCurrencyErrors( $form->get('channels')->addError(new FormError($message)); } } + + /** + * The submitted values are read from the fields, the PayPlug limits from the gateway config, + * where the form never writes them. A field that failed to transform already carries its own + * error and is not checked again. + */ + private function addPproEligibilityErrors(FormInterface $form, GatewayConfigInterface $gatewayConfig): void + { + if (!PproPaymentMethods::isPpro($gatewayConfig->getFactoryName())) { + return; + } + + $configForm = $form->get('gatewayConfig')->get('config'); + $fields = [ + PproEligibilityTypeExtension::MIN_AMOUNT_FIELD, + PproEligibilityTypeExtension::MAX_AMOUNT_FIELD, + PproEligibilityTypeExtension::COUNTRIES_FIELD, + ]; + foreach ($fields as $field) { + if (!$configForm->has($field) || $configForm->get($field)->isDisabled() || !$configForm->get($field)->isSynchronized()) { + return; + } + } + + /** @var int|null $min */ + $min = $configForm->get(PproEligibilityTypeExtension::MIN_AMOUNT_FIELD)->getData(); + /** @var int|null $max */ + $max = $configForm->get(PproEligibilityTypeExtension::MAX_AMOUNT_FIELD)->getData(); + /** @var list|null $countries */ + $countries = $configForm->get(PproEligibilityTypeExtension::COUNTRIES_FIELD)->getData(); + + $stored = PproConfig::fromArray($gatewayConfig->getConfig()[PproConfig::CONFIG_KEY] ?? null); + + foreach ($this->pproConfigurationValidator->violations($stored, $min, $max, $countries) as $field => $messages) { + foreach ($messages as $message) { + $configForm->get($field)->addError(new FormError($message)); + } + } + } } diff --git a/src/Gateway/Form/Extension/PproEligibilityTypeExtension.php b/src/Gateway/Form/Extension/PproEligibilityTypeExtension.php new file mode 100644 index 00000000..60c6db58 --- /dev/null +++ b/src/Gateway/Form/Extension/PproEligibilityTypeExtension.php @@ -0,0 +1,99 @@ +addEventListener(FormEvents::PRE_SET_DATA, static function (FormEvent $event): void { + $config = $event->getData(); + $config = \is_array($config) ? $config : []; + $stored = PproConfig::fromArray($config[PproConfig::CONFIG_KEY] ?? null); + $disabled = !GatewayCredentials::isConnected($config); + + $event->getForm() + ->add(self::MIN_AMOUNT_FIELD, MoneyType::class, self::amountOptions('min', $disabled, $stored?->defaultMin)) + ->add(self::MAX_AMOUNT_FIELD, MoneyType::class, self::amountOptions('max', $disabled, $stored?->defaultMax)) + ->add(self::COUNTRIES_FIELD, PproCountryChoiceType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.ppro.countries_label', + 'allowed_countries' => $stored?->allowedCountries, + 'property_path' => self::COUNTRIES_PATH, + 'disabled' => $disabled, + ]) + ; + }); + } + + public static function getExtendedTypes(): iterable + { + return [ + BancontactGatewayConfigurationType::class, + WeroGatewayConfigurationType::class, + ScalapayGatewayConfigurationType::class, + ]; + } + + /** + * @return array + */ + private static function amountOptions(string $side, bool $disabled, ?int $apiLimit): array + { + return [ + 'attr' => null === $apiLimit ? [] : ['placeholder' => self::formatEuros($apiLimit)], + 'label' => 'payplug_sylius_payplug_plugin.ui.ppro.' . $side . '_amount', + 'currency' => 'EUR', + 'required' => false, + 'invalid_message' => 'payplug_sylius_payplug_plugin.ppro.invalid_amount', + 'property_path' => sprintf(self::AMOUNT_PATH, $side), + 'disabled' => $disabled, + ]; + } + + /** + * In the same format as the field's own value, so the placeholder reads like an entry. + */ + private static function formatEuros(int $cents): string + { + $formatter = new \NumberFormatter(\Locale::getDefault(), \NumberFormatter::DECIMAL); + $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, 2); + $formatter->setAttribute(\NumberFormatter::GROUPING_USED, 0); + + return (string) $formatter->format($cents / 100); + } +} diff --git a/src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php deleted file mode 100644 index 4cecf182..00000000 --- a/src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php +++ /dev/null @@ -1,43 +0,0 @@ -add(ScalapayGatewayFactory::MIN_AMOUNT, MoneyType::class, [ - 'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.min_amount', - 'help' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help', - 'currency' => 'EUR', - 'required' => false, - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - ]) - ->add(ScalapayGatewayFactory::MAX_AMOUNT, MoneyType::class, [ - 'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.max_amount', - 'help' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help', - 'currency' => 'EUR', - 'required' => false, - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - ]) - ; - } - - public static function getExtendedTypes(): iterable - { - return [ScalapayGatewayConfigurationType::class]; - } -} diff --git a/src/Gateway/Form/Type/PproCountryChoiceType.php b/src/Gateway/Form/Type/PproCountryChoiceType.php new file mode 100644 index 00000000..7b3d85b3 --- /dev/null +++ b/src/Gateway/Form/Type/PproCountryChoiceType.php @@ -0,0 +1,97 @@ + $choices */ + $choices = $options['choices']; + $codes = array_values($choices); + + $builder->addModelTransformer(new CallbackTransformer( + static fn (mixed $countries): array => \is_array($countries) ? array_values(array_intersect($countries, $codes)) : $codes, + static function (mixed $selected) use ($codes): ?array { + $selected = \is_array($selected) ? array_values($selected) : []; + $missing = array_diff($codes, $selected); + + return [] !== $selected && [] === $missing ? null : $selected; + }, + )); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'allowed_countries' => null, + 'multiple' => true, + 'required' => false, + 'choice_translation_domain' => false, + 'choices' => static function (Options $options): array { + /** @var list|null $allowedCountries */ + $allowedCountries = $options['allowed_countries']; + + return self::buildChoices($allowedCountries); + }, + 'autocomplete' => true, + 'max_results' => 500, + 'tom_select_options' => [ + 'plugins' => ['checkbox_options'], + 'hideSelected' => false, + 'closeAfterSelect' => false, + ], + ]) + ->setAllowedTypes('allowed_countries', ['null', 'string[]']) + ; + } + + public function getParent(): string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'payplug_ppro_country_choice'; + } + + /** + * @param list|null $allowedCountries + * + * @return array country name => country code + */ + private static function buildChoices(?array $allowedCountries): array + { + $names = Countries::getNames(); + if (null === $allowedCountries) { + return array_flip($names); + } + + $choices = []; + foreach ($allowedCountries as $code) { + $choices[$names[$code] ?? $code] = $code; + } + + return $choices; + } +} diff --git a/src/Gateway/GatewayCredentials.php b/src/Gateway/GatewayCredentials.php new file mode 100644 index 00000000..3b675403 --- /dev/null +++ b/src/Gateway/GatewayCredentials.php @@ -0,0 +1,27 @@ + $config + */ + public static function isConnected(array $config): bool + { + $client = true === ($config['live'] ?? false) ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null); + + return \is_array($client) && self::isFilled($client['client_id'] ?? null) && self::isFilled($client['client_secret'] ?? null); + } + + private static function isFilled(mixed $value): bool + { + return \is_string($value) && '' !== $value; + } +} diff --git a/src/Gateway/PproPaymentMethods.php b/src/Gateway/PproPaymentMethods.php new file mode 100644 index 00000000..2bb3f121 --- /dev/null +++ b/src/Gateway/PproPaymentMethods.php @@ -0,0 +1,36 @@ + 'bancontact', + WeroGatewayFactory::FACTORY_NAME => 'wero', + ScalapayGatewayFactory::FACTORY_NAME => 'scalapay', + ]; + + public static function isPpro(?string $factoryName): bool + { + return null !== self::apiKey($factoryName); + } + + public static function apiKey(?string $factoryName): ?string + { + if (null === $factoryName) { + return null; + } + + return self::API_KEY_BY_FACTORY[$factoryName] ?? null; + } +} diff --git a/src/Gateway/ScalapayGatewayFactory.php b/src/Gateway/ScalapayGatewayFactory.php index e982ffaa..28aaec04 100644 --- a/src/Gateway/ScalapayGatewayFactory.php +++ b/src/Gateway/ScalapayGatewayFactory.php @@ -12,7 +12,9 @@ final class ScalapayGatewayFactory extends AbstractGatewayFactory public const PAYMENT_METHOD_SCALAPAY = 'scalapay'; + /** @deprecated no longer read; the merchant range is stored under the `payment_method` config key */ public const MIN_AMOUNT = 'min_amount'; + /** @deprecated no longer read; the merchant range is stored under the `payment_method` config key */ public const MAX_AMOUNT = 'max_amount'; } diff --git a/src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php b/src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php deleted file mode 100644 index 10c0d5ee..00000000 --- a/src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php +++ /dev/null @@ -1,22 +0,0 @@ -resolveApplicableConfiguredAmounts($value, $constraint); - if (null === $configuredAmounts) { - return; - } - - $authorizedRange = $this->resolveAuthorizedRange($value); - if (null === $authorizedRange) { - return; - } - - $this->applyRangeViolations($configuredAmounts, $authorizedRange, $constraint); - } - - /** - * Resolves the merchant-configured amounts, applying the early guards that don't need a - * live API call: the method must be enabled, amounts must be configured, and — when both - * sides are explicitly set — locally consistent. - * - * @return array{0: int|null, 1: int|null}|null - */ - private function resolveApplicableConfiguredAmounts( - PaymentMethodInterface $paymentMethod, - IsScalapayAmountRangeValid $constraint, - ): ?array { - $configuredAmounts = false !== $paymentMethod->isEnabled() ? $this->resolveConfiguredAmounts($paymentMethod) : null; - if (null === $configuredAmounts) { - return null; - } - - [$minAmount, $maxAmount] = $configuredAmounts; - - if (\is_int($minAmount) && \is_int($maxAmount) && $minAmount > $maxAmount) { - $this->context->buildViolation($constraint->minGreaterThanMaxMessage)->addViolation(); - - return null; - } - - return $configuredAmounts; - } - - /** - * @param array{0: int|null, 1: int|null} $configuredAmounts - * @param array{min_amount: int, max_amount: int} $authorizedRange - */ - private function applyRangeViolations( - array $configuredAmounts, - array $authorizedRange, - IsScalapayAmountRangeValid $constraint, - ): void { - [$minAmount, $maxAmount] = $configuredAmounts; - - // A merchant may configure only one side of the range; the other falls back to the - // API bound at checkout (see SupportedMethodsProvider), so the min>max check must - // compare against that same effective range, not just the explicitly configured side. - $effectiveMinAmount = $minAmount ?? $authorizedRange['min_amount']; - $effectiveMaxAmount = $maxAmount ?? $authorizedRange['max_amount']; - - if ($effectiveMinAmount > $effectiveMaxAmount) { - $this->context->buildViolation($constraint->minGreaterThanMaxMessage)->addViolation(); - - return; - } - - if ( - (\is_int($minAmount) && $minAmount < $authorizedRange['min_amount']) || - (\is_int($maxAmount) && $maxAmount > $authorizedRange['max_amount']) - ) { - $this->context->buildViolation($constraint->outOfRangeMessage) - ->setParameter('%min_amount%', self::formatAmount($authorizedRange['min_amount'])) - ->setParameter('%max_amount%', self::formatAmount($authorizedRange['max_amount'])) - ->addViolation() - ; - } - } - - /** - * The bounds are EUR cents (the form field is hardcoded to EUR), rendered with two decimals so - * 500 reads as "5.00" rather than "5". - */ - private static function formatAmount(int $amountInCents): string - { - return number_format($amountInCents / 100, 2, '.', ''); - } - - /** - * @return array{0: int|null, 1: int|null}|null - */ - private function resolveConfiguredAmounts(PaymentMethodInterface $paymentMethod): ?array - { - $gatewayConfig = $paymentMethod->getGatewayConfig(); - - if (!$gatewayConfig instanceof GatewayConfigInterface || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) { - return null; - } - - [$minAmount, $maxAmount] = $this->readConfiguredAmounts($gatewayConfig->getConfig()); - - return null === $minAmount && null === $maxAmount ? null : [$minAmount, $maxAmount]; - } - - /** - * The admin form only ever writes null or an int, but the gateway config is a plain serialized - * array that a direct DB edit, an import script or an admin API write can leave anything in. - * PaymentMethodValidator::process() has no surrounding try/catch, so a malformed value - * degrades to "not configured" — leaving the API bounds in force at checkout — rather than - * throwing an assertion error that would 500 the admin save. - * - * @param array $config - * - * @return array{0: int|null, 1: int|null} - */ - private function readConfiguredAmounts(array $config): array - { - $minAmount = $config[ScalapayGatewayFactory::MIN_AMOUNT] ?? null; - $maxAmount = $config[ScalapayGatewayFactory::MAX_AMOUNT] ?? null; - - try { - Assert::nullOrInteger($minAmount); - Assert::nullOrInteger($maxAmount); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('Skipping Scalapay amount range validation: the stored range is malformed.', [ - 'min_amount' => $minAmount, - 'max_amount' => $maxAmount, - 'exception' => $exception->getMessage(), - ]); - - return [null, null]; - } - - return [$minAmount, $maxAmount]; - } - - /** - * Fails open: when the authorized range can't be established the config saves unvalidated, - * matching the plugin's convention of never blocking an admin save on an API hiccup. That is - * not free — a one-sided range that inverts against the live API bounds slips through and - * silently hides Scalapay at checkout — so the skip is logged rather than swallowed. - * - * @return array{min_amount: int, max_amount: int}|null - */ - private function resolveAuthorizedRange(PaymentMethodInterface $paymentMethod): ?array - { - try { - $authorizedRange = $this->resolveApiAuthorizedRange($paymentMethod); - } catch (GatewayConfigurationException | PayplugException | InvalidArgumentException $exception) { - $this->logger->warning('Skipping Scalapay amount range validation: the PayPlug account could not be read.', [ - 'payment_method' => $paymentMethod->getCode(), - 'exception' => $exception->getMessage(), - ]); - - return null; - } - - if (null === $authorizedRange) { - $this->logger->warning('Skipping Scalapay amount range validation: the PayPlug account authorizes no EUR range for Scalapay.', [ - 'payment_method' => $paymentMethod->getCode(), - ]); - } - - return $authorizedRange; - } - - /** - * @return array{min_amount: int, max_amount: int}|null - */ - private function resolveApiAuthorizedRange(PaymentMethodInterface $paymentMethod): ?array - { - $account = $this->apiClientFactory->createForPaymentMethod($paymentMethod)->getAccount(); - $currencies = $this->amountRangeResolver->resolve($account, ScalapayGatewayFactory::PAYMENT_METHOD_SCALAPAY); - - return $currencies['EUR'] ?? null; - } -} diff --git a/src/Ppro/PproConfig.php b/src/Ppro/PproConfig.php new file mode 100644 index 00000000..60efa5f8 --- /dev/null +++ b/src/Ppro/PproConfig.php @@ -0,0 +1,180 @@ +|null $allowedCountries + * @param list|null $configuredCountries + */ + public function __construct( + public readonly ?string $name, + public readonly bool $enabled, + public readonly ?array $allowedCountries, + public readonly ?int $defaultMin, + public readonly ?int $defaultMax, + public readonly ?int $configuredMin, + public readonly ?int $configuredMax, + public readonly ?array $configuredCountries, + public readonly ?bool $live = null, + public readonly ?string $refreshedAt = null, + ) { + } + + /** + * Returns null when nothing is stored or when the stored value does not have the expected + * shape; never throws. + */ + public static function fromArray(mixed $stored): ?self + { + if (!\is_array($stored)) { + return null; + } + + try { + return StoredPproConfigReader::read($stored); + } catch (\UnexpectedValueException) { + return null; + } + } + + /** + * Combines freshly fetched PayPlug limits ($api) with the merchant settings of $previous: a + * configured amount is kept but clamped into the new bounds (an empty side keeps following the + * PayPlug limit, a range left empty falls back to the PayPlug limits), and configured countries + * are intersected with the new allowed list (an empty intersection falls back to all countries). + */ + public static function fromApiLimits(self $api, ?self $previous): self + { + [$configuredMin, $configuredMax] = PproSettingsAdjuster::clampAmounts( + $previous?->configuredMin, + $previous?->configuredMax, + $api->defaultMin, + $api->defaultMax, + ); + + return new self( + $api->name, + $api->enabled, + $api->allowedCountries, + $api->defaultMin, + $api->defaultMax, + $configuredMin, + $configuredMax, + PproSettingsAdjuster::intersectCountries($previous?->configuredCountries, $api->allowedCountries), + $api->live, + $api->refreshedAt, + ); + } + + /** + * Whether a merchant setting held by $previous had to change to fit the limits held here. + */ + public function adjustsSettingsOf(?self $previous): bool + { + if (null === $previous) { + return false; + } + + return (null !== $previous->configuredMin && $previous->configuredMin !== $this->configuredMin) || + (null !== $previous->configuredMax && $previous->configuredMax !== $this->configuredMax) || + (null !== $previous->configuredCountries && $previous->configuredCountries !== $this->configuredCountries); + } + + public function isEligible(int $amount, ?string $billingCountry): bool + { + if (!$this->enabled || $this->hasEmptyAmountRange()) { + return false; + } + + if (!$this->isAmountInRange($amount)) { + return false; + } + + return null === $billingCountry || $this->isCountryEligible($billingCountry); + } + + public function effectiveMin(): ?int + { + if (null === $this->configuredMin || null === $this->defaultMin) { + return $this->configuredMin ?? $this->defaultMin; + } + + return max($this->configuredMin, $this->defaultMin); + } + + public function effectiveMax(): ?int + { + if (null === $this->configuredMax || null === $this->defaultMax) { + return $this->configuredMax ?? $this->defaultMax; + } + + return min($this->configuredMax, $this->defaultMax); + } + + public function hasEmptyAmountRange(): bool + { + $min = $this->effectiveMin(); + $max = $this->effectiveMax(); + + return null !== $min && null !== $max && $min > $max; + } + + /** + * @return array{ + * name: string|null, + * enabled: bool, + * allowed_countries: list|null, + * default_amount: array{min: int|null, max: int|null}|null, + * configured_amount: array{min: int|null, max: int|null}, + * configured_countries: list|null, + * live: bool|null, + * refreshed_at: string|null, + * } + */ + public function toArray(): array + { + $defaultAmount = null === $this->defaultMin && null === $this->defaultMax + ? null + : ['min' => $this->defaultMin, 'max' => $this->defaultMax]; + + return [ + 'name' => $this->name, + 'enabled' => $this->enabled, + 'allowed_countries' => $this->allowedCountries, + 'default_amount' => $defaultAmount, + 'configured_amount' => ['min' => $this->configuredMin, 'max' => $this->configuredMax], + 'configured_countries' => $this->configuredCountries, + 'live' => $this->live, + 'refreshed_at' => $this->refreshedAt, + ]; + } + + private function isAmountInRange(int $amount): bool + { + $min = $this->effectiveMin(); + $max = $this->effectiveMax(); + + return (null === $min || $amount >= $min) && (null === $max || $amount <= $max); + } + + private function isCountryEligible(string $country): bool + { + if (null !== $this->allowedCountries && !\in_array($country, $this->allowedCountries, true)) { + return false; + } + + return null === $this->configuredCountries || \in_array($country, $this->configuredCountries, true); + } +} diff --git a/src/Ppro/PproConfigurationRefresher.php b/src/Ppro/PproConfigurationRefresher.php new file mode 100644 index 00000000..8184a5d7 --- /dev/null +++ b/src/Ppro/PproConfigurationRefresher.php @@ -0,0 +1,113 @@ +getGatewayConfig(); + $apiKey = PproPaymentMethods::apiKey($gatewayConfig?->getFactoryName()); + + if (null === $gatewayConfig || null === $apiKey) { + return PproRefreshResult::notRefreshed(); + } + + try { + $config = $gatewayConfig->getConfig(); + $previous = PproConfig::fromArray($config[PproConfig::CONFIG_KEY] ?? null); + $refreshed = PproConfig::fromApiLimits($this->fetchApiLimits($paymentMethod, $apiKey, $config), $previous); + + $config[PproConfig::CONFIG_KEY] = $refreshed->toArray(); + $this->store($gatewayConfig, $config, $flush); + } catch (\Throwable $exception) { + $this->logger->warning('[PayPlug] Could not refresh the PPRO limits from the PayPlug account.', [ + 'factory_name' => $gatewayConfig->getFactoryName(), + 'exception' => $exception->getMessage(), + ]); + + return PproRefreshResult::notRefreshed(); + } + + return new PproRefreshResult(true, $refreshed->adjustsSettingsOf($previous)); + } + + /** + * @param array $config + */ + private function fetchApiLimits(PaymentMethodInterface $paymentMethod, string $apiKey, array $config): PproConfig + { + $account = $this->apiClientFactory->createForPaymentMethod($paymentMethod)->getAccount(true); + + $paymentMethods = $account['payment_methods'] ?? null; + $entry = \is_array($paymentMethods) ? ($paymentMethods[$apiKey] ?? null) : null; + if (!\is_array($entry)) { + throw new \UnexpectedValueException(sprintf('The PayPlug account has no "%s" payment method entry.', $apiKey)); + } + + $enabled = $entry['enabled'] ?? true; + if (!\is_bool($enabled)) { + throw new \UnexpectedValueException('The "enabled" flag is not a boolean.'); + } + + $bounds = $this->amountRangeResolver->resolve($account, $apiKey)[self::CURRENCY] ?? null; + + return new PproConfig( + $apiKey, + $enabled, + $this->allowedCountriesResolver->resolve($account, $apiKey), + $bounds['min_amount'] ?? null, + $bounds['max_amount'] ?? null, + null, + null, + null, + true === ($config['live'] ?? false), + $this->clock->now()->format(\DateTimeInterface::ATOM), + ); + } + + /** + * @param array $config + */ + private function store(GatewayConfigInterface $gatewayConfig, array $config, bool $flush): void + { + $previousConfig = $gatewayConfig->getConfig(); + $gatewayConfig->setConfig($config); + + if (!$flush) { + return; + } + + try { + $this->entityManager->flush(); + } catch (\Throwable $exception) { + $gatewayConfig->setConfig($previousConfig); + + throw $exception; + } + } +} diff --git a/src/Ppro/PproConfigurationRefresherInterface.php b/src/Ppro/PproConfigurationRefresherInterface.php new file mode 100644 index 00000000..abf26409 --- /dev/null +++ b/src/Ppro/PproConfigurationRefresherInterface.php @@ -0,0 +1,16 @@ +|null $countries null when every country is selected + * + * @return array> translated messages keyed by form field name + */ + public function violations(?PproConfig $stored, ?int $min, ?int $max, ?array $countries): array + { + $violations = [ + PproEligibilityTypeExtension::MIN_AMOUNT_FIELD => $this->minViolations($stored, $min, $max), + PproEligibilityTypeExtension::MAX_AMOUNT_FIELD => $this->maxViolations($stored, $max), + PproEligibilityTypeExtension::COUNTRIES_FIELD => $this->countryViolations($stored?->allowedCountries, $countries), + ]; + + return array_filter($violations, static fn (array $messages): bool => [] !== $messages); + } + + /** + * @return list + */ + private function minViolations(?PproConfig $stored, ?int $min, ?int $max): array + { + $messages = []; + $defaultMin = $stored?->defaultMin; + $defaultMax = $stored?->defaultMax; + + if (null !== $min && null !== $defaultMin && $min < $defaultMin) { + $messages[] = $this->boundsMessage('min_below_api_min', $defaultMin, $defaultMax); + } + + $effectiveMin = $min ?? $defaultMin; + $effectiveMax = $max ?? $defaultMax; + if (null !== $effectiveMin && null !== $effectiveMax && $effectiveMin > $effectiveMax) { + $messages[] = $this->message('min_greater_than_max'); + } + + return $messages; + } + + /** + * @return list + */ + private function maxViolations(?PproConfig $stored, ?int $max): array + { + $defaultMax = $stored?->defaultMax; + + if (null !== $max && null !== $defaultMax && $max > $defaultMax) { + return [$this->boundsMessage('max_above_api_max', $stored?->defaultMin, $defaultMax)]; + } + + return []; + } + + /** + * @param list|null $allowedCountries + * @param list|null $countries + * + * @return list + */ + private function countryViolations(?array $allowedCountries, ?array $countries): array + { + if ([] === $countries) { + return [$this->message('no_country')]; + } + + if (null === $countries || null === $allowedCountries) { + return []; + } + + $notAllowed = array_values(array_diff($countries, $allowedCountries)); + + return [] === $notAllowed ? [] : [$this->message('country_not_allowed', ['%countries%' => implode(', ', $notAllowed)])]; + } + + private function boundsMessage(string $key, ?int $defaultMin, ?int $defaultMax): string + { + return $this->message($key, [ + '%min%' => $this->formatAmount($defaultMin), + '%max%' => $this->formatAmount($defaultMax), + ]); + } + + private function formatAmount(?int $amount): string + { + return null === $amount ? '' : $this->moneyFormatter->format($amount, self::CURRENCY, $this->translator->getLocale()); + } + + /** + * @param array $parameters + */ + private function message(string $key, array $parameters = []): string + { + return $this->translator->trans('payplug_sylius_payplug_plugin.ppro.' . $key, $parameters, self::DOMAIN); + } +} diff --git a/src/Ppro/PproEligibilityChecker.php b/src/Ppro/PproEligibilityChecker.php new file mode 100644 index 00000000..d5b88216 --- /dev/null +++ b/src/Ppro/PproEligibilityChecker.php @@ -0,0 +1,66 @@ +readStoredConfig($paymentMethod); + if (null === $config) { + return true; + } + + if ($config->hasEmptyAmountRange()) { + $this->logger->warning('[PayPlug] The PPRO amount range is empty; the payment method is hidden until its limits are refreshed.', [ + 'factory_name' => $paymentMethod->getGatewayConfig()?->getFactoryName(), + 'min' => $config->effectiveMin(), + 'max' => $config->effectiveMax(), + ]); + } + + return $config->isEligible($amount, $billingCountryCode); + } + + /** + * Null when nothing is stored, or when the stored block is malformed (logged). + */ + private function readStoredConfig(PaymentMethodInterface $paymentMethod): ?PproConfig + { + $gatewayConfig = $paymentMethod->getGatewayConfig(); + $stored = $gatewayConfig?->getConfig()[PproConfig::CONFIG_KEY] ?? null; + $config = null === $stored ? null : PproConfig::fromArray($stored); + + if (null !== $stored && null === $config) { + $this->logger->warning('[PayPlug] Ignoring a malformed stored PPRO configuration.', [ + 'factory_name' => $gatewayConfig?->getFactoryName(), + ]); + } + + return $config; + } +} diff --git a/src/Ppro/PproRefreshResult.php b/src/Ppro/PproRefreshResult.php new file mode 100644 index 00000000..5b8bd505 --- /dev/null +++ b/src/Ppro/PproRefreshResult.php @@ -0,0 +1,19 @@ +readDetails($responseBody); + if (null === $details) { + return null; + } + + if (\array_key_exists('amount', $details)) { + return self::AMOUNT; + } + + $billing = $details['billing'] ?? null; + + return \is_array($billing) && \array_key_exists('country', $billing) ? self::COUNTRY : null; + } + + /** + * @return array|null + */ + private function readDetails(?string $responseBody): ?array + { + $body = null === $responseBody || '' === $responseBody ? null : json_decode($responseBody, true); + $details = \is_array($body) ? ($body['details'] ?? null) : null; + + return \is_array($details) ? $details : null; + } +} diff --git a/src/Ppro/PproSettingsAdjuster.php b/src/Ppro/PproSettingsAdjuster.php new file mode 100644 index 00000000..4d6d7db2 --- /dev/null +++ b/src/Ppro/PproSettingsAdjuster.php @@ -0,0 +1,60 @@ + $right; + } + + /** + * @param list|null $configured + * @param list|null $allowed + * + * @return list|null + */ + public static function intersectCountries(?array $configured, ?array $allowed): ?array + { + if (null === $configured || null === $allowed) { + return $configured; + } + + $intersection = array_values(array_intersect($configured, $allowed)); + + return [] === $intersection ? null : $intersection; + } +} diff --git a/src/Ppro/StoredPproConfigReader.php b/src/Ppro/StoredPproConfigReader.php new file mode 100644 index 00000000..7c9acfe8 --- /dev/null +++ b/src/Ppro/StoredPproConfigReader.php @@ -0,0 +1,107 @@ + $stored + * + * @throws \UnexpectedValueException when a stored value does not have the expected type + */ + public static function read(array $stored): PproConfig + { + $defaultAmount = self::readAmountRange($stored['default_amount'] ?? null); + $configuredAmount = self::readAmountRange($stored['configured_amount'] ?? null); + + return new PproConfig( + self::readString($stored['name'] ?? null), + self::readBool($stored['enabled'] ?? null) ?? true, + self::readCountries($stored['allowed_countries'] ?? null), + $defaultAmount[0], + $defaultAmount[1], + $configuredAmount[0], + $configuredAmount[1], + self::readCountries($stored['configured_countries'] ?? null), + self::readBool($stored['live'] ?? null), + self::readString($stored['refreshed_at'] ?? null), + ); + } + + /** + * @return array{0: int|null, 1: int|null} + */ + private static function readAmountRange(mixed $range): array + { + if (null === $range) { + return [null, null]; + } + + if (!\is_array($range)) { + throw new \UnexpectedValueException('Amount range must be an array.'); + } + + return [self::readAmount($range['min'] ?? null), self::readAmount($range['max'] ?? null)]; + } + + private static function readAmount(mixed $amount): ?int + { + if (null === $amount) { + return null; + } + + if (!\is_int($amount) || $amount < 0) { + throw new \UnexpectedValueException('Amount must be a positive integer.'); + } + + return $amount; + } + + /** + * @return list|null + */ + private static function readCountries(mixed $countries): ?array + { + if (null === $countries) { + return null; + } + + if (!\is_array($countries)) { + throw new \UnexpectedValueException('Country list must be an array.'); + } + + $codes = []; + foreach ($countries as $country) { + if (!\is_string($country)) { + throw new \UnexpectedValueException('Country code must be a string.'); + } + $codes[] = $country; + } + + return $codes; + } + + private static function readString(mixed $value): ?string + { + if (null !== $value && !\is_string($value)) { + throw new \UnexpectedValueException('Expected a string.'); + } + + return $value; + } + + private static function readBool(mixed $value): ?bool + { + if (null !== $value && !\is_bool($value)) { + throw new \UnexpectedValueException('Expected a boolean.'); + } + + return $value; + } +} diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index f285683b..b535ee6a 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -6,14 +6,15 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; -use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproEligibilityChecker; +use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAllowedCountriesResolver; use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Webmozart\Assert\Assert; -use Webmozart\Assert\InvalidArgumentException; final class SupportedMethodsProvider { @@ -22,6 +23,8 @@ public function __construct( private PayPlugApiClientFactoryInterface $clientFactory, private AccountAmountRangeResolver $amountRangeResolver, private LoggerInterface $logger, + private PproEligibilityChecker $pproEligibilityChecker, + private AccountAllowedCountriesResolver $allowedCountriesResolver, ) { } @@ -47,7 +50,7 @@ public function provide( ): array { $activeCurrencyCode = $paymentCurrencyCode ?? $this->currencyContext->getCurrencyCode(); - /** @var array> $accounts */ + /** @var array, allowedCountries: list|null}|null> $accounts */ $accounts = []; foreach ($supportedMethods as $key => $paymentMethod) { @@ -60,46 +63,18 @@ public function provide( continue; } - $memoKey = $this->accountMemoKey($gatewayConfig); - $account = $accounts[$memoKey] ??= $this->clientFactory->createForPaymentMethod($paymentMethod)->getAccount(); - - $authorizedCurrencies = $this->resolveAuthorizedCurrencies($account, $gatewayConfig); - $allowedCountries = $this->resolveAllowedCountries($account, $gatewayConfig); - - if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) { - unset($supportedMethods[$key]); - - continue; - } - - if (!\array_key_exists($activeCurrencyCode, $authorizedCurrencies)) { - // Unified Hosted Fields is exempt from the currency gate, pending a source of truth - // that actually knows a UHF account's currencies. The Retail `/account` payload only - // advertises the legacy acquiring setup (`configuration.min_amounts` / - // `max_amounts`), which is demonstrably wrong for a UHF account: account 1461487 - // reports `currencies: ['EUR']` while a USD payment on that same account completed - // on staging (schemeTransactionId 6a9ad8eb905d3). Hiding a method that works is the - // worse failure, so UHF is kept and left to the API to accept or refuse. - // - // Every other gateway keeps the gate: Bancontact, Scalapay, Apple Pay, American - // Express and Wero are EUR-only, and `payplug` in integrated_payment mode is served - // by the very legacy setup this payload does describe correctly. - if (!PayPlugGatewayFactory::isHostedFieldsConfig($gatewayConfig)) { - unset($supportedMethods[$key]); + if (PproPaymentMethods::isPpro($factoryName)) { + $isSupported = $this->pproEligibilityChecker->isEligible($paymentMethod, $paymentAmount, $activeCurrencyCode, $billingCountryCode); + } else { + $memoKey = $this->accountMemoKey($gatewayConfig); + if (!\array_key_exists($memoKey, $accounts)) { + $accounts[$memoKey] = $this->readAccountLimits($paymentMethod, $gatewayConfig); } - // An unadvertised currency carries no amount limits either, so a method kept above - // skips the bounds check rather than indexing a missing key. - continue; + $isSupported = $this->isWithinAccountLimits($accounts[$memoKey], $gatewayConfig, $paymentAmount, $activeCurrencyCode, $billingCountryCode); } - [$minAmount, $maxAmount] = $this->resolveAmountBounds( - $gatewayConfig, - $activeCurrencyCode, - $authorizedCurrencies[$activeCurrencyCode], - ); - - if ($paymentAmount < $minAmount || $paymentAmount > $maxAmount) { + if (!$isSupported) { unset($supportedMethods[$key]); } } @@ -108,66 +83,55 @@ public function provide( } /** - * ScalapayGatewayConfigurationTypeExtension lets the merchant tighten the API-provided bounds - * via the min_amount/max_amount config keys. The override is deliberately scoped to Scalapay: - * IsScalapayAmountRangeValidValidator — the save-time guardrail that keeps the configured - * range inside what PayPlug authorizes — is wired for Scalapay only, so honouring the same - * keys on another gateway would grant it a checkout override with no validation behind it. - * The values are entered in EUR, so the override also only applies to an EUR checkout; other - * currencies keep the raw API bounds. - * - * @param array{min_amount: int, max_amount: int} $authorizedRange - * - * @return array{0: int, 1: int} + * @param array{currencies: array, allowedCountries: list|null}|null $accountLimits null when the account could not be read */ - private function resolveAmountBounds( + private function isWithinAccountLimits( + ?array $accountLimits, GatewayConfigInterface $gatewayConfig, + int $paymentAmount, string $activeCurrencyCode, - array $authorizedRange, - ): array { - if ('EUR' !== $activeCurrencyCode || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) { - return [$authorizedRange['min_amount'], $authorizedRange['max_amount']]; + ?string $billingCountryCode, + ): bool { + if (null === $accountLimits) { + return true; } - [$minAmount, $maxAmount] = $this->readConfiguredAmounts($gatewayConfig->getConfig()); + $allowedCountries = $accountLimits['allowedCountries']; + if (null !== $billingCountryCode && null !== $allowedCountries && !\in_array($billingCountryCode, $allowedCountries, true)) { + return false; + } - return [ - $minAmount ?? $authorizedRange['min_amount'], - $maxAmount ?? $authorizedRange['max_amount'], - ]; + return $this->isWithinCurrencyLimits($accountLimits['currencies'], $gatewayConfig, $paymentAmount, $activeCurrencyCode); } /** - * The admin form only ever writes null or an int, but the gateway config is a plain serialized - * array that a direct DB edit, an import script or an admin API write can leave anything in. - * provide() runs unguarded on every checkout page (via the gateway resolver decorators), so a - * malformed value degrades to "not configured" — falling back to the API bounds — rather than - * throwing an assertion error that would break payment-method resolution for the whole - * checkout, not just hide Scalapay. - * - * @param array $config - * - * @return array{0: int|null, 1: int|null} + * @param array $authorizedCurrencies */ - private function readConfiguredAmounts(array $config): array - { - $minAmount = $config[ScalapayGatewayFactory::MIN_AMOUNT] ?? null; - $maxAmount = $config[ScalapayGatewayFactory::MAX_AMOUNT] ?? null; - - try { - Assert::nullOrInteger($minAmount); - Assert::nullOrInteger($maxAmount); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('Ignoring malformed Scalapay amount range in gateway config; falling back to the PayPlug API bounds.', [ - 'min_amount' => $minAmount, - 'max_amount' => $maxAmount, - 'exception' => $exception->getMessage(), - ]); - - return [null, null]; + private function isWithinCurrencyLimits( + array $authorizedCurrencies, + GatewayConfigInterface $gatewayConfig, + int $paymentAmount, + string $activeCurrencyCode, + ): bool { + if (!\array_key_exists($activeCurrencyCode, $authorizedCurrencies)) { + // Unified Hosted Fields is exempt from the currency gate, pending a source of truth + // that actually knows a UHF account's currencies. The Retail `/account` payload only + // advertises the legacy acquiring setup (`configuration.min_amounts` / + // `max_amounts`), which is demonstrably wrong for a UHF account: account 1461487 + // reports `currencies: ['EUR']` while a USD payment on that same account completed + // on staging (schemeTransactionId 6a9ad8eb905d3). Hiding a method that works is the + // worse failure, so UHF is kept and left to the API to accept or refuse. + // + // Every other gateway keeps the gate: Apple Pay and American Express are EUR-only, + // and `payplug` in integrated_payment mode is served by the very legacy setup this + // payload does describe correctly. An unadvertised currency carries no amount limits + // either, so a kept method skips the bounds check. + return PayPlugGatewayFactory::isHostedFieldsConfig($gatewayConfig); } - return [$minAmount, $maxAmount]; + $authorizedRange = $authorizedCurrencies[$activeCurrencyCode]; + + return $paymentAmount >= $authorizedRange['min_amount'] && $paymentAmount <= $authorizedRange['max_amount']; } /** @@ -189,48 +153,41 @@ private function accountMemoKey(GatewayConfigInterface $gatewayConfig): string } /** - * Both resolvers below read the factory name off the gateway config the $account was fetched - * for, rather than off provide()'s $factoryName argument. The loop guard above makes the two - * equal today, but keeping the account payload and the key used to index it sourced from the - * same config is what stops the pair drifting apart if that guard is ever relaxed. + * The PPRO gateways never reach this point; for the others the `/account` payload is still read + * live (through the token-keyed cache of PayPlugApiClient). A failure to read or parse it keeps + * the method rather than breaking payment-method resolution for the whole checkout, and is + * memoized by the caller like a successful read. * - * @param array $account + * The factory name is read off the gateway config the account is fetched for, so the payload and + * the key used to index it always come from the same config. * - * @return array - */ - private function resolveAuthorizedCurrencies(array $account, GatewayConfigInterface $gatewayConfig): array - { - return $this->amountRangeResolver->resolve($account, $this->paymentMethodKey($gatewayConfig)); - } - - /** - * @param array $account + * @return array{currencies: array, allowedCountries: list|null}|null */ - private function resolveAllowedCountries(array $account, GatewayConfigInterface $gatewayConfig): array - { - $pmKey = $this->paymentMethodKey($gatewayConfig); - if (null === $pmKey) { - return []; - } - - $paymentMethods = $account['payment_methods'] ?? []; - Assert::isArray($paymentMethods); - $pmData = $paymentMethods[$pmKey] ?? []; - Assert::isArray($pmData); - - $allowedCountries = $pmData['allowed_countries'] ?? []; - Assert::isArray($allowedCountries); + private function readAccountLimits( + PaymentMethodInterface $paymentMethod, + GatewayConfigInterface $gatewayConfig, + ): ?array { + try { + $account = $this->clientFactory->createForPaymentMethod($paymentMethod)->getAccount(); + $paymentMethodKey = $this->paymentMethodKey($gatewayConfig); + + return [ + 'currencies' => $this->amountRangeResolver->resolve($account, $paymentMethodKey), + 'allowedCountries' => $this->allowedCountriesResolver->resolve($account, $paymentMethodKey), + ]; + } catch (\Throwable $exception) { + $this->logger->warning('[PayPlug] Could not read the PayPlug account; the payment method is kept.', [ + 'factory_name' => $gatewayConfig->getFactoryName(), + 'exception' => $exception->getMessage(), + ]); - if (\in_array('ALL', $allowedCountries, true)) { - return []; + return null; } - - return $allowedCountries; } /** - * The `/account` payload keys each PPRO method under the factory name's suffix — `payplug_oney` - * is advertised as `oney`. A suffix-less factory name (`payplug`) is the card gateway, which + * The `/account` payload keys each payment method under the factory name's suffix — + * `payplug_apple_pay` is advertised as `apple_pay`. A suffix-less factory name (`payplug`) is the card gateway, which * has no such sub-payload. */ private function paymentMethodKey(GatewayConfigInterface $gatewayConfig): ?string diff --git a/src/Resolver/AccountAllowedCountriesResolver.php b/src/Resolver/AccountAllowedCountriesResolver.php new file mode 100644 index 00000000..26f7e8c7 --- /dev/null +++ b/src/Resolver/AccountAllowedCountriesResolver.php @@ -0,0 +1,59 @@ + $account + * + * @throws \UnexpectedValueException when the payload does not have the expected shape + * + * @return list|null + */ + public function resolve(array $account, ?string $paymentMethodKey): ?array + { + $allowedCountries = null === $paymentMethodKey ? null : $this->readEntry($account, $paymentMethodKey)['allowed_countries'] ?? null; + if (null === $allowedCountries) { + return null; + } + + if (!\is_array($allowedCountries)) { + throw new \UnexpectedValueException('The allowed countries are not a list.'); + } + + $codes = []; + foreach ($allowedCountries as $country) { + if (!\is_string($country)) { + throw new \UnexpectedValueException('An allowed country is not a string.'); + } + $codes[] = $country; + } + + return [] === $codes || \in_array('ALL', $codes, true) ? null : $codes; + } + + /** + * @param array $account + * + * @return array + */ + private function readEntry(array $account, string $paymentMethodKey): array + { + $paymentMethods = $account['payment_methods'] ?? []; + $entry = \is_array($paymentMethods) ? ($paymentMethods[$paymentMethodKey] ?? []) : null; + + if (!\is_array($entry)) { + throw new \UnexpectedValueException('The payment methods of the PayPlug account are malformed.'); + } + + return $entry; + } +} diff --git a/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php index 26aa539f..3d3283b0 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -15,7 +15,6 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsOneyEnabled; -use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -50,7 +49,7 @@ public function process(PaymentMethodInterface $paymentMethod): void BancontactGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), AmericanExpressGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), ApplePayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), - ScalapayGatewayFactory::FACTORY_NAME => $this->processScalapay($paymentMethod), + ScalapayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), WeroGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), default => throw new \InvalidArgumentException('Unsupported payment method'), }; @@ -98,11 +97,4 @@ private function processDefault(PaymentMethodInterface $paymentMethod): Constrai return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } - - private function processScalapay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod(), new IsScalapayAmountRangeValid(), new HasNoGatewayChannelConflict()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } } diff --git a/templates/admin/payment_method/form/ppro_eligibility.html.twig b/templates/admin/payment_method/form/ppro_eligibility.html.twig new file mode 100644 index 00000000..6c258363 --- /dev/null +++ b/templates/admin/payment_method/form/ppro_eligibility.html.twig @@ -0,0 +1,68 @@ +{% set config_form = hookable_metadata.context.form.gatewayConfig.config %} +{% set gateway_config = hookable_metadata.context.form.vars.data.gatewayConfig %} +{% set ppro = gateway_config ? gateway_config.config['payment_method']|default(null) : null %} +{% set default_amount = ppro is iterable ? ppro['default_amount']|default(null) : null %} +{% set default_min = default_amount is iterable ? default_amount['min']|default(null) : null %} +{% set default_max = default_amount is iterable ? default_amount['max']|default(null) : null %} +{% set locale = app.request.locale|default('en') %} +{% set min_label = default_min is not null ? default_min|sylius_format_money('EUR', locale) : '' %} +{% set max_label = default_max is not null ? default_max|sylius_format_money('EUR', locale) : '' %} +{% set controller = '@payplug/sylius-payplug-plugin/ppro-eligibility' %} +{% set bounds = {} %} +{% if default_min is not null %}{% set bounds = bounds|merge({minBound: default_min}) %}{% endif %} +{% if default_max is not null %}{% set bounds = bounds|merge({maxBound: default_max}) %}{% endif %} + +
+ {% if ppro is iterable and ppro['enabled']|default(true) is same as(false) %} + + {% endif %} + {% if config_form.ppro_min_amount.vars.disabled %} + + {% endif %} + +
+
+ +
+
+

{{ 'payplug_sylius_payplug_plugin.ui.ppro.amount_title'|trans }}

+

{{ 'payplug_sylius_payplug_plugin.ui.ppro.amount_help'|trans }}

+ {% if default_min is not null and default_max is not null %} +

{{ 'payplug_sylius_payplug_plugin.ui.ppro.api_limits'|trans({'%min%': min_label, '%max%': max_label}) }}

+ {% endif %} +
+
+ {{ form_row(config_form.ppro_min_amount) }} + +
+
+ {{ form_row(config_form.ppro_max_amount) }} + +
+
+
+
+ +
+
+ +
+
+

{{ 'payplug_sylius_payplug_plugin.ui.ppro.countries_title'|trans }}

+

{{ 'payplug_sylius_payplug_plugin.ui.ppro.countries_help'|trans }}

+ {{ form_row(config_form.ppro_countries) }} + +
+
+
diff --git a/templates/admin/payment_method/form/scalapay_amount_range.html.twig b/templates/admin/payment_method/form/scalapay_amount_range.html.twig deleted file mode 100644 index 6a0c0b3b..00000000 --- a/templates/admin/payment_method/form/scalapay_amount_range.html.twig +++ /dev/null @@ -1,9 +0,0 @@ -{% set min_amount_form = hookable_metadata.context.form.gatewayConfig.config.min_amount %} -{% set max_amount_form = hookable_metadata.context.form.gatewayConfig.config.max_amount %} - -
- {{ form_row(min_amount_form) }} -
-
- {{ form_row(max_amount_form) }} -
diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php index 4d534bb0..c8886416 100644 --- a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -9,6 +9,9 @@ use PayPlug\SyliusPayPlugPlugin\Action\Admin\Auth\UnifiedAuthenticationController; use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationRefresherInterface; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproRefreshResult; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; use PHPUnit\Framework\MockObject\MockObject; @@ -53,8 +56,15 @@ final class UnifiedAuthenticationControllerTest extends TestCase private ValidatorInterface&MockObject $validator; + private PproConfigurationRefresherInterface&MockObject $pproConfigurationRefresher; + private UnifiedAuthenticationController $controller; + /** @var list */ + private array $calls = []; + + private PproRefreshResult $refreshResult; + protected function setUp(): void { $this->router = $this->createMock(RouterInterface::class); @@ -71,6 +81,8 @@ protected function setUp(): void ); $this->logger = $this->createMock(LoggerInterface::class); $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->pproConfigurationRefresher = $this->createMock(PproConfigurationRefresherInterface::class); + $this->refreshResult = new PproRefreshResult(true); $this->controller = new UnifiedAuthenticationController( $this->router, @@ -80,6 +92,7 @@ protected function setUp(): void $this->logger, $this->oauthHttpClient, new IdTokenEmailExtractor(), + $this->pproConfigurationRefresher, 'https://api-qa.payplug.com', 'https://www.payplug.com', ); @@ -178,6 +191,7 @@ public function testOauthCallback_withMismatchedState_rejectsBeforeExchangingTok { $this->stubRouterGenerate([]); $this->oauthHttpClient->expects(self::never())->method('post'); + $this->pproConfigurationRefresher->expects(self::never())->method('refresh'); $this->logger->expects(self::once())->method('critical') ->with('Error while perform Payplug OAuth callback', self::anything()) ; @@ -319,8 +333,11 @@ private function idTokenWithClaims(array $claims): string * * @return array */ - private function runSuccessfulCallback(array $tokenResponse, array $initialConfig = []): array - { + private function runSuccessfulCallback( + array $tokenResponse, + array $initialConfig = [], + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + ): array { $this->stubRouterGenerate([ 'payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback', 'sylius_admin_payment_method_update' => '/admin/payment-methods/1/edit', @@ -338,7 +355,7 @@ private function runSuccessfulCallback(array $tokenResponse, array $initialConfi $storedConfig = $initialConfig; $gatewayConfig = $this->createMock(GatewayConfigInterface::class); - $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); $gatewayConfig->method('getConfig')->willReturnCallback(static fn (): array => $storedConfig); $gatewayConfig->method('setConfig')->willReturnCallback( static function (array $config) use (&$storedConfig): void { @@ -352,7 +369,22 @@ static function (array $config) use (&$storedConfig): void { $paymentMethod->method('getId')->willReturn(1); $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); - $this->validator->method('validate')->willReturn(new ConstraintViolationList()); + $this->entityManager->method('flush')->willReturnCallback(function (): void { + $this->calls[] = 'flush'; + }); + $this->pproConfigurationRefresher->method('refresh')->willReturnCallback( + function (PaymentMethodInterface $refreshed, bool $flush = true) use ($paymentMethod): PproRefreshResult { + self::assertSame($paymentMethod, $refreshed); + $this->calls[] = $flush ? 'refresh+flush' : 'refresh'; + + return $this->refreshResult; + }, + ); + $this->validator->method('validate')->willReturnCallback(function (): ConstraintViolationList { + $this->calls[] = 'validate'; + + return new ConstraintViolationList(); + }); $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']); $session = $request->getSession(); @@ -422,4 +454,33 @@ public function testOauthCallback_overwritesAPreviouslyStoredEmail(): void self::assertSame('new-owner@example.com', $config['account_email'] ?? null); } + + // ------------------------------------------------------------------------- + // oauthCallback() — PPRO limits captured right after the credentials + // ------------------------------------------------------------------------- + + public function testCallbackRefreshesPproConfigAfterCredentialsAreFlushed(): void + { + $this->runSuccessfulCallback( + ['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer'], + factoryName: WeroGatewayFactory::FACTORY_NAME, + ); + + // The refresher does not flush: PaymentMethodValidator::process() flushes right after it. + self::assertSame(['flush', 'refresh', 'validate', 'flush'], $this->calls); + } + + public function testCallbackStillSucceedsWhenRefreshReportsFailure(): void + { + $this->refreshResult = PproRefreshResult::notRefreshed(); + + $config = $this->runSuccessfulCallback( + ['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer'], + factoryName: WeroGatewayFactory::FACTORY_NAME, + ); + + self::assertSame('generated_client', $config['live_client']['client_id'] ?? null); + self::assertContains('refresh', $this->calls); + self::assertContains('validate', $this->calls); + } } diff --git a/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php new file mode 100644 index 00000000..f96fab1a --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php @@ -0,0 +1,257 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->client = $this->createMock(PayPlugApiClientInterface::class); + $this->refresher = $this->createMock(PproConfigurationRefresherInterface::class); + $this->finalUrlProvider = $this->createMock(FinalUrlProviderInterface::class); + $this->finalUrlProvider->method('getUrl')->willReturn(self::RETRY_URL); + $this->logger = $this->createMock(LoggerInterface::class); + $this->requestStack = new RequestStack(); + + $clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $clientFactory->method('createForPaymentMethod')->willReturn($this->client); + + $dataCreator = $this->createMock(PayPlugPaymentDataCreator::class); + $dataCreator->method('create')->willReturn(new ArrayObject(['amount' => 1000, 'currency' => 'EUR'])); + + $afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/after-pay/hash'); + + $this->handler = new CapturePaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $clientFactory, + $dataCreator, + $afterPayUrlProvider, + $this->logger, + $this->refresher, + new PproRefusalClassifier(), + $this->finalUrlProvider, + $this->requestStack, + ); + } + + public function testCountryRefusalOnPproRefreshesAndRedirectsToOrderPage(): void + { + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + // The bus's doctrine_transaction middleware flushes once the handler returns. + $this->refresher->expects(self::once())->method('refresh')->with($paymentRequest->getMethod(), false)->willReturn(new PproRefreshResult(true, true)); + $this->finalUrlProvider->expects(self::once())->method('getUrl')->with($paymentRequest->getPayment()); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); + $this->logger->expects(self::once())->method('info'); + $this->logger->expects(self::never())->method('error'); + + ($this->handler)(new CapturePaymentRequest('hash')); + + $data = $paymentRequest->getResponseData(); + self::assertSame(self::RETRY_URL, $data['redirect_url']); + self::assertSame('payplug_sylius_payplug_plugin.error.ppro_not_available', $data['error']); + self::assertSame('error', $data['object']); + } + + public function testAmountRefusalOnPproIsHandledTheSameWay(): void + { + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', '{"details":{"amount":"Too low."}}', 400)); + $this->refresher->expects(self::once())->method('refresh')->willReturn(new PproRefreshResult(true)); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame(self::RETRY_URL, $paymentRequest->getResponseData()['redirect_url']); + } + + public function testFlashIsAddedWhenASessionExists(): void + { + $request = new Request(); + $request->setSession(new Session(new MockArraySessionStorage())); + $this->requestStack->push($request); + + $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + $this->refresher->method('refresh')->willReturn(new PproRefreshResult(true)); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame( + ['payplug_sylius_payplug_plugin.error.ppro_not_available'], + $request->getSession()->getFlashBag()->peek('error'), + ); + } + + public function testNoFlashAndNoErrorWithoutASession(): void + { + $this->requestStack->push(new Request()); + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + $this->refresher->method('refresh')->willReturn(new PproRefreshResult(true)); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame(self::RETRY_URL, $paymentRequest->getResponseData()['redirect_url']); + } + + public function testRefresherFailureStillRedirects(): void + { + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + $this->refresher->method('refresh')->willReturn(PproRefreshResult::notRefreshed()); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame(self::RETRY_URL, $paymentRequest->getResponseData()['redirect_url']); + } + + public function testOtherHttpExceptionKeepsTodaysBehaviour(): void + { + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::POSTCODE_REFUSAL, 400)); + $this->refresher->expects(self::never())->method('refresh'); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); + $this->logger->expects(self::once())->method('error'); + + ($this->handler)(new CapturePaymentRequest('hash')); + + $data = $paymentRequest->getResponseData(); + self::assertArrayNotHasKey('redirect_url', $data); + self::assertArrayNotHasKey('error', $data); + self::assertSame(['billing' => ['postcode' => 'Invalid postcode.']], $data['details']); + } + + public function testNonPproFactoryNeverRefreshes(): void + { + $paymentRequest = $this->paymentRequest(ApplePayGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + $this->refresher->expects(self::never())->method('refresh'); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertArrayNotHasKey('redirect_url', $paymentRequest->getResponseData()); + } + + /** + * A refusal whose body is not JSON (a proxy error page, an empty body) still fails the payment + * request cleanly instead of breaking on the response data. + */ + public function testNonJsonRefusalBodyFailsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad gateway', '502', 502)); + $this->refresher->expects(self::never())->method('refresh'); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame([], $paymentRequest->getResponseData()); + } + + public function testHappyPathUnchanged(): void + { + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willReturn(PayplugPayment::fromAttributes([ + 'id' => 'pay_123', + 'hosted_payment' => ['payment_url' => 'https://secure.payplug.com/pay/123'], + ])); + $this->refresher->expects(self::never())->method('refresh'); + $this->expectTransition(PaymentRequestTransitions::TRANSITION_COMPLETE); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame('https://secure.payplug.com/pay/123', $paymentRequest->getResponseData()['redirect_url']); + self::assertSame('pay_123', $paymentRequest->getPayment()->getDetails()['payment_id']); + } + + private function expectTransition(string $transition): void + { + $this->stateMachine->expects(self::once())->method('apply')->with( + self::isInstanceOf(PaymentRequest::class), + PaymentRequestTransitions::GRAPH, + $transition, + ); + } + + private function paymentRequest(string $factoryName): PaymentRequest + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setGatewayName($factoryName); + + $method = new PaymentMethod(); + $method->setCode($factoryName); + $method->setGatewayConfig($gatewayConfig); + + $payment = new Payment(); + $payment->setMethod($method); + $payment->setAmount(1000); + $payment->setCurrencyCode('EUR'); + + $paymentRequest = new PaymentRequest($payment, $method); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } +} diff --git a/tests/PHPUnit/Config/PproTranslationsTest.php b/tests/PHPUnit/Config/PproTranslationsTest.php new file mode 100644 index 00000000..8655bac2 --- /dev/null +++ b/tests/PHPUnit/Config/PproTranslationsTest.php @@ -0,0 +1,111 @@ +message($domain, $locale, $key); + + self::assertIsString($message, sprintf('%s.%s.yml is missing "%s"', $domain, $locale, $key)); + self::assertNotSame('', trim($message), sprintf('%s.%s.yml has an empty "%s"', $domain, $locale, $key)); + + preg_match_all('/%[a-z_]+%/', $message, $matches); + $found = array_unique($matches[0]); + sort($found); + $placeholders ??= $found; + self::assertSame($placeholders, $found, sprintf('"%s" does not use the same placeholders in %s.%s.yml', $key, $domain, $locale)); + } + } + + /** + * @return iterable + */ + public static function pproKeys(): iterable + { + $keys = [ + 'messages' => [ + 'payplug_sylius_payplug_plugin.ui.ppro.amount_title', + 'payplug_sylius_payplug_plugin.ui.ppro.amount_help', + 'payplug_sylius_payplug_plugin.ui.ppro.min_amount', + 'payplug_sylius_payplug_plugin.ui.ppro.max_amount', + 'payplug_sylius_payplug_plugin.ui.ppro.api_limits', + 'payplug_sylius_payplug_plugin.ui.ppro.countries_title', + 'payplug_sylius_payplug_plugin.ui.ppro.countries_help', + 'payplug_sylius_payplug_plugin.ui.ppro.countries_label', + 'payplug_sylius_payplug_plugin.ui.ppro.not_connected', + 'payplug_sylius_payplug_plugin.ui.ppro.disabled_by_payplug', + 'payplug_sylius_payplug_plugin.error.ppro_not_available', + ], + 'validators' => [ + 'payplug_sylius_payplug_plugin.ppro.min_below_api_min', + 'payplug_sylius_payplug_plugin.ppro.max_above_api_max', + 'payplug_sylius_payplug_plugin.ppro.min_greater_than_max', + 'payplug_sylius_payplug_plugin.ppro.invalid_amount', + 'payplug_sylius_payplug_plugin.ppro.no_country', + 'payplug_sylius_payplug_plugin.ppro.country_not_allowed', + ], + 'flashes' => [ + 'payplug_sylius_payplug_plugin.ppro.refresh_failed', + 'payplug_sylius_payplug_plugin.ppro.settings_adjusted', + 'payplug_sylius_payplug_plugin.error.ppro_not_available', + ], + ]; + + foreach ($keys as $domain => $domainKeys) { + foreach ($domainKeys as $key) { + yield $domain . ': ' . $key => [$domain, $key]; + } + } + } + + /** + * @dataProvider removedScalapayKeys + */ + public function testRemovedScalapayKeysAreGone(string $domain, string $key): void + { + foreach (self::LOCALES as $locale) { + self::assertNull($this->message($domain, $locale, $key), sprintf('%s.%s.yml still has "%s"', $domain, $locale, $key)); + } + } + + /** + * @return iterable + */ + public static function removedScalapayKeys(): iterable + { + yield 'scalapay min label' => ['messages', 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.min_amount']; + yield 'scalapay max label' => ['messages', 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.max_amount']; + yield 'scalapay help' => ['messages', 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help']; + yield 'scalapay min greater than max' => ['validators', 'payplug_sylius_payplug_plugin.payplug_scalapay.min_amount_greater_than_max']; + yield 'scalapay out of range' => ['validators', 'payplug_sylius_payplug_plugin.payplug_scalapay.amount_out_of_authorized_range']; + } + + private function message(string $domain, string $locale, string $key): mixed + { + $node = Yaml::parseFile(sprintf('%s/../../../translations/%s.%s.yml', __DIR__, $domain, $locale)); + + foreach (explode('.', $key) as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + $node = $node[$segment]; + } + + return $node; + } +} diff --git a/tests/PHPUnit/Config/PproTwigHooksConfigTest.php b/tests/PHPUnit/Config/PproTwigHooksConfigTest.php new file mode 100644 index 00000000..1e58dc31 --- /dev/null +++ b/tests/PHPUnit/Config/PproTwigHooksConfigTest.php @@ -0,0 +1,49 @@ +repository = $this->createMock(PaymentMethodRepositoryInterface::class); + $this->refresher = $this->createMock(PproConfigurationRefresherInterface::class); + + $this->tester = new CommandTester(new RefreshPproConfigurationCommand($this->repository, $this->refresher)); + } + + public function testIsNamedPayplugPproRefresh(): void + { + self::assertSame('payplug:ppro:refresh', (new RefreshPproConfigurationCommand($this->repository, $this->refresher))->getName()); + } + + public function testRefreshesEveryPproPaymentMethod(): void + { + $bancontact = $this->paymentMethod('bancontact', BancontactGatewayFactory::FACTORY_NAME); + $wero = $this->paymentMethod('wero', WeroGatewayFactory::FACTORY_NAME); + $scalapay = $this->paymentMethod('scalapay', ScalapayGatewayFactory::FACTORY_NAME, enabled: false); + $this->repository->method('findAll')->willReturn([$bancontact, $wero, $scalapay]); + + $refreshed = []; + $this->refresher->expects(self::exactly(3))->method('refresh')->willReturnCallback( + static function (PaymentMethod $paymentMethod) use (&$refreshed): PproRefreshResult { + $refreshed[] = $paymentMethod->getCode(); + + return new PproRefreshResult(true); + }, + ); + + self::assertSame(Command::SUCCESS, $this->tester->execute([])); + self::assertSame(['bancontact', 'wero', 'scalapay'], $refreshed); + self::assertStringContainsString('wero', $this->tester->getDisplay()); + } + + public function testSkipsNonPproMethods(): void + { + $this->repository->method('findAll')->willReturn([ + $this->paymentMethod('card', PayPlugGatewayFactory::FACTORY_NAME), + $this->paymentMethod('oney', OneyGatewayFactory::FACTORY_NAME), + new PaymentMethod(), + $this->paymentMethod('wero', WeroGatewayFactory::FACTORY_NAME), + ]); + $this->refresher->expects(self::once())->method('refresh')->willReturn(new PproRefreshResult(true)); + + self::assertSame(Command::SUCCESS, $this->tester->execute([])); + } + + public function testReportsFailuresAndExitsNonZeroWhenAnyFails(): void + { + $this->repository->method('findAll')->willReturn([ + $this->paymentMethod('bancontact', BancontactGatewayFactory::FACTORY_NAME), + $this->paymentMethod('wero', WeroGatewayFactory::FACTORY_NAME), + ]); + $this->refresher->method('refresh')->willReturnOnConsecutiveCalls( + PproRefreshResult::notRefreshed(), + new PproRefreshResult(true), + ); + + self::assertSame(Command::FAILURE, $this->tester->execute([])); + self::assertStringContainsString('bancontact', $this->tester->getDisplay()); + } + + public function testReportsNeverConnectedMethodsAsSkipped(): void + { + $this->repository->method('findAll')->willReturn([ + $this->paymentMethod('bancontact', BancontactGatewayFactory::FACTORY_NAME, connected: false), + $this->paymentMethod('wero', WeroGatewayFactory::FACTORY_NAME), + ]); + $this->refresher->expects(self::once())->method('refresh')->willReturn(new PproRefreshResult(true)); + + self::assertSame(Command::SUCCESS, $this->tester->execute([])); + self::assertStringContainsString('Skipped "bancontact"', $this->tester->getDisplay()); + } + + public function testDoesNothingWhenNoPproMethodExists(): void + { + $this->repository->method('findAll')->willReturn([$this->paymentMethod('card', PayPlugGatewayFactory::FACTORY_NAME)]); + $this->refresher->expects(self::never())->method('refresh'); + + self::assertSame(Command::SUCCESS, $this->tester->execute([])); + } + + private function paymentMethod( + string $code, + string $factoryName, + bool $enabled = true, + bool $connected = true, + ): PaymentMethod { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setGatewayName($factoryName); + $gatewayConfig->setConfig($connected ? ['test_client' => ['client_id' => 'id', 'client_secret' => 'secret']] : []); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setCode($code); + $paymentMethod->setEnabled($enabled); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/EventListener/PostSavePaymentMethodEventListenerTest.php b/tests/PHPUnit/EventListener/PostSavePaymentMethodEventListenerTest.php new file mode 100644 index 00000000..fa9def58 --- /dev/null +++ b/tests/PHPUnit/EventListener/PostSavePaymentMethodEventListenerTest.php @@ -0,0 +1,115 @@ +setSession(new Session(new MockArraySessionStorage())); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->method('validate')->willReturn(new ConstraintViolationList()); + + $this->refresher = $this->createMock(PproConfigurationRefresherInterface::class); + + $this->listener = new PostSavePaymentMethodEventListener( + $requestStack, + $this->createMock(RouterInterface::class), + new PaymentMethodValidator($requestStack, $validator, $this->createMock(EntityManagerInterface::class)), + new NullLogger(), + $this->refresher, + ); + } + + public function testSaveThatSwitchesTheEnvironmentRefreshesThePproLimits(): void + { + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'live' => true, + 'payment_method' => ['name' => 'wero', 'live' => false], + ]); + + $this->refresher->expects(self::once())->method('refresh')->with($paymentMethod)->willReturn(new PproRefreshResult(true)); + + $this->listener->onUpdate(new ResourceControllerEvent($paymentMethod)); + } + + public function testSaveInTheSameEnvironmentDoesNotRefresh(): void + { + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'live' => true, + 'payment_method' => ['name' => 'wero', 'live' => true], + ]); + + $this->refresher->expects(self::never())->method('refresh'); + + $this->listener->onUpdate(new ResourceControllerEvent($paymentMethod)); + } + + public function testSaveWithoutStoredLimitsDoesNotRefresh(): void + { + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, ['live' => true]); + + $this->refresher->expects(self::never())->method('refresh'); + + $this->listener->onUpdate(new ResourceControllerEvent($paymentMethod)); + } + + public function testNonPproSaveDoesNotRefresh(): void + { + $paymentMethod = $this->paymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [ + 'live' => true, + 'payment_method' => ['live' => false], + ]); + + $this->refresher->expects(self::never())->method('refresh'); + + $this->listener->onUpdate(new ResourceControllerEvent($paymentMethod)); + } + + /** + * @param array $config + */ + private function paymentMethod(string $factoryName, array $config): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setGatewayName($factoryName); + $gatewayConfig->setConfig($config); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php b/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php new file mode 100644 index 00000000..c49e5999 --- /dev/null +++ b/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php @@ -0,0 +1,198 @@ +paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->refresher = $this->createMock(PproConfigurationRefresherInterface::class); + + $this->listener = new PproConfigRefreshOnAdminDisplayListener($this->paymentMethodRepository, $this->refresher); + } + + public function testRefreshesOnGetOfTheUpdateRoute(): void + { + $paymentMethod = $this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME); + $this->paymentMethodRepository->method('find')->with('7')->willReturn($paymentMethod); + $this->refresher->expects(self::once())->method('refresh')->with($paymentMethod, true)->willReturn(new PproRefreshResult(true)); + + $request = $this->request(); + ($this->listener)($this->event($request)); + + self::assertSame([], $request->getSession()->getFlashBag()->peekAll()); + } + + public function testIgnoresPost(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request(method: 'POST'))); + } + + public function testIgnoresOtherRoutes(): void + { + $this->paymentMethodRepository->expects(self::never())->method('find'); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request(route: 'sylius_admin_payment_method_index'))); + } + + public function testIgnoresNonPproPaymentMethod(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(OneyGatewayFactory::FACTORY_NAME)); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request())); + } + + /** + * A payment method never connected to PayPlug has no limits to fetch: it is not an outage, so + * there is no API call and no warning. + */ + public function testIgnoresNeverConnectedPaymentMethod(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME, false)); + $this->refresher->expects(self::never())->method('refresh'); + + $request = $this->request(); + ($this->listener)($this->event($request)); + + self::assertSame([], $request->getSession()->getFlashBag()->peekAll()); + } + + public function testIgnoresPaymentMethodWithoutGatewayConfig(): void + { + $this->paymentMethodRepository->method('find')->willReturn(new PaymentMethod()); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request())); + } + + public function testIgnoresUnknownId(): void + { + $this->paymentMethodRepository->method('find')->willReturn(null); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request())); + } + + public function testIgnoresMissingId(): void + { + $this->paymentMethodRepository->expects(self::never())->method('find'); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request(id: null))); + } + + public function testFailureAddsWarningFlashAndDoesNotThrow(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); + $this->refresher->method('refresh')->willReturn(PproRefreshResult::notRefreshed()); + + $request = $this->request(); + ($this->listener)($this->event($request)); + + self::assertSame( + ['payplug_sylius_payplug_plugin.ppro.refresh_failed'], + $request->getSession()->getFlashBag()->peek('warning'), + ); + } + + public function testAdjustedResultAddsInfoFlash(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); + $this->refresher->method('refresh')->willReturn(new PproRefreshResult(true, true)); + + $request = $this->request(); + ($this->listener)($this->event($request)); + + self::assertSame( + ['payplug_sylius_payplug_plugin.ppro.settings_adjusted'], + $request->getSession()->getFlashBag()->peek('info'), + ); + } + + public function testRequestWithoutSessionStillRefreshes(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); + $this->refresher->expects(self::once())->method('refresh')->willReturn(PproRefreshResult::notRefreshed()); + + $request = new Request([], [], ['_route' => 'sylius_admin_payment_method_update', 'id' => '7']); + ($this->listener)($this->event($request)); + } + + public function testSubRequestIsIgnored(): void + { + $this->paymentMethodRepository->expects(self::never())->method('find'); + $this->refresher->expects(self::never())->method('refresh'); + + ($this->listener)($this->event($this->request(), HttpKernelInterface::SUB_REQUEST)); + } + + private function request( + string $method = 'GET', + string $route = 'sylius_admin_payment_method_update', + ?string $id = '7', + ): Request { + $attributes = ['_route' => $route]; + if (null !== $id) { + $attributes['id'] = $id; + } + + $request = new Request([], [], $attributes); + $request->setMethod($method); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + private function event(Request $request, int $requestType = HttpKernelInterface::MAIN_REQUEST): ControllerEvent + { + return new ControllerEvent( + $this->createMock(HttpKernelInterface::class), + static fn (): null => null, + $request, + $requestType, + ); + } + + private function paymentMethod(string $factoryName, bool $connected = true): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setGatewayName($factoryName); + $gatewayConfig->setConfig($connected ? ['test_client' => ['client_id' => 'id', 'client_secret' => 'secret']] : []); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php index b470fe11..37bdd036 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php @@ -6,11 +6,18 @@ use PayPlug\SyliusPayPlugPlugin\Checker\GatewayChannelConflictChecker; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Extension\PaymentMethodTypeExtension; +use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Extension\PproEligibilityTypeExtension; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\PayPlugGatewayConfigurationType; +use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\PproCountryChoiceType; +use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\WeroGatewayConfigurationType; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationValidator; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; use PHPUnit\Framework\MockObject\MockObject; use Sylius\Bundle\ChannelBundle\Form\Type\ChannelChoiceType; +use Sylius\Bundle\MoneyBundle\Form\Type\MoneyType; +use Sylius\Bundle\MoneyBundle\Formatter\MoneyFormatter; use Sylius\Bundle\PayumBundle\Model\GatewayConfig; use Sylius\Component\Core\Model\Channel; use Sylius\Component\Core\Model\ChannelInterface; @@ -76,9 +83,17 @@ protected function getTypes(): array return [ new ChannelChoiceType($channelRepository), new PayPlugGatewayConfigurationType($this->translator()), + new WeroGatewayConfigurationType($this->translator()), + new MoneyType(), + new PproCountryChoiceType(), ]; } + protected function getTypeExtensions(): array + { + return [new PproEligibilityTypeExtension()]; + } + /** * A channel another *enabled* CB gateway already holds must come back as a submit-time error on * the `channels` field itself — not bubbled to the root, where the admin form would render it @@ -226,11 +241,14 @@ private function payPlugPaymentMethod(): PaymentMethod * ChannelChoiceType, and `gatewayConfig.config` — with the extension's own listeners attached * to the real root builder, so they run through Symfony's real event dispatch. */ - private function createRootForm(PaymentMethod $paymentMethod): FormInterface - { + private function createRootForm( + PaymentMethod $paymentMethod, + string $configurationType = PayPlugGatewayConfigurationType::class, + ): FormInterface { $extension = new PaymentMethodTypeExtension( new GatewayChannelConflictChecker($this->paymentMethodRepository), $this->translator(), + new PproConfigurationValidator($this->translator(), new MoneyFormatter()), ); $builder = $this->factory->createBuilder(FormType::class, $paymentMethod, [ @@ -243,8 +261,14 @@ private function createRootForm(PaymentMethod $paymentMethod): FormInterface 'expanded' => true, ]); - $gatewayConfig = $builder->create('gatewayConfig', FormType::class, ['mapped' => false]); - $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class, ['mapped' => false]); + if (PayPlugGatewayConfigurationType::class === $configurationType) { + $gatewayConfig = $builder->create('gatewayConfig', FormType::class, ['mapped' => false]); + $gatewayConfig->add('config', $configurationType, ['mapped' => false]); + } else { + // Mapped like Sylius' own GatewayConfigType, so the submitted settings reach the model. + $gatewayConfig = $builder->create('gatewayConfig', FormType::class, ['inherit_data' => true]); + $gatewayConfig->add('config', $configurationType, ['property_path' => 'gatewayConfig.config']); + } $builder->add($gatewayConfig); $extension->buildForm($builder, []); @@ -268,6 +292,7 @@ private function buildChannel(string $code, string $currencyCode): ChannelInterf private function translator(): TranslatorInterface { $translator = $this->createMock(TranslatorInterface::class); + $translator->method('getLocale')->willReturn('en'); $translator->method('trans')->willReturnCallback( static fn (string $id, array $parameters = []): string => \implode( '|', @@ -277,4 +302,105 @@ private function translator(): TranslatorInterface return $translator; } + + // ------------------------------------------------------------------------- + // PPRO eligibility settings are refused at save when out of the PayPlug limits + // ------------------------------------------------------------------------- + + public function testSubmit_pproAmountOutOfBounds_isRejectedOnTheAmountField(): void + { + $form = $this->createRootForm($this->weroPaymentMethod(), WeroGatewayConfigurationType::class); + + $form->submit($this->weroSubmission(['ppro_min_amount' => '0.10', 'ppro_max_amount' => '30000'])); + + self::assertFalse($form->isValid()); + $config = $form->get('gatewayConfig')->get('config'); + self::assertSame( + 'payplug_sylius_payplug_plugin.ppro.min_below_api_min|€0.30|€20,000.00', + $config->get('ppro_min_amount')->getErrors()[0]?->getMessage(), + ); + self::assertSame( + 'payplug_sylius_payplug_plugin.ppro.max_above_api_max|€0.30|€20,000.00', + $config->get('ppro_max_amount')->getErrors()[0]?->getMessage(), + ); + } + + public function testSubmit_pproWithoutCountry_isRejectedOnTheCountriesField(): void + { + $form = $this->createRootForm($this->weroPaymentMethod(), WeroGatewayConfigurationType::class); + + $form->submit($this->weroSubmission(['ppro_countries' => []])); + + self::assertFalse($form->isValid()); + self::assertSame( + 'payplug_sylius_payplug_plugin.ppro.no_country', + $form->get('gatewayConfig')->get('config')->get('ppro_countries')->getErrors()[0]?->getMessage(), + ); + } + + public function testSubmit_pproWithinBounds_isValidAndStoredInCents(): void + { + $paymentMethod = $this->weroPaymentMethod(); + $form = $this->createRootForm($paymentMethod, WeroGatewayConfigurationType::class); + + $form->submit($this->weroSubmission(['ppro_min_amount' => '10', 'ppro_max_amount' => '500', 'ppro_countries' => ['BE']])); + + self::assertTrue($form->isValid(), (string) $form->getErrors(true)); + $block = $paymentMethod->getGatewayConfig()?->getConfig()['payment_method'] ?? []; + self::assertSame(['min' => 1000, 'max' => 50000], $block['configured_amount']); + self::assertSame(['BE'], $block['configured_countries']); + self::assertSame(['min' => 30, 'max' => 2000000], $block['default_amount']); + } + + /** + * The PPRO rules only run for a PPRO configuration type: the card form, which has no such + * fields, still submits cleanly. + */ + public function testSubmit_nonPproGateway_isNotCheckedForPproSettings(): void + { + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $form->submit(['enabled' => '1', 'channels' => ['WEB_IT']]); + + self::assertTrue($form->isValid()); + } + + /** + * @param array $config + * + * @return array + */ + private function weroSubmission(array $config): array + { + return [ + 'enabled' => '1', + 'channels' => ['WEB_IT'], + 'gatewayConfig' => ['config' => array_merge(['live' => '1', 'ppro_countries' => ['BE', 'DE']], $config)], + ]; + } + + private function weroPaymentMethod(): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(WeroGatewayFactory::FACTORY_NAME); + $gatewayConfig->setGatewayName(WeroGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig([ + 'live' => true, + 'live_client' => ['client_id' => 'id', 'client_secret' => 'secret'], + 'payment_method' => [ + 'name' => 'wero', + 'enabled' => true, + 'allowed_countries' => ['BE', 'DE'], + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => ['min' => 30, 'max' => 2000000], + 'configured_countries' => null, + ], + ]); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setCode('wero'); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } } diff --git a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php index 482a02ec..030606bc 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php @@ -9,10 +9,12 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Extension\PaymentMethodTypeExtension; use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\PayPlugGatewayConfigurationType; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationValidator; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\Bundle\ChannelBundle\Form\Type\ChannelChoiceType; +use Sylius\Bundle\MoneyBundle\Formatter\MoneyFormatter; use Sylius\Bundle\PaymentBundle\Form\Type\PaymentMethodType; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -325,6 +327,7 @@ private function captureListener( $extension = new PaymentMethodTypeExtension( new GatewayChannelConflictChecker($repository), $translator ?? $this->translator, + new PproConfigurationValidator($translator ?? $this->translator, new MoneyFormatter()), ); $listener = null; diff --git a/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php new file mode 100644 index 00000000..81cb45e8 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php @@ -0,0 +1,278 @@ + true, 'live_client' => ['client_id' => 'id', 'client_secret' => 'secret']]; + + protected function getTypes(): array + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return [ + new MoneyType(), + new PproCountryChoiceType(), + new BancontactGatewayConfigurationType($translator), + new WeroGatewayConfigurationType($translator), + new ScalapayGatewayConfigurationType($translator), + ]; + } + + protected function getTypeExtensions(): array + { + return [new PproEligibilityTypeExtension()]; + } + + public function testExtendsTheThreePproConfigTypesOnly(): void + { + $extended = [...PproEligibilityTypeExtension::getExtendedTypes()]; + + self::assertSame([ + BancontactGatewayConfigurationType::class, + WeroGatewayConfigurationType::class, + ScalapayGatewayConfigurationType::class, + ], $extended); + + foreach ([OneyGatewayConfigurationType::class, PayPlugGatewayConfigurationType::class, ApplePayGatewayConfigurationType::class, AmericanExpressGatewayConfigurationType::class] as $type) { + self::assertNotContains($type, $extended); + } + } + + /** + * @dataProvider pproConfigTypes + */ + public function testAddsTheThreeFieldsToEachPproConfigType(string $type): void + { + $form = $this->factory->create($type, self::CONNECTED); + + self::assertTrue($form->has('ppro_min_amount')); + self::assertTrue($form->has('ppro_max_amount')); + self::assertTrue($form->has('ppro_countries')); + } + + /** + * @return iterable + */ + public static function pproConfigTypes(): iterable + { + yield 'bancontact' => [BancontactGatewayConfigurationType::class]; + yield 'wero' => [WeroGatewayConfigurationType::class]; + yield 'scalapay' => [ScalapayGatewayConfigurationType::class]; + } + + public function testAmountFieldsAreEuroMoneyWithPropertyPaths(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, self::CONNECTED); + + foreach (['ppro_min_amount' => 'min', 'ppro_max_amount' => 'max'] as $field => $side) { + $config = $form->get($field)->getConfig(); + self::assertInstanceOf(MoneyType::class, $config->getType()->getInnerType()); + self::assertSame('EUR', $config->getOption('currency')); + self::assertFalse($config->getOption('required')); + self::assertSame('[payment_method][configured_amount][' . $side . ']', (string) $config->getPropertyPath()); + } + } + + public function testAmountSubmittedInEurosIsStoredInCents(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block())); + + $form->submit(['live' => '1', 'ppro_min_amount' => '12.50', 'ppro_max_amount' => '1500', 'ppro_countries' => ['BE']]); + + self::assertTrue($form->isSynchronized()); + $data = $form->getData(); + self::assertSame(1250, $data['payment_method']['configured_amount']['min']); + self::assertSame(150000, $data['payment_method']['configured_amount']['max']); + self::assertSame(['BE'], $data['payment_method']['configured_countries']); + self::assertSame(['min' => 30, 'max' => 2000000], $data['payment_method']['default_amount']); + self::assertSame(['BE', 'DE'], $data['payment_method']['allowed_countries']); + self::assertSame(['client_id' => 'id', 'client_secret' => 'secret'], $data['live_client']); + } + + public function testEmptyAmountIsStoredAsNull(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block())); + + $form->submit(['live' => '1', 'ppro_min_amount' => '', 'ppro_max_amount' => '', 'ppro_countries' => ['BE', 'DE']]); + + self::assertSame(['min' => null, 'max' => null], $form->getData()['payment_method']['configured_amount']); + } + + public function testStoredCentsAreDisplayedInEuros(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block([ + 'configured_amount' => ['min' => 1250, 'max' => 150000], + ]))); + + $view = $form->createView(); + + self::assertSame('12.50', $view['ppro_min_amount']->vars['value']); + self::assertSame('1500.00', $view['ppro_max_amount']->vars['value']); + } + + /** + * An empty field follows the PayPlug limit, which the field shows as its placeholder. + */ + public function testAmountFieldsShowTheApiLimitsAsPlaceholders(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block([ + 'configured_amount' => ['min' => null, 'max' => null], + ]))); + + $view = $form->createView(); + + self::assertSame('', $view['ppro_min_amount']->vars['value']); + self::assertSame('0.30', $view['ppro_min_amount']->vars['attr']['placeholder'] ?? null); + self::assertSame('20000.00', $view['ppro_max_amount']->vars['attr']['placeholder'] ?? null); + } + + public function testAmountFieldsHaveNoPlaceholderWithoutApiLimits(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, self::CONNECTED); + + $view = $form->createView(); + + self::assertArrayNotHasKey('placeholder', $view['ppro_min_amount']->vars['attr']); + self::assertArrayNotHasKey('placeholder', $view['ppro_max_amount']->vars['attr']); + } + + public function testCountryChoicesAreTheStoredAllowedCountries(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block())); + + self::assertSame(['BE', 'DE'], $this->choiceValues($form->get('ppro_countries'))); + } + + public function testCountryChoicesAreAllCountriesWhenAllowedIsNull(): void + { + $form = $this->factory->create(BancontactGatewayConfigurationType::class, $this->connectedWith($this->block(['allowed_countries' => null]))); + + self::assertCount(\count(Countries::getCountryCodes()), $this->choiceValues($form->get('ppro_countries'))); + } + + public function testCountryChoicesAreAllCountriesWithoutAStoredBlock(): void + { + $form = $this->factory->create(BancontactGatewayConfigurationType::class, self::CONNECTED); + + self::assertCount(\count(Countries::getCountryCodes()), $this->choiceValues($form->get('ppro_countries'))); + } + + public function testAllChoicesSelectedMapsToNull(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block(['configured_countries' => ['BE']]))); + + $form->submit(['live' => '1', 'ppro_countries' => ['DE', 'BE']]); + + self::assertNull($form->getData()['payment_method']['configured_countries']); + } + + public function testSubsetIsStoredAsList(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block())); + + $form->submit(['live' => '1', 'ppro_countries' => ['DE']]); + + self::assertSame(['DE'], $form->getData()['payment_method']['configured_countries']); + } + + public function testNoCountrySelectedIsStoredAsAnEmptyList(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block())); + + $form->submit(['live' => '1']); + + self::assertSame([], $form->getData()['payment_method']['configured_countries']); + } + + public function testNullConfiguredCountriesRendersAllSelected(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block())); + + self::assertSame(['BE', 'DE'], $form->createView()['ppro_countries']->vars['value']); + } + + public function testConfiguredCountriesRenderSelected(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, $this->connectedWith($this->block(['configured_countries' => ['DE']]))); + + self::assertSame(['DE'], $form->createView()['ppro_countries']->vars['value']); + } + + public function testFieldsAreDisabledBeforeTheConnection(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, []); + + self::assertTrue($form->get('ppro_min_amount')->isDisabled()); + self::assertTrue($form->get('ppro_max_amount')->isDisabled()); + self::assertTrue($form->get('ppro_countries')->isDisabled()); + + $form->submit(['live' => '1', 'ppro_min_amount' => '12.50', 'ppro_countries' => ['FR']]); + + self::assertArrayNotHasKey('payment_method', $form->getData()); + } + + public function testFieldsAreEnabledOnceConnectedEvenWithoutAStoredBlock(): void + { + $form = $this->factory->create(WeroGatewayConfigurationType::class, ['test_client' => ['client_id' => 'id', 'client_secret' => 'secret']]); + + self::assertFalse($form->get('ppro_min_amount')->isDisabled()); + self::assertFalse($form->get('ppro_countries')->isDisabled()); + } + + /** + * @return list + */ + private function choiceValues(FormInterface $field): array + { + return array_values(array_map( + static fn ($choiceView): string => $choiceView->value, + $field->createView()->vars['choices'], + )); + } + + /** + * @param array $block + * + * @return array + */ + private function connectedWith(array $block): array + { + return self::CONNECTED + ['payment_method' => $block]; + } + + /** + * @param array $overrides + * + * @return array + */ + private function block(array $overrides = []): array + { + return array_merge([ + 'name' => 'wero', + 'enabled' => true, + 'allowed_countries' => ['BE', 'DE'], + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => ['min' => 30, 'max' => 2000000], + 'configured_countries' => null, + ], $overrides); + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php deleted file mode 100644 index 5e5f636d..00000000 --- a/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php +++ /dev/null @@ -1,71 +0,0 @@ -extension = new ScalapayGatewayConfigurationTypeExtension(); - } - - public function testBuildForm_addsMinAmountMoneyField(): void - { - [, $addCalls] = $this->buildFormAndCollectAddCalls(); - - [$name, $type, $options] = $addCalls[0]; - self::assertSame(ScalapayGatewayFactory::MIN_AMOUNT, $name); - self::assertSame(MoneyType::class, $type); - self::assertSame('EUR', $options['currency']); - self::assertFalse($options['required']); - } - - public function testBuildForm_addsMaxAmountMoneyField(): void - { - [, $addCalls] = $this->buildFormAndCollectAddCalls(); - - [$name, $type, $options] = $addCalls[1]; - self::assertSame(ScalapayGatewayFactory::MAX_AMOUNT, $name); - self::assertSame(MoneyType::class, $type); - self::assertSame('EUR', $options['currency']); - self::assertFalse($options['required']); - } - - public function testGetExtendedTypes_returnsScalapayGatewayConfigurationType(): void - { - self::assertSame([ScalapayGatewayConfigurationType::class], ScalapayGatewayConfigurationTypeExtension::getExtendedTypes()); - } - - /** - * @return array{0: FormBuilderInterface, 1: array}>} - */ - private function buildFormAndCollectAddCalls(): array - { - $builder = $this->createMock(FormBuilderInterface::class); - - $addCalls = []; - $builder - ->method('add') - ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) { - $addCalls[] = [$name, $type, $options]; - - return $builder; - }) - ; - - $this->extension->buildForm($builder, []); - - return [$builder, $addCalls]; - } -} diff --git a/tests/PHPUnit/Gateway/GatewayCredentialsTest.php b/tests/PHPUnit/Gateway/GatewayCredentialsTest.php new file mode 100644 index 00000000..eb4137b4 --- /dev/null +++ b/tests/PHPUnit/Gateway/GatewayCredentialsTest.php @@ -0,0 +1,47 @@ + 'id', 'client_secret' => 'secret']; + + public function testLiveModeNeedsTheLiveClient(): void + { + self::assertTrue(GatewayCredentials::isConnected(['live' => true, 'live_client' => self::CLIENT])); + self::assertFalse(GatewayCredentials::isConnected(['live' => true, 'test_client' => self::CLIENT])); + } + + public function testTestModeNeedsTheTestClient(): void + { + self::assertTrue(GatewayCredentials::isConnected(['live' => false, 'test_client' => self::CLIENT])); + self::assertTrue(GatewayCredentials::isConnected(['test_client' => self::CLIENT])); + self::assertFalse(GatewayCredentials::isConnected(['live' => false, 'live_client' => self::CLIENT])); + } + + /** + * @dataProvider incompleteConfigs + */ + public function testIncompleteCredentialsAreNotConnected(array $config): void + { + self::assertFalse(GatewayCredentials::isConnected($config)); + } + + /** + * @return iterable}> + */ + public static function incompleteConfigs(): iterable + { + yield 'never connected' => [[]]; + yield 'client is null' => [['test_client' => null]]; + yield 'client is a string' => [['test_client' => 'id']]; + yield 'no secret' => [['test_client' => ['client_id' => 'id']]]; + yield 'empty id' => [['test_client' => ['client_id' => '', 'client_secret' => 'secret']]]; + yield 'non-string secret' => [['test_client' => ['client_id' => 'id', 'client_secret' => 12]]]; + } +} diff --git a/tests/PHPUnit/Gateway/PproPaymentMethodsTest.php b/tests/PHPUnit/Gateway/PproPaymentMethodsTest.php new file mode 100644 index 00000000..9ddf030a --- /dev/null +++ b/tests/PHPUnit/Gateway/PproPaymentMethodsTest.php @@ -0,0 +1,51 @@ + + */ + public static function nonPproFactories(): iterable + { + yield 'card' => [PayPlugGatewayFactory::FACTORY_NAME]; + yield 'oney' => [OneyGatewayFactory::FACTORY_NAME]; + yield 'apple pay' => [ApplePayGatewayFactory::FACTORY_NAME]; + yield 'american express' => [AmericanExpressGatewayFactory::FACTORY_NAME]; + yield 'null' => [null]; + yield 'unknown' => ['payplug_satispay']; + } +} diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php deleted file mode 100644 index 3574934f..00000000 --- a/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php +++ /dev/null @@ -1,255 +0,0 @@ -apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - return new IsScalapayAmountRangeValidValidator($this->apiClientFactory, new AccountAmountRangeResolver(), $this->logger); - } - - public function testValidate_nonScalapayFactory_noViolationAndApiNeverCalled(): void - { - $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); - - $paymentMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME, []); - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - public function testValidate_noAmountsConfigured_noViolationAndApiNeverCalled(): void - { - $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); - - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []); - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - public function testValidate_minGreaterThanMax_raisesViolationWithoutCallingApi(): void - { - $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 5000, ScalapayGatewayFactory::MAX_AMOUNT => 1000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $constraint = new IsScalapayAmountRangeValid(); - $this->validator->validate($paymentMethod, $constraint); - - $this->buildViolation($constraint->minGreaterThanMaxMessage)->assertRaised(); - } - - public function testValidate_minBelowApiMin_raisesOutOfRangeViolation(): void - { - $apiClient = $this->mockApiClientWithAccount(500, 200000); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 100]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $constraint = new IsScalapayAmountRangeValid(); - $this->validator->validate($paymentMethod, $constraint); - - $this->buildViolation($constraint->outOfRangeMessage) - ->setParameter('%min_amount%', '5.00') - ->setParameter('%max_amount%', '2000.00') - ->assertRaised() - ; - } - - public function testValidate_maxAboveApiMax_raisesOutOfRangeViolation(): void - { - $apiClient = $this->mockApiClientWithAccount(500, 200000); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - $config = [ScalapayGatewayFactory::MAX_AMOUNT => 300000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $constraint = new IsScalapayAmountRangeValid(); - $this->validator->validate($paymentMethod, $constraint); - - $this->buildViolation($constraint->outOfRangeMessage) - ->setParameter('%min_amount%', '5.00') - ->setParameter('%max_amount%', '2000.00') - ->assertRaised() - ; - } - - public function testValidate_withinApiBounds_noViolation(): void - { - $apiClient = $this->mockApiClientWithAccount(500, 200000); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000, ScalapayGatewayFactory::MAX_AMOUNT => 100000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - public function testValidate_apiThrowsUnauthorizedException_noViolation(): void - { - $apiClient = $this->createMock(PayPlugApiClientInterface::class); - $apiClient->method('getAccount')->willThrowException(new UnauthorizedException('unauthorized')); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - // Failing open leaves the range unvalidated, so the skip must at least be traceable. - $this->logger->expects(self::once())->method('warning'); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - public function testValidate_apiThrowsConnectionException_noViolation(): void - { - $apiClient = $this->createMock(PayPlugApiClientInterface::class); - $apiClient->method('getAccount')->willThrowException(new ConnectionException('network blip')); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - $this->logger->expects(self::once())->method('warning'); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - /** - * The account authorizes no EUR range for Scalapay at all, so there is nothing to check the - * configured range against. Same fail-open outcome as an API error, and logged for the same - * reason. - */ - public function testValidate_accountHasNoEurRange_noViolationButLogged(): void - { - $apiClient = $this->createMock(PayPlugApiClientInterface::class); - $apiClient->method('getAccount')->willReturn([ - 'configuration' => ['min_amounts' => ['USD' => 500], 'max_amounts' => ['USD' => 200000]], - 'payment_methods' => [], - ]); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - $this->logger->expects(self::once())->method('warning'); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - /** - * The gateway config is a plain serialized array, so a direct DB edit or an import script can - * leave a non-int in it. PaymentMethodValidator::process() has no try/catch: a malformed value - * must degrade to "not configured" rather than 500 the admin save with an assertion error. - */ - public function testValidate_malformedConfiguredAmounts_noViolationButLogged(): void - { - $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); - $this->logger->expects(self::once())->method('warning'); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => '1000', ScalapayGatewayFactory::MAX_AMOUNT => 'nonsense']; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - public function testValidate_disabledMethod_noViolationAndApiNeverCalled(): void - { - $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); - - $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000, ScalapayGatewayFactory::MAX_AMOUNT => 100000]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config, false); - - $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); - - $this->assertNoViolation(); - } - - /** - * API range: min=500, max=200000 (cents). Merchant sets only max_amount=300, leaving - * min_amount blank. At checkout, the blank side falls back to the API bound (500), making - * the *effective* range inverted (500 > 300) even though neither configured value alone - * looks invalid against its own matching API bound. - */ - public function testValidate_onlyMaxConfiguredBelowEffectiveMin_raisesMinGreaterThanMaxViolation(): void - { - $apiClient = $this->mockApiClientWithAccount(500, 200000); - $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); - - $config = [ScalapayGatewayFactory::MAX_AMOUNT => 300]; - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); - - $constraint = new IsScalapayAmountRangeValid(); - $this->validator->validate($paymentMethod, $constraint); - - $this->buildViolation($constraint->minGreaterThanMaxMessage)->assertRaised(); - } - - private function mockApiClientWithAccount(int $minAmount, int $maxAmount): PayPlugApiClientInterface&MockObject - { - $apiClient = $this->createMock(PayPlugApiClientInterface::class); - $apiClient->method('getAccount')->willReturn([ - 'configuration' => [ - 'min_amounts' => ['EUR' => $minAmount], - 'max_amounts' => ['EUR' => $maxAmount], - ], - 'payment_methods' => [], - ]); - - return $apiClient; - } - - private function buildPaymentMethod( - string $factoryName, - array $config, - bool $enabled = true, - ): PaymentMethodInterface&MockObject - { - $gatewayConfig = $this->createMock(GatewayConfigInterface::class); - $gatewayConfig->method('getFactoryName')->willReturn($factoryName); - $gatewayConfig->method('getConfig')->willReturn($config); - - $paymentMethod = $this->createMock(PaymentMethodInterface::class); - $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); - $paymentMethod->method('isEnabled')->willReturn($enabled); - - return $paymentMethod; - } -} diff --git a/tests/PHPUnit/Ppro/PproConfigTest.php b/tests/PHPUnit/Ppro/PproConfigTest.php new file mode 100644 index 00000000..9a8b80ab --- /dev/null +++ b/tests/PHPUnit/Ppro/PproConfigTest.php @@ -0,0 +1,223 @@ + + */ + public static function malformedBlocks(): iterable + { + yield 'string block' => ['bancontact']; + yield 'non-numeric default min' => [['default_amount' => ['min' => 'abc', 'max' => 200000]]]; + yield 'negative configured max' => [['configured_amount' => ['min' => 50, 'max' => -1]]]; + yield 'default amount is a string' => [['default_amount' => '50']]; + yield 'allowed countries is a string' => [['allowed_countries' => 'FR']]; + yield 'allowed countries holds a non-string' => [['allowed_countries' => ['FR', 12]]]; + yield 'configured countries is an int' => [['configured_countries' => 1]]; + yield 'enabled is a string' => [['enabled' => 'yes']]; + yield 'name is an int' => [['name' => 3]]; + } + + public function testPartialBlockIsReadWithDefaults(): void + { + $config = PproConfig::fromArray(['configured_amount' => ['min' => 1000, 'max' => null]]); + + self::assertNotNull($config); + self::assertTrue($config->enabled); + self::assertNull($config->allowedCountries); + self::assertNull($config->defaultMin); + self::assertSame(1000, $config->configuredMin); + self::assertNull($config->configuredMax); + self::assertNull($config->configuredCountries); + } + + public function testHiddenBelowDefaultMin(): void + { + self::assertFalse($this->config()->isEligible(49, null)); + } + + public function testHiddenAboveDefaultMax(): void + { + self::assertFalse($this->config()->isEligible(200001, null)); + } + + public function testKeptOnBothBoundsInclusive(): void + { + self::assertTrue($this->config()->isEligible(50, null)); + self::assertTrue($this->config()->isEligible(200000, null)); + } + + public function testConfiguredRangeRaisesTheFloor(): void + { + $config = $this->config(['configured_amount' => ['min' => 5000, 'max' => 200000]]); + + self::assertFalse($config->isEligible(4000, null)); + self::assertTrue($config->isEligible(5000, null)); + } + + public function testConfiguredRangeLowersTheCeiling(): void + { + $config = $this->config(['configured_amount' => ['min' => 50, 'max' => 100000]]); + + self::assertFalse($config->isEligible(100001, null)); + self::assertTrue($config->isEligible(100000, null)); + } + + public function testConfiguredRangeOutsideDefaultIsClampedAtRead(): void + { + $config = $this->config(['configured_amount' => ['min' => 10, 'max' => 900000]]); + + self::assertFalse($config->isEligible(300000, null)); + self::assertFalse($config->isEligible(20, null)); + self::assertSame(50, $config->effectiveMin()); + self::assertSame(200000, $config->effectiveMax()); + } + + public function testEffectiveMinGreaterThanMaxIsNotEligible(): void + { + $config = $this->config(['configured_amount' => ['min' => 150000, 'max' => 100000]]); + + self::assertTrue($config->hasEmptyAmountRange()); + self::assertFalse($config->isEligible(120000, null)); + } + + public function testMissingDefaultSkipsApiBoundsAndUsesConfigured(): void + { + $config = $this->config([ + 'default_amount' => null, + 'configured_amount' => ['min' => 1000, 'max' => null], + ]); + + self::assertFalse($config->isEligible(999, null)); + self::assertTrue($config->isEligible(99999999, null)); + } + + public function testNoAmountAtAllIsNeverAmountFiltered(): void + { + $config = $this->config(['default_amount' => null, 'configured_amount' => null]); + + self::assertTrue($config->isEligible(0, null)); + self::assertTrue($config->isEligible(99999999, null)); + } + + public function testBillingCountryOutsideAllowedIsHidden(): void + { + $config = $this->config(['allowed_countries' => ['BE', 'DE']]); + + self::assertFalse($config->isEligible(1000, 'FR')); + self::assertTrue($config->isEligible(1000, 'BE')); + } + + public function testBillingCountryOutsideConfiguredSubsetIsHidden(): void + { + $config = $this->config(['allowed_countries' => ['BE', 'DE'], 'configured_countries' => ['BE']]); + + self::assertFalse($config->isEligible(1000, 'DE')); + self::assertTrue($config->isEligible(1000, 'BE')); + } + + public function testNullAllowedMeansNoApiRestriction(): void + { + self::assertTrue($this->config(['allowed_countries' => null])->isEligible(1000, 'US')); + } + + public function testNullConfiguredMeansAllEligible(): void + { + $config = $this->config(['allowed_countries' => ['BE', 'DE'], 'configured_countries' => null]); + + self::assertTrue($config->isEligible(1000, 'DE')); + self::assertTrue($config->isEligible(1000, 'BE')); + } + + public function testConfiguredSubsetAppliesWhenApiHasNoRestriction(): void + { + $config = $this->config(['allowed_countries' => null, 'configured_countries' => ['BE']]); + + self::assertFalse($config->isEligible(1000, 'FR')); + self::assertTrue($config->isEligible(1000, 'BE')); + } + + public function testNullBillingCountrySkipsTheCountryCheck(): void + { + $config = $this->config(['allowed_countries' => ['BE'], 'configured_countries' => ['BE']]); + + self::assertTrue($config->isEligible(1000, null)); + } + + public function testDisabledByApiIsNotEligible(): void + { + self::assertFalse($this->config(['enabled' => false])->isEligible(1000, 'BE')); + } + + public function testToArrayRoundTrips(): void + { + $stored = $this->stored([ + 'allowed_countries' => ['BE', 'DE'], + 'configured_countries' => ['BE'], + 'live' => true, + 'refreshed_at' => '2026-10-20T09:00:00+00:00', + ]); + + $config = PproConfig::fromArray($stored); + + self::assertNotNull($config); + self::assertSame($stored, $config->toArray()); + } + + public function testToArrayStoresNullDefaultAmountWhenUnknown(): void + { + $config = $this->config(['default_amount' => null]); + + self::assertNull($config->toArray()['default_amount']); + } + + /** + * @param array $overrides + */ + private function config(array $overrides = []): PproConfig + { + $config = PproConfig::fromArray($this->stored($overrides)); + self::assertNotNull($config); + + return $config; + } + + /** + * @param array $overrides + * + * @return array + */ + private function stored(array $overrides = []): array + { + return array_merge([ + 'name' => 'bancontact', + 'enabled' => true, + 'allowed_countries' => null, + 'default_amount' => ['min' => 50, 'max' => 200000], + 'configured_amount' => ['min' => 50, 'max' => 200000], + 'configured_countries' => null, + 'live' => null, + 'refreshed_at' => null, + ], $overrides); + } +} diff --git a/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php b/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php new file mode 100644 index 00000000..3195d02c --- /dev/null +++ b/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php @@ -0,0 +1,560 @@ +clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->client = $this->createMock(PayPlugApiClientInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->clientFactory->method('createForPaymentMethod')->willReturn($this->client); + + $this->refresher = new PproConfigurationRefresher( + $this->clientFactory, + new AccountAmountRangeResolver(), + new AccountAllowedCountriesResolver(), + $this->entityManager, + new MockClock(self::NOW), + $this->logger, + ); + } + + public function testCapturesBancontactEntry(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME, ['live' => true]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->refreshed); + self::assertFalse($result->adjusted); + self::assertSame([ + 'name' => 'bancontact', + 'enabled' => true, + 'allowed_countries' => null, + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => ['min' => null, 'max' => null], + 'configured_countries' => null, + 'live' => true, + 'refreshed_at' => self::NOW, + ], $this->storedBlock($paymentMethod)); + } + + public function testCapturesWeroCountries(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['BE', 'DE'], $this->storedBlock($paymentMethod)['allowed_countries']); + self::assertFalse($this->storedBlock($paymentMethod)['live']); + } + + public function testCapturesScalapayOwnBounds(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => 500, 'max' => 200000], $this->storedBlock($paymentMethod)['default_amount']); + } + + public function testIgnoresGiropayAndSofortKeys(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME); + + $this->refresher->refresh($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertArrayNotHasKey('giropay', $config); + self::assertArrayNotHasKey('sofort', $config); + self::assertSame('wero', $this->storedBlock($paymentMethod)['name']); + } + + public function testMissingAllowedCountriesKeyMeansNull(): void + { + $account = self::sampleAccount(); + unset($account['payment_methods']['wero']['allowed_countries']); + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME); + + $this->refresher->refresh($paymentMethod); + + self::assertNull($this->storedBlock($paymentMethod)['allowed_countries']); + } + + public function testEmptyAllowedCountriesMeansNull(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['allowed_countries'] = []; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME); + + $this->refresher->refresh($paymentMethod); + + self::assertNull($this->storedBlock($paymentMethod)['allowed_countries']); + } + + public function testDisabledByApiIsStored(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['enabled'] = false; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME); + + $this->refresher->refresh($paymentMethod); + + self::assertFalse($this->storedBlock($paymentMethod)['enabled']); + } + + public function testPayloadWithoutEurBoundsLeavesDefaultAmountNull(): void + { + $account = self::sampleAccount(); + $account['configuration']['min_amounts'] = ['USD' => 30]; + $account['configuration']['max_amounts'] = ['USD' => 2000000]; + $account['payment_methods']['wero']['min_amounts'] = ['USD' => 30]; + $account['payment_methods']['wero']['max_amounts'] = ['USD' => 2000000]; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->refreshed); + self::assertNull($this->storedBlock($paymentMethod)['default_amount']); + self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testCallsGetAccountWithRefreshTrue(): void + { + $this->client->expects(self::once())->method('getAccount')->with(true)->willReturn(self::sampleAccount()); + + $this->refresher->refresh($this->paymentMethod(WeroGatewayFactory::FACTORY_NAME)); + } + + public function testFlushesByDefault(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $this->entityManager->expects(self::once())->method('flush'); + + $this->refresher->refresh($this->paymentMethod(WeroGatewayFactory::FACTORY_NAME)); + } + + public function testDoesNotFlushWhenAskedNotTo(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $this->entityManager->expects(self::never())->method('flush'); + + $result = $this->refresher->refresh($this->paymentMethod(WeroGatewayFactory::FACTORY_NAME), false); + + self::assertTrue($result->refreshed); + } + + public function testKeepsConfiguredValuesInsideNewBounds(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 1000, 'max' => 50000], ['BE']), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertFalse($result->adjusted); + self::assertSame(['min' => 1000, 'max' => 50000], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertSame(['BE'], $this->storedBlock($paymentMethod)['configured_countries']); + } + + /** + * An empty side means "follow the PayPlug limit": it is never replaced by a snapshot of the + * current bound, so a limit PayPlug widens later applies without any merchant action. + */ + public function testKeepsAnEmptyConfiguredSideEmpty(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 1000, 'max' => null]), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertFalse($result->adjusted); + self::assertSame(['min' => 1000, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testEmptySidesFollowAWidenedApiLimit(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['max_amounts'] = ['EUR' => 5000000]; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => null, 'max' => null]), + ]); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => 30, 'max' => 5000000], $this->storedBlock($paymentMethod)['default_amount']); + self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + + /** + * Only the max is set and PayPlug raises its min above it: the effective range is empty, so the + * merchant range is reset to follow PayPlug. + */ + public function testResetsAOneSidedRangeLeftEmptyByANewApiMin(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['min_amounts'] = ['EUR' => 60000]; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => null, 'max' => 50000]), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->adjusted); + self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testClampsConfiguredAmountToNewDefaultBounds(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['min_amounts'] = ['EUR' => 2000]; + $account['payment_methods']['wero']['max_amounts'] = ['EUR' => 40000]; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 1000, 'max' => 50000]), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->adjusted); + self::assertSame(['min' => 2000, 'max' => 40000], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testResetsConfiguredAmountWhenClampedRangeIsEmpty(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['min_amounts'] = ['EUR' => 60000]; + $account['payment_methods']['wero']['max_amounts'] = ['EUR' => 90000]; + $this->client->method('getAccount')->willReturn($account); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 1000, 'max' => 50000]), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->adjusted); + self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testIntersectsConfiguredCountriesWithNewAllowed(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 30, 'max' => 2000000], ['FR', 'BE']), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->adjusted); + self::assertSame(['BE'], $this->storedBlock($paymentMethod)['configured_countries']); + } + + public function testResetsConfiguredCountriesToNullWhenIntersectionIsEmpty(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 30, 'max' => 2000000], ['FR', 'IT']), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertTrue($result->adjusted); + self::assertNull($this->storedBlock($paymentMethod)['configured_countries']); + } + + public function testKeepsConfiguredCountriesWhenApiHasNoRestriction(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME, [ + 'payment_method' => self::previousBlock(['min' => 30, 'max' => 2000000], ['FR', 'IT']), + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertFalse($result->adjusted); + self::assertSame(['FR', 'IT'], $this->storedBlock($paymentMethod)['configured_countries']); + } + + public function testNeverOverwritesConfiguredValuesOtherwise(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $previous = self::previousBlock(['min' => 30, 'max' => 2000000], ['DE']); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, ['payment_method' => $previous]); + + $this->refresher->refresh($paymentMethod); + $this->refresher->refresh($paymentMethod); + + self::assertSame($previous['configured_amount'], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertSame(['DE'], $this->storedBlock($paymentMethod)['configured_countries']); + } + + public function testScalapayOldMinMaxKeysAreIgnoredOnFirstCapture(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 5000, + 'max_amount' => 90000, + ]); + + $this->refresher->refresh($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertSame(5000, $config['min_amount']); + self::assertSame(90000, $config['max_amount']); + } + + public function testUnrelatedConfigKeysSurvive(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $initial = [ + 'live' => true, + 'live_client' => ['client_id' => 'live_id'], + 'test_client' => ['client_id' => 'test_id'], + 'account_email' => 'merchant@example.com', + ]; + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, $initial); + + $this->refresher->refresh($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + foreach ($initial as $key => $value) { + self::assertSame($value, $config[$key]); + } + } + + public function testGatewayConfigurationExceptionFromFactory(): void + { + $clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $clientFactory->method('createForPaymentMethod')->willThrowException(new GatewayConfigurationException('no client')); + $refresher = new PproConfigurationRefresher( + $clientFactory, + new AccountAmountRangeResolver(), + new AccountAllowedCountriesResolver(), + $this->entityManager, + new MockClock(self::NOW), + $this->logger, + ); + + $this->assertFailureLeavesConfigUntouched($refresher); + } + + public function testSdkExceptionFromGetAccount(): void + { + $this->client->method('getAccount')->willThrowException(new ConnectionException('timeout')); + + $this->assertFailureLeavesConfigUntouched($this->refresher); + } + + public function testPayloadWithoutTheMethodEntry(): void + { + $account = self::sampleAccount(); + unset($account['payment_methods']['wero']); + $this->client->method('getAccount')->willReturn($account); + + $this->assertFailureLeavesConfigUntouched($this->refresher); + } + + /** + * @dataProvider malformedAccounts + */ + public function testMalformedPayload(array $account): void + { + $this->client->method('getAccount')->willReturn($account); + + $this->assertFailureLeavesConfigUntouched($this->refresher); + } + + /** + * @return iterable}> + */ + public static function malformedAccounts(): iterable + { + $account = self::sampleAccount(); + + yield 'empty payload' => [[]]; + yield 'payment_methods is a string' => [['payment_methods' => 'wero']]; + + $countries = $account; + $countries['payment_methods']['wero']['allowed_countries'] = 'BE'; + yield 'allowed_countries is a string' => [$countries]; + + $amounts = $account; + $amounts['payment_methods']['wero']['min_amounts'] = ['EUR' => '30']; + yield 'min amount is a string' => [$amounts]; + + $enabled = $account; + $enabled['payment_methods']['wero']['enabled'] = 'yes'; + yield 'enabled is a string' => [$enabled]; + } + + public function testFlushFailureIsReportedAsAFailure(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $this->entityManager->method('flush')->willThrowException(new \RuntimeException('db down')); + $this->logger->expects(self::once())->method('warning'); + + $result = $this->refresher->refresh($this->paymentMethod(WeroGatewayFactory::FACTORY_NAME)); + + self::assertFalse($result->refreshed); + } + + public function testNonPproFactoryReturnsFalseWithoutCallingTheApi(): void + { + $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); + $this->logger->expects(self::never())->method('warning'); + + $result = $this->refresher->refresh($this->paymentMethod(OneyGatewayFactory::FACTORY_NAME)); + + self::assertFalse($result->refreshed); + self::assertFalse($result->adjusted); + } + + public function testPaymentMethodWithoutGatewayConfigReturnsFalse(): void + { + $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); + + $result = $this->refresher->refresh(new PaymentMethod()); + + self::assertFalse($result->refreshed); + } + + private function assertFailureLeavesConfigUntouched(PproConfigurationRefresher $refresher): void + { + $initial = ['live' => true, 'payment_method' => self::previousBlock(['min' => 1000, 'max' => 50000], ['BE'])]; + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, $initial); + + $this->logger->expects(self::once())->method('warning'); + $this->entityManager->expects(self::never())->method('flush'); + + $result = $refresher->refresh($paymentMethod); + + self::assertFalse($result->refreshed); + self::assertSame($initial, $paymentMethod->getGatewayConfig()?->getConfig()); + } + + /** + * @param array{min: int|null, max: int|null} $configuredAmount + * @param list|null $configuredCountries + * + * @return array + */ + private static function previousBlock(array $configuredAmount, ?array $configuredCountries = null): array + { + return [ + 'name' => 'wero', + 'enabled' => true, + 'allowed_countries' => ['BE', 'DE'], + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => $configuredAmount, + 'configured_countries' => $configuredCountries, + 'live' => true, + 'refreshed_at' => '2026-10-01T09:00:00+00:00', + ]; + } + + /** + * @return array + */ + private function storedBlock(PaymentMethod $paymentMethod): array + { + $block = ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])['payment_method'] ?? null; + self::assertIsArray($block); + + return $block; + } + + /** + * @param array $config + */ + private function paymentMethod(string $factoryName, array $config = []): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setGatewayName($factoryName); + $gatewayConfig->setConfig($config); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setCode($factoryName); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } + + /** + * Redacted `/account` payload captured on a live PPRO account. + * + * @return array + */ + private static function sampleAccount(): array + { + return [ + 'object' => 'account', + 'is_live' => true, + 'configuration' => [ + 'currencies' => ['EUR'], + 'min_amounts' => ['EUR' => 30], + 'max_amounts' => ['EUR' => 2000000], + ], + 'payment_methods' => [ + 'apple_pay' => ['enabled' => true, 'allowed_domain_names' => ['shop.example.com']], + 'american_express' => ['enabled' => true], + 'bancontact' => ['enabled' => true, 'allowed_countries' => ['ALL'], 'min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], + 'giropay' => ['enabled' => true, 'allowed_countries' => ['DE'], 'min_amounts' => ['EUR' => 100], 'max_amounts' => ['EUR' => 1000000]], + 'sofort' => ['enabled' => true, 'allowed_countries' => ['AT', 'BE', 'DE', 'ES', 'NL'], 'min_amounts' => ['EUR' => 100], 'max_amounts' => ['EUR' => 500000]], + 'wero' => ['enabled' => true, 'allowed_countries' => ['BE', 'DE'], 'min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], + 'scalapay' => ['enabled' => true, 'allowed_countries' => ['AT', 'BE', 'DE', 'ES', 'FI', 'FR', 'IT', 'NL', 'PT'], 'min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'merchant_token' => 'redacted'], + ], + ]; + } +} diff --git a/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php b/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php new file mode 100644 index 00000000..4f7f3a35 --- /dev/null +++ b/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php @@ -0,0 +1,142 @@ +createMock(TranslatorInterface::class); + $translator->method('getLocale')->willReturn('en'); + $translator->method('trans')->willReturnCallback( + static fn (string $id, array $parameters = [], ?string $domain = null): string => implode('|', [$domain, $id, ...array_values($parameters)]), + ); + + $this->validator = new PproConfigurationValidator($translator, new MoneyFormatter()); + } + + public function testWithinApiBoundsHasNoViolation(): void + { + self::assertSame([], $this->validator->violations($this->stored(), 1000, 100000, ['FR'])); + } + + public function testMinBelowApiMinReportsBothBounds(): void + { + self::assertSame( + ['ppro_min_amount' => ['validators|payplug_sylius_payplug_plugin.ppro.min_below_api_min|€5.00|€2,000.00']], + $this->validator->violations($this->stored(), 100, null, null), + ); + } + + public function testMaxAboveApiMaxReportsBothBounds(): void + { + self::assertSame( + ['ppro_max_amount' => ['validators|payplug_sylius_payplug_plugin.ppro.max_above_api_max|€5.00|€2,000.00']], + $this->validator->violations($this->stored(), null, 300000, null), + ); + } + + public function testMinGreaterThanMax(): void + { + self::assertSame( + ['ppro_min_amount' => ['validators|payplug_sylius_payplug_plugin.ppro.min_greater_than_max']], + $this->validator->violations($this->stored(), 5000, 1000, null), + ); + } + + /** + * Only the max is set, below the API min the empty min falls back to: the effective range is + * inverted although neither value alone is out of its own bound. + */ + public function testOnlyMaxConfiguredBelowEffectiveMinIsMinGreaterThanMax(): void + { + $violations = $this->validator->violations($this->stored(), null, 300, null); + + self::assertSame(['validators|payplug_sylius_payplug_plugin.ppro.min_greater_than_max'], $violations['ppro_min_amount'] ?? null); + } + + public function testEmptyAmountsFallBackToDefaultsWithoutError(): void + { + self::assertSame([], $this->validator->violations($this->stored(), null, null, null)); + } + + public function testNoCountrySelectedIsRefused(): void + { + self::assertSame( + ['ppro_countries' => ['validators|payplug_sylius_payplug_plugin.ppro.no_country']], + $this->validator->violations($this->stored(), null, null, []), + ); + } + + public function testNullCountriesMeansAllAndIsAccepted(): void + { + self::assertSame([], $this->validator->violations($this->stored(['allowed_countries' => ['BE', 'DE']]), null, null, null)); + } + + public function testCountryOutsideAllowedListsTheCodes(): void + { + self::assertSame( + ['ppro_countries' => ['validators|payplug_sylius_payplug_plugin.ppro.country_not_allowed|FR, IT']], + $this->validator->violations($this->stored(['allowed_countries' => ['BE', 'DE']]), null, null, ['BE', 'FR', 'IT']), + ); + } + + public function testAnyCountryIsAcceptedWhenPayPlugSetsNoRestriction(): void + { + self::assertSame([], $this->validator->violations($this->stored(['allowed_countries' => null]), null, null, ['US'])); + } + + public function testUnknownStoredBlockSkipsBoundsButKeepsMinMaxAndCountryRules(): void + { + self::assertSame([], $this->validator->violations(null, 1, 999999999, ['US'])); + self::assertSame( + [ + 'ppro_min_amount' => ['validators|payplug_sylius_payplug_plugin.ppro.min_greater_than_max'], + 'ppro_countries' => ['validators|payplug_sylius_payplug_plugin.ppro.no_country'], + ], + $this->validator->violations(null, 5000, 1000, []), + ); + } + + public function testUnknownDefaultAmountSkipsBounds(): void + { + self::assertSame([], $this->validator->violations($this->stored(['default_amount' => null]), 1, 999999999, null)); + } + + public function testEveryRuleIsReportedTogether(): void + { + $violations = $this->validator->violations($this->stored(['allowed_countries' => ['BE']]), 100, 300000, []); + + self::assertCount(1, $violations['ppro_min_amount']); + self::assertCount(1, $violations['ppro_max_amount']); + self::assertCount(1, $violations['ppro_countries']); + } + + /** + * @param array $overrides + */ + private function stored(array $overrides = []): PproConfig + { + $config = PproConfig::fromArray(array_merge([ + 'name' => 'scalapay', + 'enabled' => true, + 'allowed_countries' => null, + 'default_amount' => ['min' => 500, 'max' => 200000], + 'configured_amount' => ['min' => 500, 'max' => 200000], + 'configured_countries' => null, + ], $overrides)); + self::assertNotNull($config); + + return $config; + } +} diff --git a/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php b/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php new file mode 100644 index 00000000..9af82b53 --- /dev/null +++ b/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php @@ -0,0 +1,110 @@ +logger = $this->createMock(LoggerInterface::class); + $this->checker = new PproEligibilityChecker($this->logger); + } + + public function testNoStoredBlockIsFailOpenForEur(): void + { + $this->logger->expects(self::never())->method('warning'); + + self::assertTrue($this->checker->isEligible($this->paymentMethod([]), 1, 'EUR', 'US')); + self::assertTrue($this->checker->isEligible($this->paymentMethod([]), 99999999, 'EUR', null)); + } + + public function testPaymentMethodWithoutGatewayConfigIsFailOpen(): void + { + self::assertTrue($this->checker->isEligible(new PaymentMethod(), 1000, 'EUR', 'FR')); + } + + public function testMalformedBlockIsFailOpenAndLogged(): void + { + $this->logger->expects(self::once())->method('warning'); + + $paymentMethod = $this->paymentMethod(['payment_method' => ['default_amount' => ['min' => 'abc']]]); + + self::assertTrue($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'FR')); + } + + public function testNonEurCurrencyIsHidden(): void + { + self::assertFalse($this->checker->isEligible($this->paymentMethod([]), 1000, 'USD', null)); + self::assertFalse($this->checker->isEligible($this->paymentMethod(['payment_method' => $this->block()]), 1000, 'USD', 'BE')); + } + + public function testDelegatesToPproConfig(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => $this->block()]); + + self::assertTrue($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); + self::assertFalse($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'FR')); + self::assertFalse($this->checker->isEligible($paymentMethod, 29, 'EUR', 'BE')); + self::assertFalse($this->checker->isEligible($paymentMethod, 2000001, 'EUR', 'BE')); + } + + public function testEmptyEffectiveRangeIsHiddenAndLogged(): void + { + $this->logger->expects(self::once())->method('warning'); + + $paymentMethod = $this->paymentMethod(['payment_method' => $this->block([ + 'default_amount' => ['min' => 30, 'max' => 1000], + 'configured_amount' => ['min' => 5000, 'max' => 9000], + ])]); + + self::assertFalse($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); + } + + /** + * @param array $overrides + * + * @return array + */ + private function block(array $overrides = []): array + { + return array_merge([ + 'name' => 'wero', + 'enabled' => true, + 'allowed_countries' => ['BE', 'DE'], + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => ['min' => 30, 'max' => 2000000], + 'configured_countries' => null, + ], $overrides); + } + + /** + * @param array $config + */ + private function paymentMethod(array $config): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(WeroGatewayFactory::FACTORY_NAME); + $gatewayConfig->setGatewayName(WeroGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig($config); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setCode('wero'); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Ppro/PproRefusalClassifierTest.php b/tests/PHPUnit/Ppro/PproRefusalClassifierTest.php new file mode 100644 index 00000000..9cf42a28 --- /dev/null +++ b/tests/PHPUnit/Ppro/PproRefusalClassifierTest.php @@ -0,0 +1,73 @@ +classifier = new PproRefusalClassifier(); + } + + public function testCountryRefusalIsClassified(): void + { + $body = '{"object":"error","message":"The parameters you sent are invalid.","details":{"billing":{"country":"This payment method is not available for this country."}}}'; + + self::assertSame(PproRefusalClassifier::COUNTRY, $this->classifier->classify($body)); + } + + public function testAmountRefusalIsClassified(): void + { + $body = '{"object":"error","message":"The parameters you sent are invalid.","details":{"amount":"The amount must be between 1.00 EUR and 2000.00 EUR."}}'; + + self::assertSame(PproRefusalClassifier::AMOUNT, $this->classifier->classify($body)); + } + + public function testPostcodeRefusalIsNotClassified(): void + { + $body = '{"object":"error","message":"The parameters you sent are invalid.","details":{"billing":{"postcode":"Invalid postcode."}}}'; + + self::assertNull($this->classifier->classify($body)); + } + + public function testShippingCountryRefusalIsNotClassified(): void + { + self::assertNull($this->classifier->classify('{"details":{"shipping":{"country":"Invalid country."}}}')); + } + + /** + * @dataProvider unclassifiableBodies + */ + public function testEmptyOrNonJsonBodyIsNotClassified(?string $body): void + { + self::assertNull($this->classifier->classify($body)); + } + + /** + * @return iterable + */ + public static function unclassifiableBodies(): iterable + { + yield 'null' => [null]; + yield 'empty' => ['']; + yield 'html' => ['502 Bad Gateway']; + yield 'truncated json' => ['{"details":{"amount"']; + yield 'json scalar' => ['"amount"']; + yield 'details is a string' => ['{"details":"amount"}']; + yield 'billing is a string' => ['{"details":{"billing":"country"}}']; + yield 'message only' => ['{"message":"The amount is too low."}']; + } +} diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php index bec3d0e7..ec9efc8c 100644 --- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -4,15 +4,21 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Provider; +use Payplug\Exception\ConnectionException; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; +use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproEligibilityChecker; use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider; +use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAllowedCountriesResolver; use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; @@ -26,6 +32,8 @@ final class SupportedMethodsProviderTest extends TestCase private PayPlugApiClientInterface&MockObject $apiClient; + private LoggerInterface&MockObject $logger; + private SupportedMethodsProvider $provider; /** @var \SplObjectStorage */ @@ -46,7 +54,15 @@ protected function setUp(): void : $this->apiClient, ); - $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory, new AccountAmountRangeResolver(), new NullLogger()); + $this->logger = $this->createMock(LoggerInterface::class); + $this->provider = new SupportedMethodsProvider( + $this->currencyContext, + $this->clientFactory, + new AccountAmountRangeResolver(), + $this->logger, + new PproEligibilityChecker(new NullLogger()), + new AccountAllowedCountriesResolver(), + ); } // ------------------------------------------------------------------------- @@ -310,83 +326,126 @@ public function testProvide_withMixedList_filtersCorrectly(): void } // ------------------------------------------------------------------------- - // provide() — allowed_countries filter + // provide() — PPRO methods read their stored configuration, never the API // ------------------------------------------------------------------------- - /** - * Billing country is in the allowed_countries list → method kept. - */ - public function testProvide_withAllowedCountry_keepsMethod(): void + public function testPproMethodReadsStoredConfigAndNeverCallsTheApi(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $account = [ - 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], - 'payment_methods' => ['scalapay' => ['min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'allowed_countries' => ['FR', 'DE']]], - ]; - $this->apiClient->method('getAccount')->willReturn($account); + $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); - $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'FR'); + $method = $this->buildPaymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(['allowed_countries' => ['BE', 'DE']]), + ]); - self::assertCount(1, $result); + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'FR')); + self::assertCount(1, $this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'BE')); } - /** - * Billing country is NOT in the allowed_countries list → method removed. - */ - public function testProvide_withDisallowedCountry_removesMethod(): void + public function testPproMethodWithoutStoredConfigIsKept(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $account = [ - 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], - 'payment_methods' => ['scalapay' => ['min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'allowed_countries' => ['FR', 'DE']]], - ]; - $this->apiClient->method('getAccount')->willReturn($account); + $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); - $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'US'); + $method = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME, []); - self::assertEmpty($result); + self::assertCount(1, $this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 1, 'EUR', 'US')); } - /** - * allowed_countries = ["ALL"] → country check skipped, method kept. - */ - public function testProvide_withAllowedCountriesAll_keepsMethod(): void + public function testPproMethodWithNonEurPaymentIsRemoved(): void + { + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(), + ]); + + self::assertEmpty($this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000, 'USD')); + } + + public function testPproMethodFallsBackToTheDisplayCurrencyWhenThePaymentHasNone(): void { $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $account = [ - 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], - 'payment_methods' => ['bancontact' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000], 'allowed_countries' => ['ALL']]], - ]; - $this->apiClient->method('getAccount')->willReturn($account); + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(), + ]); - $method = $this->buildPaymentMethod('payplug_bancontact'); - $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, billingCountryCode: 'US'); + self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000)); + } - self::assertCount(1, $result); + public function testPproAllowedCountriesNullKeepsAnyBillingCountry(): void + { + $method = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(['allowed_countries' => null]), + ]); + + self::assertCount(1, $this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'US')); + } + + public function testPproNullBillingCountryKeepsMethod(): void + { + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(['allowed_countries' => ['FR', 'DE']]), + ]); + + self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000, 'EUR', null)); + } + + public function testPproMerchantCountrySubsetIsApplied(): void + { + $method = $this->buildPaymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(['allowed_countries' => ['BE', 'DE'], 'configured_countries' => ['BE']]), + ]); + + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'DE')); } /** - * Billing country is null (no billing address yet) → country check skipped, method kept. + * The merchant range is no longer a Scalapay-only override: every PPRO gateway honours it. */ - public function testProvide_withNullBillingCountry_keepsMethod(): void + public function testBancontactHonoursMerchantConfiguredAmount(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $account = [ - 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], - 'payment_methods' => ['scalapay' => ['min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'allowed_countries' => ['FR', 'DE']]], - ]; - $this->apiClient->method('getAccount')->willReturn($account); + $method = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock(['configured_amount' => ['min' => 1000, 'max' => 100000]]), + ]); - $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: null); + self::assertEmpty($this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 999, 'EUR')); + self::assertCount(1, $this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 1000, 'EUR')); + self::assertCount(1, $this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 100000, 'EUR')); + self::assertEmpty($this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 100001, 'EUR')); + } - self::assertCount(1, $result); + public function testPproApiBoundsApplyWithoutMerchantRange(): void + { + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'payment_method' => $this->storedBlock([ + 'default_amount' => ['min' => 500, 'max' => 200000], + 'configured_amount' => ['min' => null, 'max' => null], + ]), + ]); + + self::assertEmpty($this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 499, 'EUR')); + self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 500, 'EUR')); + } + + public function testScalapayLegacyMinMaxKeysAreIgnored(): void + { + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 90000, + 'max_amount' => 'nonsense', + 'payment_method' => $this->storedBlock(), + ]); + + self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000, 'EUR')); + } + + public function testPproMalformedStoredBlockKeepsMethod(): void + { + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'payment_method' => ['default_amount' => ['min' => '500']], + ]); + + self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1, 'EUR')); } // ------------------------------------------------------------------------- - // provide() — payment method specific amounts used over configuration defaults + // provide() — non-PPRO gateways keep the live gate, guarded // ------------------------------------------------------------------------- /** @@ -397,14 +456,13 @@ public function testProvide_usesPaymentMethodSpecificAmounts(): void { $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - // Scalapay has tighter limits (500–200000) vs configuration defaults (30–2000000) $account = [ 'configuration' => [ 'min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000], ], 'payment_methods' => [ - 'scalapay' => [ + 'apple_pay' => [ 'min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], ], @@ -412,21 +470,12 @@ public function testProvide_usesPaymentMethodSpecificAmounts(): void ]; $this->apiClient->method('getAccount')->willReturn($account); - $method = $this->buildPaymentMethod('payplug_scalapay'); - - // Amount 400 is above the configuration default min (30) but below Scalapay's min (500) - $result = $this->provider->provide([$method], 'payplug_scalapay', 400); - self::assertEmpty($result); + $method = $this->buildPaymentMethod('payplug_apple_pay'); - // Amount 500 is exactly at Scalapay's min — should be kept - $result2 = $this->provider->provide([$method], 'payplug_scalapay', 500); - self::assertCount(1, $result2); + self::assertEmpty($this->provider->provide([$method], 'payplug_apple_pay', 400)); + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 500)); } - // ------------------------------------------------------------------------- - // provide() — fallback to configuration when payment method has no amounts - // ------------------------------------------------------------------------- - /** * When the payment method entry in the API has no min/max amounts (e.g. Apple Pay), * the configuration defaults are used. @@ -443,7 +492,6 @@ public function testProvide_fallsBackToConfigurationAmounts(): void 'payment_methods' => [ 'apple_pay' => [ 'enabled' => true, - // no min_amounts / max_amounts ], ], ]; @@ -451,135 +499,155 @@ public function testProvide_fallsBackToConfigurationAmounts(): void $method = $this->buildPaymentMethod('payplug_apple_pay'); - // 30 is at configuration min — should be kept - $result = $this->provider->provide([$method], 'payplug_apple_pay', 30); - self::assertCount(1, $result); - - // 29 is below configuration min — should be removed - $result2 = $this->provider->provide([$method], 'payplug_apple_pay', 29); - self::assertEmpty($result2); + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 30)); + self::assertEmpty($this->provider->provide([$method], 'payplug_apple_pay', 29)); } - // ------------------------------------------------------------------------- - // provide() — merchant-configured min/max override the API bounds - // ------------------------------------------------------------------------- - /** - * The gateway config sets a min_amount (1000) tighter than the API min (99). - * Verifies amounts below the merchant's min are removed, and the merchant's own min boundary is kept. + * The min_amount/max_amount keys are not read for any gateway: the card gateway carrying them + * stays on the raw API bounds. */ - public function testProvide_withMerchantConfiguredMinAmount_overridesApiMin(): void + public function testProvide_merchantConfiguredAmountsIgnoredForNonPproGateway(): void { $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); - $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => 1000]); - - $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 500); - self::assertEmpty($result); + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, ['min_amount' => 1000, 'max_amount' => 300]); - $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000); - self::assertCount(1, $result2); + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 150000); + self::assertCount(1, $result); } - /** - * The gateway config sets a max_amount (100000) tighter than the API max (2000000). - * Verifies amounts above the merchant's max are removed, and the merchant's own max boundary is kept. - */ - public function testProvide_withMerchantConfiguredMaxAmount_overridesApiMax(): void + public function testNonPproGatewayIsKeptWhenGetAccountThrows(): void { $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + $this->apiClient->method('getAccount')->willThrowException(new ConnectionException('timeout')); + $this->logger->expects(self::once())->method('warning'); - $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['max_amount' => 100000]); + $method = $this->buildPaymentMethod('payplug_american_express'); - $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000); - self::assertEmpty($result); + self::assertCount(1, $this->provider->provide([$method], 'payplug_american_express', 1000)); + } + + public function testNonPproGatewayIsKeptWhenFactoryThrowsGatewayConfigurationException(): void + { + $clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $clientFactory->method('createForPaymentMethod')->willThrowException(new GatewayConfigurationException('no client configured')); + $this->logger->expects(self::once())->method('warning'); + $provider = new SupportedMethodsProvider( + $this->currencyContext, + $clientFactory, + new AccountAmountRangeResolver(), + $this->logger, + new PproEligibilityChecker($this->logger), + new AccountAllowedCountriesResolver(), + ); - $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 100000); - self::assertCount(1, $result2); + $method = $this->buildPaymentMethod('payplug_apple_pay'); + + self::assertCount(1, $provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR')); } - /** - * No min_amount/max_amount set in the gateway config (merchant left the fields blank). - * Verifies the API bounds alone still apply, unchanged from today's behavior. - */ - public function testProvide_withoutMerchantConfiguredAmounts_fallsBackToApiBoundsOnly(): void + public function testNonPproGatewayIsKeptWhenThePayloadIsMalformed(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + $this->apiClient->method('getAccount')->willReturn(['configuration' => ['min_amounts' => ['EUR' => '30']]]); + $this->logger->expects(self::once())->method('warning'); - $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []); + $method = $this->buildPaymentMethod('payplug_apple_pay'); - $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 50); - self::assertEmpty($result); + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR')); + } + + // ------------------------------------------------------------------------- + // provide() — non-PPRO gateways keep the API allowed_countries filter + // ------------------------------------------------------------------------- + + public function testNonPproBillingCountryInAllowedCountriesKeepsMethod(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('apple_pay', ['FR', 'DE'])); + + $method = $this->buildPaymentMethod('payplug_apple_pay'); - $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 99); - self::assertCount(1, $result2); + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR', 'FR')); } - /** - * The merchant's min_amount/max_amount override is entered as EUR (MoneyType field), but - * checkout is happening in USD. The EUR-denominated override must not be applied to a - * USD amount — only the API's own per-currency bounds apply. - */ - public function testProvide_merchantConfiguredAmountsIgnoredForNonEurCurrency(): void + public function testNonPproBillingCountryOutsideAllowedCountriesRemovesMethod(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('american_express', ['FR', 'DE'])); - $account = [ - 'configuration' => [ - 'min_amounts' => ['USD' => 100], - 'max_amounts' => ['USD' => 200000], - ], - 'payment_methods' => [], - ]; - $this->apiClient->method('getAccount')->willReturn($account); + $method = $this->buildPaymentMethod('payplug_american_express'); - // If wrongly applied to USD, this EUR-denominated max_amount would exclude the payment. - $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['max_amount' => 300]); + self::assertEmpty($this->provider->provide([$method], 'payplug_american_express', 1000, 'EUR', 'US')); + } - $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000); - self::assertCount(1, $result); + public function testNonPproAllowedCountriesAllKeepsAnyBillingCountry(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('apple_pay', ['ALL'])); + + $method = $this->buildPaymentMethod('payplug_apple_pay'); + + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR', 'US')); } - /** - * The min_amount/max_amount keys are Scalapay's own: only IsScalapayAmountRangeValidValidator - * keeps them inside the API-authorized range at save time, and it is wired for Scalapay only. - * Another gateway carrying the same keys must therefore be left on the raw API bounds rather - * than granted an unvalidated checkout override. - */ - public function testProvide_merchantConfiguredAmountsIgnoredForNonScalapayGateway(): void + public function testNonPproWithoutAllowedCountriesKeepsAnyBillingCountry(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); - // If wrongly honored, this max_amount would exclude the payment below. - $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, ['min_amount' => 1000, 'max_amount' => 300]); + $method = $this->buildPaymentMethod('payplug_apple_pay'); + + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR', 'US')); + } + + public function testNonPproNullBillingCountryKeepsMethod(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('apple_pay', ['FR'])); + + $method = $this->buildPaymentMethod('payplug_apple_pay'); + + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR', null)); + } + + public function testNonPproMalformedAllowedCountriesKeepsMethodAndLogs(): void + { + $account = $this->buildAccount(99, 2000000); + $account['payment_methods'] = ['apple_pay' => ['allowed_countries' => 'FR']]; + $this->apiClient->method('getAccount')->willReturn($account); + $this->logger->expects(self::once())->method('warning'); + + $method = $this->buildPaymentMethod('payplug_apple_pay'); + + self::assertCount(1, $this->provider->provide([$method], 'payplug_apple_pay', 1000, 'EUR', 'US')); + } + + public function testNonPproMethodsOnDifferentAccountsUseTheirOwnAllowedCountries(): void + { + $frMethod = $this->buildPaymentMethod('payplug_apple_pay', configId: 1); + $deMethod = $this->buildPaymentMethod('payplug_apple_pay', configId: 2); + + $this->assignAccount($frMethod, $this->buildAccountWithCountries('apple_pay', ['FR'])); + $this->assignAccount($deMethod, $this->buildAccountWithCountries('apple_pay', ['DE'])); + + $result = $this->provider->provide([$frMethod, $deMethod], 'payplug_apple_pay', 1000, 'EUR', 'FR'); - $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 150000); self::assertCount(1, $result); + self::assertSame($frMethod, reset($result)); } /** - * The gateway config is a plain serialized array, so a direct DB edit or an import script can - * leave a non-int in it. provide() runs on every checkout page with no surrounding try/catch: - * a malformed override must degrade to the API bounds, not throw and break payment-method - * resolution for the whole checkout. + * A failed read is memoized like a successful one: the API is not hammered once per method. */ - public function testProvide_withMalformedMerchantConfiguredAmounts_fallsBackToApiBounds(): void + public function testFailedAccountReadIsNotRetriedForTheSameGatewayConfig(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); - - $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => '1000', 'max_amount' => 'nonsense']); + $gatewayConfig = $this->buildGatewayConfig(PayPlugGatewayFactory::FACTORY_NAME, [], 1); + $first = $this->buildPaymentMethodFor($gatewayConfig); + $second = $this->buildPaymentMethodFor($gatewayConfig); - // API bounds are 99–2000000: an in-range amount is kept, an out-of-range one still removed. - $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000); - self::assertCount(1, $result); + $client = $this->createMock(PayPlugApiClientInterface::class); + $client->expects(self::once())->method('getAccount')->willThrowException(new ConnectionException('timeout')); + $this->clientsByPaymentMethod[$first] = $client; + $this->clientsByPaymentMethod[$second] = $client; - $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 50); - self::assertEmpty($result2); + self::assertCount(2, $this->provider->provide([$first, $second], PayPlugGatewayFactory::FACTORY_NAME, 1000, 'EUR')); } // ------------------------------------------------------------------------- @@ -612,20 +680,15 @@ public function testProvide_withMethodsOnDifferentAccounts_filtersEachAgainstIts } /** - * Same split, for the billing-country gate: each method is checked against its own account's - * allowed_countries. + * Same split, for the billing-country gate: each PPRO method is checked against its own stored + * allowed countries. */ public function testProvide_withMethodsOnDifferentAccounts_filtersEachAgainstItsOwnAllowedCountries(): void { - $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); - - $frMethod = $this->buildPaymentMethod('payplug_scalapay', configId: 1); - $deMethod = $this->buildPaymentMethod('payplug_scalapay', configId: 2); + $frMethod = $this->buildPaymentMethod('payplug_scalapay', ['payment_method' => $this->storedBlock(['allowed_countries' => ['FR']])], 1); + $deMethod = $this->buildPaymentMethod('payplug_scalapay', ['payment_method' => $this->storedBlock(['allowed_countries' => ['DE']])], 2); - $this->assignAccount($frMethod, $this->buildScalapayAccount(['FR'])); - $this->assignAccount($deMethod, $this->buildScalapayAccount(['DE'])); - - $result = $this->provider->provide([$frMethod, $deMethod], 'payplug_scalapay', 1000, billingCountryCode: 'FR'); + $result = $this->provider->provide([$frMethod, $deMethod], 'payplug_scalapay', 1000, 'EUR', 'FR'); self::assertCount(1, $result); self::assertSame($frMethod, reset($result)); @@ -670,19 +733,34 @@ private function buildAccount(int $minAmount, int $maxAmount): array /** * @param list $allowedCountries + * + * @return array */ - private function buildScalapayAccount(array $allowedCountries): array + private function buildAccountWithCountries(string $paymentMethodKey, array $allowedCountries): array { return [ 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], - 'payment_methods' => ['scalapay' => [ - 'min_amounts' => ['EUR' => 500], - 'max_amounts' => ['EUR' => 200000], - 'allowed_countries' => $allowedCountries, - ]], + 'payment_methods' => [$paymentMethodKey => ['enabled' => true, 'allowed_countries' => $allowedCountries]], ]; } + /** + * @param array $overrides + * + * @return array + */ + private function storedBlock(array $overrides = []): array + { + return array_merge([ + 'name' => 'wero', + 'enabled' => true, + 'allowed_countries' => null, + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => ['min' => 30, 'max' => 2000000], + 'configured_countries' => null, + ], $overrides); + } + /** * @param array $config Persisted gateway config; defaults to empty, which is * neither integrated_payment nor hosted_fields. @@ -691,8 +769,7 @@ private function buildPaymentMethod( string $factoryName, array $config = [], int|string|null $configId = null, - ): PaymentMethodInterface - { + ): PaymentMethodInterface { return $this->buildPaymentMethodFor($this->buildGatewayConfig($factoryName, $config, $configId)); } @@ -703,8 +780,7 @@ private function buildGatewayConfig( string $factoryName, array $config = [], int|string|null $configId = null, - ): GatewayConfigInterface - { + ): GatewayConfigInterface { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getFactoryName')->willReturn($factoryName); $gatewayConfig->method('getConfig')->willReturn($config); diff --git a/tests/PHPUnit/Resolver/AccountAllowedCountriesResolverTest.php b/tests/PHPUnit/Resolver/AccountAllowedCountriesResolverTest.php new file mode 100644 index 00000000..e0ddefe3 --- /dev/null +++ b/tests/PHPUnit/Resolver/AccountAllowedCountriesResolverTest.php @@ -0,0 +1,76 @@ +resolver = new AccountAllowedCountriesResolver(); + } + + public function testReturnsTheAllowedCountriesOfThePaymentMethod(): void + { + self::assertSame(['BE', 'DE'], $this->resolver->resolve($this->account(['BE', 'DE']), 'wero')); + } + + /** + * @dataProvider unrestrictedAccounts + */ + public function testReturnsNullWhenThereIsNoRestriction(array $account, ?string $paymentMethodKey): void + { + self::assertNull($this->resolver->resolve($account, $paymentMethodKey)); + } + + /** + * @return iterable, 1: string|null}> + */ + public static function unrestrictedAccounts(): iterable + { + yield 'ALL' => [['payment_methods' => ['wero' => ['allowed_countries' => ['ALL']]]], 'wero']; + yield 'ALL among codes' => [['payment_methods' => ['wero' => ['allowed_countries' => ['FR', 'ALL']]]], 'wero']; + yield 'key missing' => [['payment_methods' => ['wero' => ['enabled' => true]]], 'wero']; + yield 'empty list' => [['payment_methods' => ['wero' => ['allowed_countries' => []]]], 'wero']; + yield 'entry missing' => [['payment_methods' => []], 'wero']; + yield 'payment_methods missing' => [[], 'wero']; + yield 'card gateway' => [['payment_methods' => ['wero' => ['allowed_countries' => ['BE']]]], null]; + } + + /** + * @dataProvider malformedAccounts + */ + public function testThrowsOnAMalformedPayload(array $account): void + { + $this->expectException(\UnexpectedValueException::class); + + $this->resolver->resolve($account, 'wero'); + } + + /** + * @return iterable}> + */ + public static function malformedAccounts(): iterable + { + yield 'payment_methods is a string' => [['payment_methods' => 'wero']]; + yield 'entry is a string' => [['payment_methods' => ['wero' => 'BE']]]; + yield 'allowed_countries is a string' => [['payment_methods' => ['wero' => ['allowed_countries' => 'BE']]]]; + yield 'non-string code' => [['payment_methods' => ['wero' => ['allowed_countries' => ['BE', 12]]]]]; + } + + /** + * @param list $allowedCountries + * + * @return array + */ + private function account(array $allowedCountries): array + { + return ['payment_methods' => ['wero' => ['enabled' => true, 'allowed_countries' => $allowedCountries]]]; + } +} diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index 5a97dd24..bca830fe 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -11,8 +11,8 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; -use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; +use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -292,25 +292,26 @@ public function testProcess_payplugFactory_hostedFieldsTrueOneClickTrue_validate } // ------------------------------------------------------------------------- - // process() — Scalapay factory → base constraint + amount range constraint + // process() — PPRO factories → base constraints only // ------------------------------------------------------------------------- /** - * Scalapay gateway config. Verifies both IsCanSavePaymentMethod and - * IsScalapayAmountRangeValid are passed to the validator (2 total). + * The PPRO amount and country settings are refused by the form before the save, so no PPRO + * gateway runs an amount constraint after it. + * + * @dataProvider pproFactories */ - public function testProcess_scalapayFactory_validatesWithBaseAndAmountRangeConstraints(): void + public function testProcess_pproFactory_validatesWithTheDefaultConstraints(string $factoryName): void { - $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []); + $paymentMethod = $this->buildPaymentMethod($factoryName, []); $this->validator ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - self::assertCount(3, $constraints); - self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[2]); + self::assertCount(2, $constraints); self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); - self::assertInstanceOf(IsScalapayAmountRangeValid::class, $constraints[1]); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[1]); return new ConstraintViolationList(); }) @@ -324,6 +325,16 @@ public function testProcess_scalapayFactory_validatesWithBaseAndAmountRangeConst $this->paymentMethodValidator->process($paymentMethod); } + /** + * @return iterable + */ + public static function pproFactories(): iterable + { + yield 'bancontact' => [BancontactGatewayFactory::FACTORY_NAME]; + yield 'scalapay' => [ScalapayGatewayFactory::FACTORY_NAME]; + yield 'wero' => [WeroGatewayFactory::FACTORY_NAME]; + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index b05df556..9e3b8624 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -1,5 +1,6 @@ payplug_sylius_payplug_plugin: error: + ppro_not_available: "This payment method is not available for your billing country or for this amount. Please choose another payment method." oney_not_enabled: Oney is not (or is no longer) activated on your account. api_unknow_error: An error occurred. Please retry in few seconds. transaction_failed_1click: The transaction was not completed and your card was not charged. @@ -36,3 +37,6 @@ payplug_sylius_payplug_plugin: payment_not_found: This payment could not be found at PayPlug. Check that the payment method is connected to the right PayPlug account. api_error: The operation could not be performed because of a PayPlug error. The payment was not changed; try again in a moment. unexpected_error: The operation may have been performed by PayPlug, but it could not be recorded here. Check the payment in your PayPlug portal before trying again, to avoid capturing or cancelling twice. + ppro: + refresh_failed: "PayPlug could not be reached: the amount and country limits shown are the last ones saved." + settings_adjusted: "PayPlug changed the limits of this payment method: your amount range or billing countries were adjusted to fit them. Check them below." diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index fc9fa375..2467f348 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -1,5 +1,6 @@ payplug_sylius_payplug_plugin: error: + ppro_not_available: "Ce moyen de paiement n'est pas disponible pour votre pays de facturation ou pour ce montant. Veuillez choisir un autre moyen de paiement." oney_not_enabled: Oney n'est pas ou plus activé sur votre compte. api_unknow_error: Une erreur s'est produite. Veuillez réessayer dans quelques secondes. transaction_failed_1click: La transaction a échoué, votre carte de paiement ne sera pas débitée. @@ -36,3 +37,6 @@ payplug_sylius_payplug_plugin: payment_not_found: Ce paiement est introuvable chez PayPlug. Vérifiez que le moyen de paiement est connecté au bon compte PayPlug. api_error: L'opération n'a pas pu être effectuée à cause d'une erreur PayPlug. Le paiement n'a pas été modifié ; réessayez dans quelques instants. unexpected_error: L'opération a peut-être été effectuée par PayPlug, mais elle n'a pas pu être enregistrée ici. Vérifiez le paiement dans votre portail PayPlug avant de réessayer, pour ne pas capturer ou annuler deux fois. + ppro: + refresh_failed: "PayPlug n'a pas pu être joint : les limites de montant et de pays affichées sont les dernières enregistrées." + settings_adjusted: "PayPlug a modifié les limites de ce moyen de paiement : votre plage de montants ou vos pays de facturation ont été ajustés pour les respecter. Vérifiez-les ci-dessous." diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index 764f2bbe..cce54556 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -1,5 +1,6 @@ payplug_sylius_payplug_plugin: error: + ppro_not_available: "Questo metodo di pagamento non è disponibile per il tuo paese di fatturazione o per questo importo. Scegli un altro metodo di pagamento." oney_not_enabled: Oney non è o non è più attivato per il tuo account. api_unknow_error: C'è stato un errore. Per favore riprova tra qualche secondo. transaction_failed_1click: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta. @@ -36,3 +37,6 @@ payplug_sylius_payplug_plugin: payment_not_found: Questo pagamento non è stato trovato presso PayPlug. Verifica che il metodo di pagamento sia collegato all'account PayPlug corretto. api_error: L'operazione non è stata eseguita a causa di un errore PayPlug. Il pagamento non è stato modificato; riprova tra qualche istante. unexpected_error: L'operazione potrebbe essere stata eseguita da PayPlug, ma non è stato possibile registrarla qui. Verifica il pagamento nel tuo portale PayPlug prima di riprovare, per non catturare o annullare due volte. + ppro: + refresh_failed: "Impossibile contattare PayPlug: i limiti di importo e di paese mostrati sono gli ultimi salvati." + settings_adjusted: "PayPlug ha modificato i limiti di questo metodo di pagamento: l'intervallo di importi o i paesi di fatturazione sono stati adattati per rispettarli. Verificali qui sotto." diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 21142be6..24411435 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -114,10 +114,17 @@ payplug_sylius_payplug_plugin: title: 'The fees are:' client: Split between you and your customers merchant: For you - scalapay_gateway_config: - min_amount: Minimum amount - max_amount: Maximum amount - amount_help: Leave empty to use the limits authorized by PayPlug. + ppro: + amount_title: Cart amount + amount_help: The payment method is displayed only if the cart amount is between these two values. + min_amount: Minimum + max_amount: Maximum + api_limits: "Limits authorized by PayPlug: from %min% to %max%. Leave a field empty to use the PayPlug limit." + countries_title: Billing countries + countries_help: The payment method is displayed only for customers whose billing address is in one of the selected countries. + countries_label: Countries + not_connected: The amount and country limits are loaded from PayPlug once this payment method is connected to your PayPlug account. + disabled_by_payplug: PayPlug reports this payment method as not enabled on your account, so it is hidden at checkout. Contact PayPlug to enable it. integrated_payment: card_holder.title: 'Cardholder name' card_holder.error: 'Invalid Name and/or Last Name.' @@ -183,3 +190,5 @@ payplug_sylius_payplug_plugin: deferred_capture_enable: Enable deferred capture deferred_capture_help: | Please ensure that a delayed capture trigger has been added to the project source code. + error: + ppro_not_available: "This payment method is not available for your billing country or for this amount. Please choose another payment method." diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index a756f418..cdd3993c 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -132,10 +132,17 @@ payplug_sylius_payplug_plugin: title: 'Les frais sont :' client: Répartis entre vous et vos clients merchant: À votre charge - scalapay_gateway_config: - min_amount: Montant minimum - max_amount: Montant maximum - amount_help: Laissez vide pour utiliser les limites autorisées par PayPlug. + ppro: + amount_title: Montant du panier + amount_help: Le moyen de paiement s'affiche uniquement si le montant du panier est compris entre ces deux valeurs. + min_amount: Minimum + max_amount: Maximum + api_limits: "Limites autorisées par PayPlug : de %min% à %max%. Laissez un champ vide pour utiliser la limite PayPlug." + countries_title: Pays de facturation + countries_help: Le moyen de paiement s'affiche uniquement pour les clients dont l'adresse de facturation est dans un des pays sélectionnés. + countries_label: Pays + not_connected: Les limites de montant et de pays sont chargées depuis PayPlug une fois ce moyen de paiement connecté à votre compte PayPlug. + disabled_by_payplug: PayPlug indique que ce moyen de paiement n'est pas activé sur votre compte, il est donc masqué au checkout. Contactez PayPlug pour l'activer. integrated_payment: card_holder.title: 'Nom du titulaire de la carte' card_holder.error: 'Nom et/ou prénom invalide(s).' @@ -203,3 +210,5 @@ payplug_sylius_payplug_plugin: deferred_capture_enable: Activer la capture différée deferred_capture_help: | Attention, assurez vous qu'un déclencheur de la capture différée a bien été ajouté dans le code source du projet + error: + ppro_not_available: "Ce moyen de paiement n'est pas disponible pour votre pays de facturation ou pour ce montant. Veuillez choisir un autre moyen de paiement." diff --git a/translations/messages.it.yml b/translations/messages.it.yml index b50eae48..ac83e288 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -114,10 +114,17 @@ payplug_sylius_payplug_plugin: title: 'Le spese sono:' client: Ripartite tra te e i tuoi clienti merchant: A tuo carico - scalapay_gateway_config: - min_amount: Importo minimo - max_amount: Importo massimo - amount_help: Lascia vuoto per usare i limiti autorizzati da PayPlug. + ppro: + amount_title: Importo del carrello + amount_help: Il metodo di pagamento viene mostrato solo se l'importo del carrello è compreso tra questi due valori. + min_amount: Minimo + max_amount: Massimo + api_limits: "Limiti autorizzati da PayPlug: da %min% a %max%. Lascia un campo vuoto per usare il limite PayPlug." + countries_title: Paesi di fatturazione + countries_help: Il metodo di pagamento viene mostrato solo ai clienti il cui indirizzo di fatturazione si trova in uno dei paesi selezionati. + countries_label: Paesi + not_connected: I limiti di importo e di paese vengono caricati da PayPlug una volta collegato questo metodo di pagamento al tuo account PayPlug. + disabled_by_payplug: PayPlug indica che questo metodo di pagamento non è attivo sul tuo account, quindi è nascosto al checkout. Contatta PayPlug per attivarlo. integrated_payment: card_holder.title: 'Titolare della carta' card_holder.error: 'Nome e/o Cognome non valido(i).' @@ -182,3 +189,5 @@ payplug_sylius_payplug_plugin: deferred_capture_enable: Abilita la cattura differita deferred_capture_help: | Assicurarsi che nel codice sorgente del progetto sia stato aggiunto un trigger di acquisizione ritardata + error: + ppro_not_available: "Questo metodo di pagamento non è disponibile per il tuo paese di fatturazione o per questo importo. Scegli un altro metodo di pagamento." diff --git a/translations/validators.en.yml b/translations/validators.en.yml index cf4141c2..705bb1b7 100644 --- a/translations/validators.en.yml +++ b/translations/validators.en.yml @@ -33,8 +33,6 @@ payplug_sylius_payplug_plugin: You don't have access to this feature yet. To activate Scalapay, please contact us at support@payplug.com and activate the LIVE mode. - min_amount_greater_than_max: The minimum amount must be lower than or equal to the maximum amount. - amount_out_of_authorized_range: The amount limits must be between %min_amount% and %max_amount%. payplug_wero: can_not_save_method_with_test_key: | The Wero payment method is not available for the TEST mode. @@ -64,4 +62,11 @@ payplug_sylius_payplug_plugin: payplug: can_not_save_method_no_access: | You do not have access to LIVE mode. - To enable it, please complete your onboarding and reconnect. \ No newline at end of file + To enable it, please complete your onboarding and reconnect. + ppro: + min_below_api_min: "The minimum amount cannot be lower than %min%, the minimum authorized by PayPlug for this payment method (authorized range: %min% to %max%)." + max_above_api_max: "The maximum amount cannot be higher than %max%, the maximum authorized by PayPlug for this payment method (authorized range: %min% to %max%)." + min_greater_than_max: The minimum amount must be lower than or equal to the maximum amount. + invalid_amount: "Enter an amount in euros, for example 12.50." + no_country: Select at least one billing country. + country_not_allowed: "PayPlug does not authorize this payment method for these countries: %countries%." diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml index 5f4baffc..7dfd1045 100644 --- a/translations/validators.fr.yml +++ b/translations/validators.fr.yml @@ -32,8 +32,6 @@ payplug_sylius_payplug_plugin: Vous n'avez pas accès à cette fonctionnalité. Pour activer Scalapay, contactez-nous à support@payplug.com et activez le mode LIVE. - min_amount_greater_than_max: Le montant minimum doit être inférieur ou égal au montant maximum. - amount_out_of_authorized_range: Les limites de montant doivent être comprises entre %min_amount% et %max_amount%. payplug_wero: can_not_save_method_with_test_key: | Le paiement par Wero n'est pas disponible en mode TEST. @@ -63,4 +61,11 @@ payplug_sylius_payplug_plugin: payplug: can_not_save_method_no_access: | Vous n'avez pas accès au mode LIVE. - Pour l'activer, veuillez terminer votre onboarding et vous reconnecter. \ No newline at end of file + Pour l'activer, veuillez terminer votre onboarding et vous reconnecter. + ppro: + min_below_api_min: "Le montant minimum ne peut pas être inférieur à %min%, le minimum autorisé par PayPlug pour ce moyen de paiement (plage autorisée : de %min% à %max%)." + max_above_api_max: "Le montant maximum ne peut pas être supérieur à %max%, le maximum autorisé par PayPlug pour ce moyen de paiement (plage autorisée : de %min% à %max%)." + min_greater_than_max: Le montant minimum doit être inférieur ou égal au montant maximum. + invalid_amount: "Saisissez un montant en euros, par exemple 12,50." + no_country: Sélectionnez au moins un pays de facturation. + country_not_allowed: "PayPlug n'autorise pas ce moyen de paiement pour ces pays : %countries%." diff --git a/translations/validators.it.yml b/translations/validators.it.yml index 7ebaea99..1d5f7461 100644 --- a/translations/validators.it.yml +++ b/translations/validators.it.yml @@ -32,8 +32,6 @@ payplug_sylius_payplug_plugin: Non puoi ancora accedere a questa funzionalità. Per attivare Scalapay, contattaci a support@payplug.com e attiva la modalità LIVE. - min_amount_greater_than_max: L'importo minimo deve essere inferiore o uguale all'importo massimo. - amount_out_of_authorized_range: I limiti di importo devono essere compresi tra %min_amount% e %max_amount%. payplug_wero: can_not_save_method_with_test_key: | Il metodo di pagamento Wero non è disponibile in modalità TEST. @@ -63,4 +61,11 @@ payplug_sylius_payplug_plugin: payplug: can_not_save_method_no_access: | Non hai accesso alla modalità LIVE. - Per attivarla, completa il tuo onboarding e riconnettiti. \ No newline at end of file + Per attivarla, completa il tuo onboarding e riconnettiti. + ppro: + min_below_api_min: "L'importo minimo non può essere inferiore a %min%, il minimo autorizzato da PayPlug per questo metodo di pagamento (intervallo autorizzato: da %min% a %max%)." + max_above_api_max: "L'importo massimo non può essere superiore a %max%, il massimo autorizzato da PayPlug per questo metodo di pagamento (intervallo autorizzato: da %min% a %max%)." + min_greater_than_max: L'importo minimo deve essere inferiore o uguale all'importo massimo. + invalid_amount: "Inserisci un importo in euro, ad esempio 12,50." + no_country: Seleziona almeno un paese di fatturazione. + country_not_allowed: "PayPlug non autorizza questo metodo di pagamento per questi paesi: %countries%." From 6b81a97cf4376baf79b6d0aa1020e23928b6e5bb Mon Sep 17 00:00:00 2001 From: hdelaforce-payplug Date: Fri, 23 Oct 2026 17:41:02 +0200 Subject: [PATCH 2/3] PRE-3677: address PPRO eligibility review feedback --- .github/workflows/ci.yml | 4 + CHANGELOG.md | 14 +- UPGRADE.md | 62 +++++-- assets/package.json | 2 +- doc/ppro_display_eligibility.md | 21 ++- .../Auth/UnifiedAuthenticationController.php | 20 ++- src/Auth/GatewayConnectionRevoker.php | 15 ++ .../Handler/CapturePaymentRequestHandler.php | 21 ++- ...proConfigRefreshOnAdminDisplayListener.php | 9 +- src/Gateway/ScalapayGatewayFactory.php | 4 +- src/Ppro/PproConfig.php | 21 ++- src/Ppro/PproConfigurationRefresher.php | 82 +++++++-- .../PproConfigurationRefresherInterface.php | 12 +- src/Ppro/PproConfigurationValidator.php | 8 + src/Ppro/PproEligibilityChecker.php | 32 ++-- src/Ppro/PproRefreshResult.php | 9 + src/Provider/SupportedMethodsProvider.php | 19 +- .../UnifiedAuthenticationControllerTest.php | 41 +++++ .../Auth/GatewayConnectionRevokerTest.php | 38 ++++ .../CapturePaymentRequestHandlerTest.php | 63 ++++++- ...onfigRefreshOnAdminDisplayListenerTest.php | 39 ++-- tests/PHPUnit/Ppro/PproConfigTest.php | 20 +++ .../Ppro/PproConfigurationRefresherTest.php | 170 +++++++++++++++++- .../Ppro/PproConfigurationValidatorTest.php | 32 ++++ .../Ppro/PproEligibilityCheckerTest.php | 67 +++++-- .../Provider/SupportedMethodsProviderTest.php | 47 ++++- translations/flashes.en.yml | 2 +- translations/flashes.fr.yml | 2 +- translations/flashes.it.yml | 2 +- 29 files changed, 750 insertions(+), 128 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3874d07..26490f44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20.x' + - + name: 'JS - Run the admin controller tests' + run: 'npm test' + working-directory: assets - name: 'Composer - Get Cache Directory' id: composer-cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8a31f2..ee33d35a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,15 +45,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ended - The PayPlug amount and country limits of Bancontact, Wero and Scalapay are stored per payment method and refreshed when the admin page is opened, after an OAuth connection, after a save that - switches the LIVE mode and after a payment PayPlug refuses for its country or amount: the checkout - no longer calls the PayPlug API for these methods, and a method without stored limits stays visible + switches the LIVE mode and after a payment PayPlug refuses for its country or amount (the admin page + and a refusal reuse limits refreshed less than 5 minutes ago): once they are stored, the checkout no + longer calls the PayPlug API for these methods, which are filtered live from the account until then +- A PPRO method PayPlug reports as not enabled is hidden for 24 hours after the last refresh, then + shown again for PayPlug to decide at payment time +- A logout from PayPlug clears the stored PayPlug limits of a PPRO method (the merchant range and + countries are kept), and a warning is shown when they cannot be captured after a connection - A Bancontact, Wero or Scalapay payment refused by PayPlug for its billing country or amount sends the customer back to the order page with a message to choose another payment method - A PPRO amount outside the PayPlug limits, or an empty country selection, is refused when saving the payment method instead of being saved with the payment method disabled -- **The Scalapay minimum/maximum amount override is not carried over**: the merchant range is empty - after the upgrade, so it follows the PayPlug limits, and must be entered again (see - [UPGRADE.md](UPGRADE.md)) +- The Scalapay minimum/maximum amount override becomes the merchant range of the new PPRO settings + when the PayPlug limits are first stored, adjusted to them if needed (see [UPGRADE.md](UPGRADE.md)) ### Removed - Drop Payum support diff --git a/UPGRADE.md b/UPGRADE.md index 95da5432..c619ced7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -34,20 +34,20 @@ to the command handlers in `src/Command/Handler/` before then. Bancontact, Wero and Scalapay are now shown at checkout only for the cart amounts and billing countries PayPlug accepts for them, narrowed by the merchant on the payment method page. The -PayPlug limits are stored in each payment method's gateway config (`payment_method` key) instead of -being read from the PayPlug API on every checkout page. +PayPlug limits are stored in each payment method's gateway config (`payment_method` key); once +stored, the checkout no longer reads the PayPlug API for these methods. What changes for a merchant already in production, in this order: -1. **The Scalapay minimum/maximum amount override is not carried over.** The `min_amount` and - `max_amount` keys of a Scalapay gateway config are no longer read. After the upgrade the merchant - range is empty, so it follows the PayPlug limits; a merchant who had narrowed the Scalapay range - must enter it again on the payment method page. -2. **Until the PayPlug limits are first captured, these methods are not filtered** by amount or - billing country (PayPlug still refuses what it does not accept at payment time). The limits are - captured when the payment method page is opened in the admin, after an OAuth (re)connection, after - a save that switches the LIVE mode, or after a payment refused by PayPlug for its country or - amount. To capture them right after the upgrade, run once: +1. **The Scalapay minimum/maximum amount override is carried over** when the PayPlug limits are + first captured: the former `min_amount` and `max_amount` keys of a Scalapay gateway config become + the merchant range, adjusted to the PayPlug limits if they no longer fit (the payment method page + then shows a notice). They are not read again afterwards. +2. **Until the PayPlug limits are first captured, these methods are filtered live from the PayPlug + account**, by amount and billing country, as before the upgrade (the merchant settings apply once + the limits are stored). The limits are captured when the payment method page is opened in the + admin, after an OAuth (re)connection, after a save that switches the LIVE mode, or after a payment + refused by PayPlug for its country or amount. To capture them right after the upgrade, run once: ```bash bin/console payplug:ppro:refresh @@ -60,13 +60,20 @@ What changes for a merchant already in production, in this order: means "the PayPlug limits" (shown as the placeholder of the minimum and maximum fields). At least one country must stay selected, and an amount outside the PayPlug limits is refused when saving (the payment method is no longer saved and disabled instead). 4. **Fallback.** Without stored limits, or with stored limits that cannot be read, the payment method - is shown and PayPlug arbitrates at payment time: this feature never blocks the checkout. When + is filtered live from the PayPlug account as before; if the account cannot be read either, it is + shown and PayPlug arbitrates at payment time: this feature never blocks the checkout. When PayPlug refuses a payment for its billing country or amount, the limits are refreshed and the customer is sent back to the order page to choose another payment method; the payment request response data carries `redirect_url` and `error` for headless frontends. -5. The stored limits move only on the events listed in point 2, not on a schedule. An empty minimum - or maximum, like a selection of every country, follows PayPlug's changes. A value the merchant - entered is kept, and only adjusted when PayPlug narrows its limits below it. +5. The stored limits move only on the events listed in point 2, not on a schedule. The admin page and + a refused payment reuse limits refreshed less than 5 minutes ago instead of calling PayPlug again. + An empty minimum or maximum, like a selection of every country, follows PayPlug's changes. A value + the merchant entered is kept, and only adjusted when PayPlug narrows its limits below it. A method + PayPlug reports as not enabled is hidden for 24 hours after the last refresh; past that, it is + shown again and PayPlug decides at payment time. +6. A logout from PayPlug clears the stored PayPlug limits (the merchant range and countries are + kept), so a reconnection to another PayPlug account never filters with the previous account's + limits. If the limits cannot be captured right after a connection, a warning is shown. For anyone extending the plugin: @@ -74,16 +81,33 @@ For anyone extending the plugin: | --- | --- | | `Gateway\Form\Extension\ScalapayGatewayConfigurationTypeExtension` | `Gateway\Form\Extension\PproEligibilityTypeExtension`, on the Bancontact, Wero and Scalapay configuration types | | `Gateway\Validator\Constraints\IsScalapayAmountRangeValid` and its validator | `Ppro\PproConfigurationValidator`, run by `PaymentMethodTypeExtension` before the save | -| `ScalapayGatewayFactory::MIN_AMOUNT`, `ScalapayGatewayFactory::MAX_AMOUNT` | **deprecated**, no longer read; removal planned with the next major | +| `ScalapayGatewayFactory::MIN_AMOUNT`, `ScalapayGatewayFactory::MAX_AMOUNT` | **deprecated**, read once to carry the range over; removal planned with the next major | | Twig hook `amount_range` and `admin/payment_method/form/scalapay_amount_range.html.twig` | hook `ppro_eligibility`, `admin/payment_method/form/ppro_eligibility.html.twig` | | Translation keys `ui.scalapay_gateway_config.*` (messages), `payplug_scalapay.min_amount_greater_than_max` and `payplug_scalapay.amount_out_of_authorized_range` (validators) | `ui.ppro.*` (messages), `ppro.*` (validators) | -| `SupportedMethodsProvider::__construct()` | now also takes a `PproEligibilityChecker` | +| `SupportedMethodsProvider::__construct()` | now also takes a `PproEligibilityChecker` and an `AccountAllowedCountriesResolver` | +| `PproConfigurationRefresherInterface` | `refreshIfStale()` added next to `refresh()` | | `CapturePaymentRequestHandler::__construct()` | now also takes the PPRO refresher, the refusal classifier, `sylius_shop.provider.order_pay.final_url` and the request stack | | `UnifiedAuthenticationController::__construct()`, `PostSavePaymentMethodEventListener::__construct()` | now also take a `PproConfigurationRefresherInterface` | The admin form uses a new Stimulus controller, `@payplug/sylius-payplug-plugin/ppro-eligibility` -(declared in `assets/package.json` and `assets/controllers.json`): rebuild the admin assets after the -upgrade. +(declared in `assets/package.json` and `assets/controllers.json`). If your application lists the +plugin's controllers in its own admin `assets/admin/controllers.json`, add it there, otherwise the +live validation of the form never runs (the save is still validated on the server): + +```json +{ + "controllers": { + "@payplug/sylius-payplug-plugin": { + "ppro-eligibility": { + "enabled": true, + "fetch": "lazy" + } + } + } +} +``` + +Then rebuild the admin assets. # Upgrading from 1.0.0 diff --git a/assets/package.json b/assets/package.json index 7783818e..4ba4ff36 100644 --- a/assets/package.json +++ b/assets/package.json @@ -11,7 +11,7 @@ "dist" ], "scripts": { - "test": "node --test 'admin/controllers/*.test.js'" + "test": "node --test admin/controllers/*.test.js" }, "symfony": { "controllers": { diff --git a/doc/ppro_display_eligibility.md b/doc/ppro_display_eligibility.md index ddd42990..4c0e30c5 100644 --- a/doc/ppro_display_eligibility.md +++ b/doc/ppro_display_eligibility.md @@ -32,15 +32,18 @@ shown while typing, and the save is refused with the same message. The PayPlug limits are stored with the payment method. They are refreshed: -- when the payment method page is opened in the admin; +- when the payment method page is opened in the admin, unless they were refreshed less than 5 minutes + ago; - after connecting (or reconnecting) the payment method to PayPlug; - after a save that switches the LIVE mode on or off; -- when PayPlug refuses a payment for its billing country or its amount; +- when PayPlug refuses a payment for its billing country or its amount (same 5-minute rule); - with the console command `bin/console payplug:ppro:refresh`, which refreshes every Bancontact, Wero and Scalapay payment method connected to PayPlug (run it once after upgrading the plugin); payment methods not connected yet are reported as skipped. -The checkout itself never calls PayPlug to decide whether to show these payment methods. +Once the limits are stored, the checkout no longer calls PayPlug to decide whether to show these +payment methods. Before that (right after the upgrade, or after a logout from PayPlug), they are +filtered live from the PayPlug account, as before the upgrade. An empty minimum or maximum, like a selection of every country, follows PayPlug's changes. A value the merchant entered is kept; when PayPlug narrows its limits below it, it is adjusted at the next @@ -48,13 +51,20 @@ refresh and a notice is shown on the payment method page. If PayPlug reports the payment method as not enabled on the account, it is hidden at checkout and a warning is shown on the payment method page. The payment method itself stays enabled in Sylius. +PayPlug may enable it later: 24 hours after the last refresh, the method is shown again and PayPlug +decides at payment time. + +A Scalapay range set before the upgrade becomes the merchant range when the limits are first stored. + +Logging out from PayPlug clears the stored PayPlug limits but keeps the merchant range and countries. ## When PayPlug cannot be reached - On the payment method page: the last stored limits are shown, with a warning. - At checkout: nothing changes, the stored limits are used. - If no limits have ever been stored (payment method never refreshed since the upgrade), Bancontact, - Wero and Scalapay are shown for every amount and country, and PayPlug accepts or refuses the payment. + Wero and Scalapay are filtered live from the PayPlug account; if it cannot be read either, they are + shown for every amount and country, and PayPlug accepts or refuses the payment. The checkout is never blocked by this feature. @@ -66,7 +76,8 @@ for this amount. Please choose another payment method." The stored limits are re time, so the payment method is no longer offered where PayPlug does not accept it. For a headless frontend, the payment request response data carries `redirect_url` (the order page) -and `error` (the translation key `payplug_sylius_payplug_plugin.error.ppro_not_available`). +and `error` (the translation key `payplug_sylius_payplug_plugin.error.ppro_not_available`). No flash +message is added for a JSON or XHR request. ## Adding a PPRO payment method diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index 94fef380..1458d137 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -8,7 +8,9 @@ use Payplug\Authentication; use Payplug\Payplug; use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor; +use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfigurationRefresherInterface; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproRefreshResult; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Contracts\IOAuthHttpClient; @@ -18,6 +20,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -141,10 +144,11 @@ public function oauthCallback(Request $request): Response $this->entityManager->flush(); // After the credentials are persisted, so a PayPlug outage here cannot lose them. A no-op // for non-PPRO gateways; never throws. PaymentMethodValidator::process() flushes it. - $this->pproConfigurationRefresher->refresh($paymentMethod, false); + $pproRefresh = $this->pproConfigurationRefresher->refresh($paymentMethod, false); $this->cleanSession($request); $request->getSession()->getFlashBag()->add('success', 'payplug_sylius_payplug_plugin.admin.oauth_callback_success'); + $this->warnOnFailedPproRefresh($request, $gatewayConfig->getFactoryName(), $pproRefresh); // Ensure that the payment method is well configured $this->paymentMethodValidator->process($paymentMethod); @@ -156,6 +160,20 @@ public function oauthCallback(Request $request): Response } } + /** + * Without fresh limits, the checkout keeps filtering with whatever was stored before: the merchant + * is told, so they can be refreshed from the payment method page. + */ + private function warnOnFailedPproRefresh(Request $request, ?string $factoryName, PproRefreshResult $result): void + { + $session = $request->getSession(); + if ($result->refreshed || !PproPaymentMethods::isPpro($factoryName) || !$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $session->getFlashBag()->add('warning', 'payplug_sylius_payplug_plugin.ppro.refresh_failed'); + } + private function handleOAuthError(Request $request): RedirectResponse { $request->getSession()->getFlashBag()->add('error', 'payplug_sylius_payplug_plugin.admin.oauth_setup_error'); diff --git a/src/Auth/GatewayConnectionRevoker.php b/src/Auth/GatewayConnectionRevoker.php index 3d1fc8bd..9e3c67dc 100644 --- a/src/Auth/GatewayConnectionRevoker.php +++ b/src/Auth/GatewayConnectionRevoker.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Ppro\PproConfig; use PayplugUnifiedCore\Contracts\ITokenCache; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -42,6 +43,13 @@ final class GatewayConnectionRevoker /** Everything the OAuth callback writes, all of it account-bound. */ private const CONNECTION_KEYS = ['live_client', 'test_client', 'account_email']; + /** + * The PayPlug limits stored in a PPRO `payment_method` block, fetched from the account being + * disconnected. The merchant's own narrowing (`configured_amount`, `configured_countries`) is a + * setting, not account data, and is kept. + */ + private const PPRO_ACCOUNT_KEYS = ['name', 'enabled', 'allowed_countries', 'default_amount', 'live', 'refreshed_at']; + public function __construct( private EntityManagerInterface $entityManager, private ITokenCache $tokenCache, @@ -71,6 +79,13 @@ public function revoke(PaymentMethodInterface $paymentMethod): void unset($config[$key]); } + // Left in place, the old account's limits would keep filtering the checkout after a + // reconnection to another account whose first refresh fails. + $pproBlock = $config[PproConfig::CONFIG_KEY] ?? null; + if (\is_array($pproBlock)) { + $config[PproConfig::CONFIG_KEY] = array_diff_key($pproBlock, array_flip(self::PPRO_ACCOUNT_KEYS)); + } + // The Hosted Fields account id identifies the account that was just disconnected, so it is // stale the moment the merchant reconnects to a different one. Cleared only where it is // actually in use — on any other gateway, or on a CB gateway in redirected/integrated mode, diff --git a/src/Command/Handler/CapturePaymentRequestHandler.php b/src/Command/Handler/CapturePaymentRequestHandler.php index 743d9aaf..f07d2c2a 100644 --- a/src/Command/Handler/CapturePaymentRequestHandler.php +++ b/src/Command/Handler/CapturePaymentRequestHandler.php @@ -120,9 +120,9 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void /** * A PPRO payment refused for its billing country or its amount means the stored PayPlug limits - * let the method be shown where PayPlug does not accept it: they are refreshed, and the customer - * is sent back to the order page to pick another payment method. No PayPlug payment exists after - * a refusal, so there is nothing to cancel. + * let the method be shown where PayPlug does not accept it: they are refreshed (unless they were + * a few minutes ago), and the customer is sent back to the order page to pick another payment + * method. No PayPlug payment exists after a refusal, so there is nothing to cancel. */ private function failPaymentRequest( PaymentRequestInterface $paymentRequest, @@ -141,12 +141,14 @@ private function failPaymentRequest( if (null === $refusal) { $this->logger->error('[PayPlug] Payment creation failed', ['factory_name' => $factoryName, 'response' => $responseBody]); } else { - $this->logger->info('[PayPlug] PPRO payment refused for its billing country or amount; limits refreshed.', [ + // Flushed by the doctrine_transaction middleware of the payment request command bus. + // Throttled, so repeated refusals do not each call PayPlug. + $refresh = $this->pproConfigurationRefresher->refreshIfStale($method, false); + $this->logger->info('[PayPlug] PPRO payment refused for its billing country or amount.', [ 'factory_name' => $factoryName, 'refusal' => $refusal, + 'limits_refreshed' => $refresh->refreshed && !$refresh->skipped, ]); - // Flushed by the doctrine_transaction middleware of the payment request command bus. - $this->pproConfigurationRefresher->refresh($method, false); $responseData['redirect_url'] = $this->finalUrlProvider->getUrl($payment, UrlGeneratorInterface::ABSOLUTE_URL); $responseData['error'] = self::PPRO_NOT_AVAILABLE_MESSAGE; $this->addErrorFlash(); @@ -160,10 +162,15 @@ private function failPaymentRequest( ); } + /** + * Only for the Twig checkout: a decoupled frontend reads `redirect_url` and `error` from the + * response data, and a flash would stay in its session until some unrelated page shows it. + */ private function addErrorFlash(): void { $request = $this->requestStack->getCurrentRequest(); - $session = null !== $request && $request->hasSession() ? $request->getSession() : null; + $isTwigCheckout = null !== $request && 'html' === $request->getRequestFormat() && !$request->isXmlHttpRequest(); + $session = $isTwigCheckout && $request->hasSession() ? $request->getSession() : null; if ($session instanceof FlashBagAwareSessionInterface) { $session->getFlashBag()->add('error', self::PPRO_NOT_AVAILABLE_MESSAGE); diff --git a/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php b/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php index 98c6bd88..115838bf 100644 --- a/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php +++ b/src/EventListener/PproConfigRefreshOnAdminDisplayListener.php @@ -17,9 +17,10 @@ /** * Refreshes the stored PayPlug limits of a PPRO payment method when its admin edit page is - * displayed. It runs before the controller so that the form built on this request already shows - * the refreshed limits; a submit (POST) is never refreshed, so it is validated against the limits - * that were displayed. A payment method not connected to PayPlug yet has no limits to fetch. + * displayed, unless they were refreshed a few minutes ago: reloading the page, or a link or a + * browser prefetch hitting it, neither calls PayPlug nor writes each time. It runs before the + * controller so that the form built on this request already shows the refreshed limits; a submit + * (POST) is never refreshed, so it is validated against the limits that were displayed. A payment method not connected to PayPlug yet has no limits to fetch. */ #[AsEventListener(event: KernelEvents::CONTROLLER)] final class PproConfigRefreshOnAdminDisplayListener @@ -47,7 +48,7 @@ public function __invoke(ControllerEvent $event): void return; } - $result = $this->pproConfigurationRefresher->refresh($paymentMethod, true); + $result = $this->pproConfigurationRefresher->refreshIfStale($paymentMethod); if (!$result->refreshed) { $this->addFlash($request, 'warning', 'payplug_sylius_payplug_plugin.ppro.refresh_failed'); diff --git a/src/Gateway/ScalapayGatewayFactory.php b/src/Gateway/ScalapayGatewayFactory.php index 28aaec04..a925ea07 100644 --- a/src/Gateway/ScalapayGatewayFactory.php +++ b/src/Gateway/ScalapayGatewayFactory.php @@ -12,9 +12,9 @@ final class ScalapayGatewayFactory extends AbstractGatewayFactory public const PAYMENT_METHOD_SCALAPAY = 'scalapay'; - /** @deprecated no longer read; the merchant range is stored under the `payment_method` config key */ + /** @deprecated the merchant range is stored under the `payment_method` config key; this key is only read once, to carry an existing range over */ public const MIN_AMOUNT = 'min_amount'; - /** @deprecated no longer read; the merchant range is stored under the `payment_method` config key */ + /** @deprecated the merchant range is stored under the `payment_method` config key; this key is only read once, to carry an existing range over */ public const MAX_AMOUNT = 'max_amount'; } diff --git a/src/Ppro/PproConfig.php b/src/Ppro/PproConfig.php index 60efa5f8..a57fe77d 100644 --- a/src/Ppro/PproConfig.php +++ b/src/Ppro/PproConfig.php @@ -92,9 +92,12 @@ public function adjustsSettingsOf(?self $previous): bool (null !== $previous->configuredCountries && $previous->configuredCountries !== $this->configuredCountries); } - public function isEligible(int $amount, ?string $billingCountry): bool + /** + * @param bool $trustDisabledFlag false when the stored `enabled` flag is too old to hide the method + */ + public function isEligible(int $amount, ?string $billingCountry, bool $trustDisabledFlag = true): bool { - if (!$this->enabled || $this->hasEmptyAmountRange()) { + if (($trustDisabledFlag && !$this->enabled) || $this->hasEmptyAmountRange()) { return false; } @@ -105,6 +108,20 @@ public function isEligible(int $amount, ?string $billingCountry): bool return null === $billingCountry || $this->isCountryEligible($billingCountry); } + /** + * False when the PayPlug limits were never captured or their timestamp cannot be read. + */ + public function isRefreshedSince(\DateTimeInterface $threshold): bool + { + if (null === $this->refreshedAt) { + return false; + } + + $refreshedAt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $this->refreshedAt); + + return false !== $refreshedAt && $refreshedAt >= $threshold; + } + public function effectiveMin(): ?int { if (null === $this->configuredMin || null === $this->defaultMin) { diff --git a/src/Ppro/PproConfigurationRefresher.php b/src/Ppro/PproConfigurationRefresher.php index 8184a5d7..1bca36c3 100644 --- a/src/Ppro/PproConfigurationRefresher.php +++ b/src/Ppro/PproConfigurationRefresher.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAllowedCountriesResolver; use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver; use Psr\Clock\ClockInterface; @@ -18,6 +19,14 @@ final class PproConfigurationRefresher implements PproConfigurationRefresherInte { private const CURRENCY = 'EUR'; + /** How recent stored limits must be for refreshIfStale() to keep them without asking PayPlug. */ + private const MIN_REFRESH_INTERVAL = '-5 minutes'; + + /** Where the Scalapay-only form stored the merchant range, in cents, before the `payment_method` block. */ + private const LEGACY_SCALAPAY_MIN_AMOUNT = 'min_amount'; + + private const LEGACY_SCALAPAY_MAX_AMOUNT = 'max_amount'; + public function __construct( private PayPlugApiClientFactoryInterface $apiClientFactory, private AccountAmountRangeResolver $amountRangeResolver, @@ -38,12 +47,22 @@ public function refresh(PaymentMethodInterface $paymentMethod, bool $flush = tru } try { + $apiLimits = $this->fetchApiLimits($paymentMethod, $apiKey, $gatewayConfig->getConfig()); + + // Read back after the PayPlug call, which can take a while: a save of the gateway config + // made meanwhile (credentials, merchant settings) is kept, and only this block is replaced. + $this->entityManager->refresh($gatewayConfig); $config = $gatewayConfig->getConfig(); - $previous = PproConfig::fromArray($config[PproConfig::CONFIG_KEY] ?? null); - $refreshed = PproConfig::fromApiLimits($this->fetchApiLimits($paymentMethod, $apiKey, $config), $previous); + $previous = $this->readPrevious($gatewayConfig, $config); + $refreshed = PproConfig::fromApiLimits($apiLimits, $previous); $config[PproConfig::CONFIG_KEY] = $refreshed->toArray(); - $this->store($gatewayConfig, $config, $flush); + $gatewayConfig->setConfig($config); + + // A failed flush closes the entity manager, so there is nothing to restore in memory. + if ($flush) { + $this->entityManager->flush(); + } } catch (\Throwable $exception) { $this->logger->warning('[PayPlug] Could not refresh the PPRO limits from the PayPlug account.', [ 'factory_name' => $gatewayConfig->getFactoryName(), @@ -53,9 +72,23 @@ public function refresh(PaymentMethodInterface $paymentMethod, bool $flush = tru return PproRefreshResult::notRefreshed(); } + $this->warnOnEmptyAmountRange($gatewayConfig, $refreshed); + return new PproRefreshResult(true, $refreshed->adjustsSettingsOf($previous)); } + public function refreshIfStale(PaymentMethodInterface $paymentMethod, bool $flush = true): PproRefreshResult + { + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + $stored = PproConfig::fromArray($config[PproConfig::CONFIG_KEY] ?? null); + + $isFresh = null !== $stored && + $stored->live === (true === ($config['live'] ?? false)) && + $stored->isRefreshedSince($this->clock->now()->modify(self::MIN_REFRESH_INTERVAL)); + + return $isFresh ? PproRefreshResult::upToDate() : $this->refresh($paymentMethod, $flush); + } + /** * @param array $config */ @@ -91,23 +124,42 @@ private function fetchApiLimits(PaymentMethodInterface $paymentMethod, string $a } /** - * @param array $config + * The merchant settings to keep. Before any `payment_method` block exists, a Scalapay gateway + * still holds the range its former Scalapay-only form saved: it seeds the merchant range once. + * + * @param array $config */ - private function store(GatewayConfigInterface $gatewayConfig, array $config, bool $flush): void + private function readPrevious(GatewayConfigInterface $gatewayConfig, array $config): ?PproConfig { - $previousConfig = $gatewayConfig->getConfig(); - $gatewayConfig->setConfig($config); - - if (!$flush) { - return; + $stored = $config[PproConfig::CONFIG_KEY] ?? null; + if (null !== $stored || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) { + return PproConfig::fromArray($stored); } - try { - $this->entityManager->flush(); - } catch (\Throwable $exception) { - $gatewayConfig->setConfig($previousConfig); + $min = self::readLegacyAmount($config[self::LEGACY_SCALAPAY_MIN_AMOUNT] ?? null); + $max = self::readLegacyAmount($config[self::LEGACY_SCALAPAY_MAX_AMOUNT] ?? null); - throw $exception; + return null === $min && null === $max ? null : new PproConfig(null, true, null, null, null, $min, $max, null); + } + + private static function readLegacyAmount(mixed $amount): ?int + { + return \is_int($amount) && $amount >= 0 ? $amount : null; + } + + /** + * Logged here, once per refresh, rather than on every checkout render that hides the method. + */ + private function warnOnEmptyAmountRange(GatewayConfigInterface $gatewayConfig, PproConfig $refreshed): void + { + if (!$refreshed->hasEmptyAmountRange()) { + return; } + + $this->logger->warning('[PayPlug] The PPRO amount range is empty; the payment method is hidden until its limits change.', [ + 'factory_name' => $gatewayConfig->getFactoryName(), + 'min' => $refreshed->effectiveMin(), + 'max' => $refreshed->effectiveMax(), + ]); } } diff --git a/src/Ppro/PproConfigurationRefresherInterface.php b/src/Ppro/PproConfigurationRefresherInterface.php index abf26409..9dc8c014 100644 --- a/src/Ppro/PproConfigurationRefresherInterface.php +++ b/src/Ppro/PproConfigurationRefresherInterface.php @@ -9,8 +9,16 @@ interface PproConfigurationRefresherInterface { /** - * Stores the PayPlug limits of a PPRO payment method in its gateway config. Never throws: on - * any failure the stored config is left untouched and the result says it was not refreshed. + * Stores the PayPlug limits of a PPRO payment method in its gateway config, re-read from the + * database first so that only the `payment_method` block is replaced. Never throws: on a failure + * to fetch the limits the stored config is left untouched, and the result says it was not + * refreshed (a failed flush also closes the entity manager). */ public function refresh(PaymentMethodInterface $paymentMethod, bool $flush = true): PproRefreshResult; + + /** + * Same as refresh(), unless the limits stored for the current environment were refreshed a few + * minutes ago: they are then kept as they are, without calling PayPlug or writing anything. + */ + public function refreshIfStale(PaymentMethodInterface $paymentMethod, bool $flush = true): PproRefreshResult; } diff --git a/src/Ppro/PproConfigurationValidator.php b/src/Ppro/PproConfigurationValidator.php index 74abf619..d94cb1f8 100644 --- a/src/Ppro/PproConfigurationValidator.php +++ b/src/Ppro/PproConfigurationValidator.php @@ -47,6 +47,10 @@ public function violations(?PproConfig $stored, ?int $min, ?int $max, ?array $co */ private function minViolations(?PproConfig $stored, ?int $min, ?int $max): array { + if (null !== $min && $min < 0) { + return [$this->message('invalid_amount')]; + } + $messages = []; $defaultMin = $stored?->defaultMin; $defaultMax = $stored?->defaultMax; @@ -69,6 +73,10 @@ private function minViolations(?PproConfig $stored, ?int $min, ?int $max): array */ private function maxViolations(?PproConfig $stored, ?int $max): array { + if (null !== $max && $max < 0) { + return [$this->message('invalid_amount')]; + } + $defaultMax = $stored?->defaultMax; if (null !== $max && null !== $defaultMax && $max > $defaultMax) { diff --git a/src/Ppro/PproEligibilityChecker.php b/src/Ppro/PproEligibilityChecker.php index d5b88216..b6789f40 100644 --- a/src/Ppro/PproEligibilityChecker.php +++ b/src/Ppro/PproEligibilityChecker.php @@ -4,50 +4,56 @@ namespace PayPlug\SyliusPayPlugPlugin\Ppro; +use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Sylius\Component\Payment\Model\PaymentMethodInterface; /** * Checkout eligibility of a PPRO payment method, read from its stored configuration only. Without - * stored data the method is kept and PayPlug arbitrates at payment time. + * stored PayPlug limits it cannot decide, and the caller falls back to the live account check. */ final class PproEligibilityChecker { private const CURRENCY = 'EUR'; + /** + * A disabled flag older than this no longer hides the method: PayPlug may have activated it + * since, and it decides at payment time. + */ + private const DISABLED_FLAG_TTL = '-24 hours'; + public function __construct( private LoggerInterface $logger, + private ClockInterface $clock, ) { } + /** + * @return bool|null null when no PayPlug limits are stored for the method + */ public function isEligible( PaymentMethodInterface $paymentMethod, int $amount, string $currencyCode, ?string $billingCountryCode, - ): bool { + ): ?bool { if (self::CURRENCY !== $currencyCode) { return false; } $config = $this->readStoredConfig($paymentMethod); if (null === $config) { - return true; + return null; } - if ($config->hasEmptyAmountRange()) { - $this->logger->warning('[PayPlug] The PPRO amount range is empty; the payment method is hidden until its limits are refreshed.', [ - 'factory_name' => $paymentMethod->getGatewayConfig()?->getFactoryName(), - 'min' => $config->effectiveMin(), - 'max' => $config->effectiveMax(), - ]); - } + $trustDisabledFlag = $config->isRefreshedSince($this->clock->now()->modify(self::DISABLED_FLAG_TTL)); - return $config->isEligible($amount, $billingCountryCode); + return $config->isEligible($amount, $billingCountryCode, $trustDisabledFlag); } /** - * Null when nothing is stored, or when the stored block is malformed (logged). + * Null when nothing is stored, when only merchant settings are (the PayPlug limits were never + * captured, or were cleared by a logout), or when the stored block is malformed (logged). */ private function readStoredConfig(PaymentMethodInterface $paymentMethod): ?PproConfig { @@ -61,6 +67,6 @@ private function readStoredConfig(PaymentMethodInterface $paymentMethod): ?PproC ]); } - return $config; + return null === $config?->refreshedAt ? null : $config; } } diff --git a/src/Ppro/PproRefreshResult.php b/src/Ppro/PproRefreshResult.php index 5b8bd505..afbb3349 100644 --- a/src/Ppro/PproRefreshResult.php +++ b/src/Ppro/PproRefreshResult.php @@ -6,9 +6,13 @@ final class PproRefreshResult { + /** + * @param bool $skipped the stored limits were recent enough to be kept without asking PayPlug + */ public function __construct( public readonly bool $refreshed, public readonly bool $adjusted = false, + public readonly bool $skipped = false, ) { } @@ -16,4 +20,9 @@ public static function notRefreshed(): self { return new self(false); } + + public static function upToDate(): self + { + return new self(true, false, true); + } } diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index b535ee6a..80645f7e 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -63,9 +63,13 @@ public function provide( continue; } - if (PproPaymentMethods::isPpro($factoryName)) { - $isSupported = $this->pproEligibilityChecker->isEligible($paymentMethod, $paymentAmount, $activeCurrencyCode, $billingCountryCode); - } else { + // A PPRO method is filtered from its stored PayPlug limits; until they are stored, it is + // filtered live from the account like every other method. + $isSupported = PproPaymentMethods::isPpro($factoryName) + ? $this->pproEligibilityChecker->isEligible($paymentMethod, $paymentAmount, $activeCurrencyCode, $billingCountryCode) + : null; + + if (null === $isSupported) { $memoKey = $this->accountMemoKey($gatewayConfig); if (!\array_key_exists($memoKey, $accounts)) { $accounts[$memoKey] = $this->readAccountLimits($paymentMethod, $gatewayConfig); @@ -153,10 +157,11 @@ private function accountMemoKey(GatewayConfigInterface $gatewayConfig): string } /** - * The PPRO gateways never reach this point; for the others the `/account` payload is still read - * live (through the token-keyed cache of PayPlugApiClient). A failure to read or parse it keeps - * the method rather than breaking payment-method resolution for the whole checkout, and is - * memoized by the caller like a successful read. + * A PPRO gateway reaches this point only while no PayPlug limits are stored for it; for every + * other gateway the `/account` payload is read live (through the token-keyed cache of + * PayPlugApiClient). A failure to read or parse it keeps the method rather than breaking + * payment-method resolution for the whole checkout, and is memoized by the caller like a + * successful read. * * The factory name is read off the gateway config the account is fetched for, so the payload and * the key used to index it always come from the same config. diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php index c8886416..d868b693 100644 --- a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -65,6 +65,8 @@ final class UnifiedAuthenticationControllerTest extends TestCase private PproRefreshResult $refreshResult; + private ?Request $callbackRequest = null; + protected function setUp(): void { $this->router = $this->createMock(RouterInterface::class); @@ -397,6 +399,7 @@ function (PaymentMethodInterface $refreshed, bool $flush = true) use ($paymentMe $this->logger->expects(self::never())->method('critical'); $this->controller->oauthCallback($request); + $this->callbackRequest = $request; return $storedConfig; } @@ -483,4 +486,42 @@ public function testCallbackStillSucceedsWhenRefreshReportsFailure(): void self::assertContains('refresh', $this->calls); self::assertContains('validate', $this->calls); } + + /** + * Without fresh limits the method keeps the previous account's ones, or none: the merchant is + * told, so the limits get refreshed from the payment method page. + */ + public function testCallbackWarnsWhenThePproLimitsCouldNotBeRefreshed(): void + { + $this->refreshResult = PproRefreshResult::notRefreshed(); + + $this->runSuccessfulCallback( + ['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer'], + factoryName: WeroGatewayFactory::FACTORY_NAME, + ); + + self::assertSame( + ['payplug_sylius_payplug_plugin.ppro.refresh_failed'], + $this->callbackRequest?->getSession()->getFlashBag()->peek('warning'), + ); + } + + public function testCallbackDoesNotWarnForANonPproGateway(): void + { + $this->refreshResult = PproRefreshResult::notRefreshed(); + + $this->runSuccessfulCallback(['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer']); + + self::assertSame([], $this->callbackRequest?->getSession()->getFlashBag()->peek('warning')); + } + + public function testCallbackDoesNotWarnWhenThePproLimitsWereRefreshed(): void + { + $this->runSuccessfulCallback( + ['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer'], + factoryName: WeroGatewayFactory::FACTORY_NAME, + ); + + self::assertSame([], $this->callbackRequest?->getSession()->getFlashBag()->peek('warning')); + } } diff --git a/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php b/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php index 6cf0479d..6f299893 100644 --- a/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php +++ b/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php @@ -9,6 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\Auth\SyliusTokenCache; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\Component\Core\Model\PaymentMethod; @@ -65,6 +66,43 @@ public function testRevoke_keepsTheMerchantSettingsThatAreNotCredentials(): void self::assertSame('merchant', $config['fees_for']); } + /** + * The PayPlug limits stored for a PPRO method belong to the disconnected account; after a + * reconnection to another account they must not keep filtering the checkout. The merchant's own + * narrowing is a setting, not account data, and is kept. + */ + public function testRevoke_clearsTheStoredPayPlugLimitsButKeepsTheMerchantSettings(): void + { + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => [ + 'name' => 'wero', + 'enabled' => true, + 'allowed_countries' => ['BE', 'DE'], + 'default_amount' => ['min' => 30, 'max' => 2000000], + 'configured_amount' => ['min' => 1000, 'max' => null], + 'configured_countries' => ['BE'], + 'live' => true, + 'refreshed_at' => '2026-10-20T09:00:00+00:00', + ], + ]); + + $this->revoker->revoke($paymentMethod); + + self::assertSame( + ['configured_amount' => ['min' => 1000, 'max' => null], 'configured_countries' => ['BE']], + ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])['payment_method'] ?? null, + ); + } + + public function testRevoke_leavesAMalformedPproBlockAlone(): void + { + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, ['payment_method' => 'garbage']); + + $this->revoker->revoke($paymentMethod); + + self::assertSame('garbage', ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])['payment_method'] ?? null); + } + public function testRevoke_disablesThePaymentMethod(): void { $paymentMethod = $this->paymentMethod(); diff --git a/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php index f96fab1a..f8bf47ce 100644 --- a/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php @@ -97,7 +97,7 @@ public function testCountryRefusalOnPproRefreshesAndRedirectsToOrderPage(): void $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); // The bus's doctrine_transaction middleware flushes once the handler returns. - $this->refresher->expects(self::once())->method('refresh')->with($paymentRequest->getMethod(), false)->willReturn(new PproRefreshResult(true, true)); + $this->refresher->expects(self::once())->method('refreshIfStale')->with($paymentRequest->getMethod(), false)->willReturn(new PproRefreshResult(true, true)); $this->finalUrlProvider->expects(self::once())->method('getUrl')->with($paymentRequest->getPayment()); $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); $this->logger->expects(self::once())->method('info'); @@ -115,7 +115,7 @@ public function testAmountRefusalOnPproIsHandledTheSameWay(): void { $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', '{"details":{"amount":"Too low."}}', 400)); - $this->refresher->expects(self::once())->method('refresh')->willReturn(new PproRefreshResult(true)); + $this->refresher->expects(self::once())->method('refreshIfStale')->willReturn(new PproRefreshResult(true)); $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); ($this->handler)(new CapturePaymentRequest('hash')); @@ -131,7 +131,7 @@ public function testFlashIsAddedWhenASessionExists(): void $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); - $this->refresher->method('refresh')->willReturn(new PproRefreshResult(true)); + $this->refresher->method('refreshIfStale')->willReturn(new PproRefreshResult(true)); ($this->handler)(new CapturePaymentRequest('hash')); @@ -146,18 +146,63 @@ public function testNoFlashAndNoErrorWithoutASession(): void $this->requestStack->push(new Request()); $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); - $this->refresher->method('refresh')->willReturn(new PproRefreshResult(true)); + $this->refresher->method('refreshIfStale')->willReturn(new PproRefreshResult(true)); ($this->handler)(new CapturePaymentRequest('hash')); self::assertSame(self::RETRY_URL, $paymentRequest->getResponseData()['redirect_url']); } + /** + * A decoupled frontend gets `redirect_url` and `error` in the response data; a flash would stay + * in its session and show up later on an unrelated page. + * + * @dataProvider headlessRequests + */ + public function testNoFlashForAHeadlessRequest(Request $request): void + { + $request->setSession(new Session(new MockArraySessionStorage())); + $this->requestStack->push($request); + $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + $this->refresher->method('refreshIfStale')->willReturn(new PproRefreshResult(true)); + + ($this->handler)(new CapturePaymentRequest('hash')); + + self::assertSame([], $request->getSession()->getFlashBag()->peekAll()); + self::assertSame('payplug_sylius_payplug_plugin.error.ppro_not_available', $paymentRequest->getResponseData()['error']); + } + + /** + * @return iterable + */ + public static function headlessRequests(): iterable + { + $json = new Request(); + $json->setRequestFormat('json'); + yield 'JSON request format' => [$json]; + + yield 'XHR request' => [new Request([], [], [], [], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'])]; + } + + public function testRefusalLogSaysWhetherTheLimitsWereRefreshed(): void + { + $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); + $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); + $this->refresher->method('refreshIfStale')->willReturn(PproRefreshResult::notRefreshed()); + $this->logger->expects(self::once())->method('info')->with( + self::anything(), + self::callback(static fn (array $context): bool => false === $context['limits_refreshed']), + ); + + ($this->handler)(new CapturePaymentRequest('hash')); + } + public function testRefresherFailureStillRedirects(): void { $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); - $this->refresher->method('refresh')->willReturn(PproRefreshResult::notRefreshed()); + $this->refresher->method('refreshIfStale')->willReturn(PproRefreshResult::notRefreshed()); $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); ($this->handler)(new CapturePaymentRequest('hash')); @@ -169,7 +214,7 @@ public function testOtherHttpExceptionKeepsTodaysBehaviour(): void { $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::POSTCODE_REFUSAL, 400)); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); $this->logger->expects(self::once())->method('error'); @@ -185,7 +230,7 @@ public function testNonPproFactoryNeverRefreshes(): void { $paymentRequest = $this->paymentRequest(ApplePayGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad request', self::COUNTRY_REFUSAL, 400)); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); ($this->handler)(new CapturePaymentRequest('hash')); @@ -201,7 +246,7 @@ public function testNonJsonRefusalBodyFailsThePaymentRequest(): void { $paymentRequest = $this->paymentRequest(WeroGatewayFactory::FACTORY_NAME); $this->client->method('createPayment')->willThrowException(new BadRequestException('Bad gateway', '502', 502)); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); $this->expectTransition(PaymentRequestTransitions::TRANSITION_FAIL); ($this->handler)(new CapturePaymentRequest('hash')); @@ -216,7 +261,7 @@ public function testHappyPathUnchanged(): void 'id' => 'pay_123', 'hosted_payment' => ['payment_url' => 'https://secure.payplug.com/pay/123'], ])); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); $this->expectTransition(PaymentRequestTransitions::TRANSITION_COMPLETE); ($this->handler)(new CapturePaymentRequest('hash')); diff --git a/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php b/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php index c49e5999..a0d7184c 100644 --- a/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php +++ b/tests/PHPUnit/EventListener/PproConfigRefreshOnAdminDisplayListenerTest.php @@ -40,7 +40,7 @@ public function testRefreshesOnGetOfTheUpdateRoute(): void { $paymentMethod = $this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME); $this->paymentMethodRepository->method('find')->with('7')->willReturn($paymentMethod); - $this->refresher->expects(self::once())->method('refresh')->with($paymentMethod, true)->willReturn(new PproRefreshResult(true)); + $this->refresher->expects(self::once())->method('refreshIfStale')->with($paymentMethod, true)->willReturn(new PproRefreshResult(true)); $request = $this->request(); ($this->listener)($this->event($request)); @@ -51,7 +51,7 @@ public function testRefreshesOnGetOfTheUpdateRoute(): void public function testIgnoresPost(): void { $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request(method: 'POST'))); } @@ -59,7 +59,7 @@ public function testIgnoresPost(): void public function testIgnoresOtherRoutes(): void { $this->paymentMethodRepository->expects(self::never())->method('find'); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request(route: 'sylius_admin_payment_method_index'))); } @@ -67,7 +67,7 @@ public function testIgnoresOtherRoutes(): void public function testIgnoresNonPproPaymentMethod(): void { $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(OneyGatewayFactory::FACTORY_NAME)); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request())); } @@ -79,7 +79,7 @@ public function testIgnoresNonPproPaymentMethod(): void public function testIgnoresNeverConnectedPaymentMethod(): void { $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME, false)); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); $request = $this->request(); ($this->listener)($this->event($request)); @@ -90,7 +90,7 @@ public function testIgnoresNeverConnectedPaymentMethod(): void public function testIgnoresPaymentMethodWithoutGatewayConfig(): void { $this->paymentMethodRepository->method('find')->willReturn(new PaymentMethod()); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request())); } @@ -98,7 +98,7 @@ public function testIgnoresPaymentMethodWithoutGatewayConfig(): void public function testIgnoresUnknownId(): void { $this->paymentMethodRepository->method('find')->willReturn(null); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request())); } @@ -106,7 +106,7 @@ public function testIgnoresUnknownId(): void public function testIgnoresMissingId(): void { $this->paymentMethodRepository->expects(self::never())->method('find'); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request(id: null))); } @@ -114,7 +114,7 @@ public function testIgnoresMissingId(): void public function testFailureAddsWarningFlashAndDoesNotThrow(): void { $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); - $this->refresher->method('refresh')->willReturn(PproRefreshResult::notRefreshed()); + $this->refresher->method('refreshIfStale')->willReturn(PproRefreshResult::notRefreshed()); $request = $this->request(); ($this->listener)($this->event($request)); @@ -128,7 +128,7 @@ public function testFailureAddsWarningFlashAndDoesNotThrow(): void public function testAdjustedResultAddsInfoFlash(): void { $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); - $this->refresher->method('refresh')->willReturn(new PproRefreshResult(true, true)); + $this->refresher->method('refreshIfStale')->willReturn(new PproRefreshResult(true, true)); $request = $this->request(); ($this->listener)($this->event($request)); @@ -139,10 +139,25 @@ public function testAdjustedResultAddsInfoFlash(): void ); } + /** + * Limits refreshed a few minutes ago are shown as they are: no PayPlug call, no write, no flash. + */ + public function testRecentLimitsAddNoFlash(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); + $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::once())->method('refreshIfStale')->willReturn(PproRefreshResult::upToDate()); + + $request = $this->request(); + ($this->listener)($this->event($request)); + + self::assertSame([], $request->getSession()->getFlashBag()->peekAll()); + } + public function testRequestWithoutSessionStillRefreshes(): void { $this->paymentMethodRepository->method('find')->willReturn($this->paymentMethod(BancontactGatewayFactory::FACTORY_NAME)); - $this->refresher->expects(self::once())->method('refresh')->willReturn(PproRefreshResult::notRefreshed()); + $this->refresher->expects(self::once())->method('refreshIfStale')->willReturn(PproRefreshResult::notRefreshed()); $request = new Request([], [], ['_route' => 'sylius_admin_payment_method_update', 'id' => '7']); ($this->listener)($this->event($request)); @@ -151,7 +166,7 @@ public function testRequestWithoutSessionStillRefreshes(): void public function testSubRequestIsIgnored(): void { $this->paymentMethodRepository->expects(self::never())->method('find'); - $this->refresher->expects(self::never())->method('refresh'); + $this->refresher->expects(self::never())->method('refreshIfStale'); ($this->listener)($this->event($this->request(), HttpKernelInterface::SUB_REQUEST)); } diff --git a/tests/PHPUnit/Ppro/PproConfigTest.php b/tests/PHPUnit/Ppro/PproConfigTest.php index 9a8b80ab..e3488b1b 100644 --- a/tests/PHPUnit/Ppro/PproConfigTest.php +++ b/tests/PHPUnit/Ppro/PproConfigTest.php @@ -169,6 +169,26 @@ public function testDisabledByApiIsNotEligible(): void self::assertFalse($this->config(['enabled' => false])->isEligible(1000, 'BE')); } + public function testDisabledFlagIsIgnoredWhenNotTrusted(): void + { + $config = $this->config(['enabled' => false, 'allowed_countries' => ['BE']]); + + self::assertTrue($config->isEligible(1000, 'BE', false)); + self::assertFalse($config->isEligible(1000, 'FR', false)); + self::assertFalse($config->isEligible(49, 'BE', false)); + } + + public function testIsRefreshedSince(): void + { + $threshold = new \DateTimeImmutable('2026-10-20T09:00:00+00:00'); + + self::assertTrue($this->config(['refreshed_at' => '2026-10-20T09:00:00+00:00'])->isRefreshedSince($threshold)); + self::assertTrue($this->config(['refreshed_at' => '2026-10-20T11:00:00+02:00'])->isRefreshedSince($threshold)); + self::assertFalse($this->config(['refreshed_at' => '2026-10-20T08:59:59+00:00'])->isRefreshedSince($threshold)); + self::assertFalse($this->config(['refreshed_at' => null])->isRefreshedSince($threshold)); + self::assertFalse($this->config(['refreshed_at' => 'yesterday'])->isRefreshedSince($threshold)); + } + public function testToArrayRoundTrips(): void { $stored = $this->stored([ diff --git a/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php b/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php index 3195d02c..e5c3649e 100644 --- a/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php +++ b/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php @@ -338,7 +338,7 @@ public function testNeverOverwritesConfiguredValuesOtherwise(): void self::assertSame(['DE'], $this->storedBlock($paymentMethod)['configured_countries']); } - public function testScalapayOldMinMaxKeysAreIgnoredOnFirstCapture(): void + public function testScalapayLegacyRangeIsCarriedOverOnFirstCapture(): void { $this->client->method('getAccount')->willReturn(self::sampleAccount()); $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ @@ -346,12 +346,163 @@ public function testScalapayOldMinMaxKeysAreIgnoredOnFirstCapture(): void 'max_amount' => 90000, ]); + $result = $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => 5000, 'max' => 90000], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertFalse($result->adjusted); + } + + public function testScalapayLegacyRangeIsClampedToThePayPlugLimits(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 100, + 'max_amount' => 900000, + ]); + + $result = $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => 500, 'max' => 200000], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertTrue($result->adjusted); + } + + public function testScalapayLegacySideOfUnexpectedTypeIsDropped(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => '5000', + 'max_amount' => 90000, + ]); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => null, 'max' => 90000], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testScalapayLegacyRangeIsNotReadOnceABlockIsStored(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 5000, + 'max_amount' => 90000, + 'payment_method' => self::previousBlock(['min' => null, 'max' => null]), + ]); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + + public function testLegacyRangeKeysAreOnlyReadForScalapay(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 5000, + 'max_amount' => 90000, + ]); + $this->refresher->refresh($paymentMethod); - $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); - self::assertSame(5000, $config['min_amount']); - self::assertSame(90000, $config['max_amount']); + } + + /** + * The PayPlug call can take a while: a save of the gateway config made meanwhile (credentials, + * merchant settings) is read back before writing, and only the `payment_method` block is replaced. + */ + public function testReReadsTheGatewayConfigBeforeWriting(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'live' => true, + 'account_email' => 'old@example.com', + ]); + $gatewayConfig = $paymentMethod->getGatewayConfig(); + self::assertNotNull($gatewayConfig); + + $this->entityManager->expects(self::once())->method('refresh')->with($gatewayConfig)->willReturnCallback( + static function (GatewayConfig $gatewayConfig): void { + $gatewayConfig->setConfig([ + 'live' => true, + 'account_email' => 'new@example.com', + 'payment_method' => self::previousBlock(['min' => 1000, 'max' => 50000], ['BE']), + ]); + }, + ); + + $this->refresher->refresh($paymentMethod); + + $config = $gatewayConfig->getConfig(); + self::assertSame('new@example.com', $config['account_email']); + self::assertSame(['min' => 1000, 'max' => 50000], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertSame(['BE'], $this->storedBlock($paymentMethod)['configured_countries']); + self::assertSame(self::NOW, $this->storedBlock($paymentMethod)['refreshed_at']); + } + + public function testLogsAnEmptyAmountRangeWhenStoringIt(): void + { + $account = self::sampleAccount(); + $account['payment_methods']['wero']['min_amounts'] = ['EUR' => 5000]; + $account['payment_methods']['wero']['max_amounts'] = ['EUR' => 1000]; + $this->client->method('getAccount')->willReturn($account); + $this->logger->expects(self::once())->method('warning')->with(self::stringContains('amount range is empty')); + + $result = $this->refresher->refresh($this->paymentMethod(WeroGatewayFactory::FACTORY_NAME)); + + self::assertTrue($result->refreshed); + } + + public function testRefreshIfStaleSkipsARecentRefresh(): void + { + $initial = ['live' => true, 'payment_method' => self::refreshedBlock('2026-10-20T08:55:01+00:00', true)]; + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, $initial); + $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); + $this->entityManager->expects(self::never())->method('flush'); + + $result = $this->refresher->refreshIfStale($paymentMethod); + + self::assertTrue($result->refreshed); + self::assertTrue($result->skipped); + self::assertFalse($result->adjusted); + self::assertSame($initial, $paymentMethod->getGatewayConfig()?->getConfig()); + } + + /** + * @dataProvider staleConfigs + * + * @param array $initial + */ + public function testRefreshIfStaleRefreshesStaleLimits(array $initial): void + { + $this->client->expects(self::once())->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, $initial); + + $result = $this->refresher->refreshIfStale($paymentMethod); + + self::assertTrue($result->refreshed); + self::assertFalse($result->skipped); + self::assertSame(self::NOW, $this->storedBlock($paymentMethod)['refreshed_at']); + } + + /** + * @return iterable}> + */ + public static function staleConfigs(): iterable + { + yield 'older than five minutes' => [['live' => true, 'payment_method' => self::refreshedBlock('2026-10-20T08:54:59+00:00', true)]]; + yield 'fetched in the other environment' => [['live' => false, 'payment_method' => self::refreshedBlock('2026-10-20T08:59:00+00:00', true)]]; + yield 'never refreshed' => [['live' => true]]; + yield 'merchant settings only' => [['live' => true, 'payment_method' => ['configured_amount' => ['min' => 1000, 'max' => null]]]]; + yield 'unreadable timestamp' => [['live' => true, 'payment_method' => self::refreshedBlock('yesterday', true)]]; + } + + public function testRefreshIfStaleOnANonPproMethodIsNotRefreshed(): void + { + $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); + + $result = $this->refresher->refreshIfStale($this->paymentMethod(OneyGatewayFactory::FACTORY_NAME)); + + self::assertFalse($result->refreshed); } public function testUnrelatedConfigKeysSurvive(): void @@ -503,6 +654,17 @@ private static function previousBlock(array $configuredAmount, ?array $configure ]; } + /** + * @return array + */ + private static function refreshedBlock(string $refreshedAt, bool $live): array + { + return array_merge(self::previousBlock(['min' => null, 'max' => null]), [ + 'live' => $live, + 'refreshed_at' => $refreshedAt, + ]); + } + /** * @return array */ diff --git a/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php b/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php index 4f7f3a35..bb8ad641 100644 --- a/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php +++ b/tests/PHPUnit/Ppro/PproConfigurationValidatorTest.php @@ -46,6 +46,38 @@ public function testMaxAboveApiMaxReportsBothBounds(): void ); } + /** + * Refused whatever the PayPlug limits: a stored negative amount would make the whole stored + * block unreadable, and the method would then be shown regardless of the merchant settings. + * + * @dataProvider storedLimits + */ + public function testNegativeAmountsAreRefused(?PproConfig $stored): void + { + self::assertSame( + [ + 'ppro_min_amount' => ['validators|payplug_sylius_payplug_plugin.ppro.invalid_amount'], + 'ppro_max_amount' => ['validators|payplug_sylius_payplug_plugin.ppro.invalid_amount'], + ], + $this->validator->violations($stored, -500, -1, null), + ); + } + + /** + * @return iterable + */ + public static function storedLimits(): iterable + { + yield 'no stored block' => [null]; + yield 'no PayPlug amount' => [new PproConfig('wero', true, null, null, null, null, null, null)]; + yield 'PayPlug limits stored' => [new PproConfig('wero', true, null, 500, 200000, null, null, null)]; + } + + public function testZeroIsAcceptedWithoutPayPlugLimits(): void + { + self::assertSame([], $this->validator->violations(null, 0, 0, null)); + } + public function testMinGreaterThanMax(): void { self::assertSame( diff --git a/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php b/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php index 9af82b53..c9d95386 100644 --- a/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php +++ b/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php @@ -11,9 +11,12 @@ use Psr\Log\LoggerInterface; use Sylius\Bundle\PayumBundle\Model\GatewayConfig; use Sylius\Component\Core\Model\PaymentMethod; +use Symfony\Component\Clock\MockClock; final class PproEligibilityCheckerTest extends TestCase { + private const NOW = '2026-10-20T09:00:00+00:00'; + private LoggerInterface&MockObject $logger; private PproEligibilityChecker $checker; @@ -21,29 +24,43 @@ final class PproEligibilityCheckerTest extends TestCase protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); - $this->checker = new PproEligibilityChecker($this->logger); + $this->checker = new PproEligibilityChecker($this->logger, new MockClock(self::NOW)); } - public function testNoStoredBlockIsFailOpenForEur(): void + public function testNoStoredBlockIsUndecidedForEur(): void { $this->logger->expects(self::never())->method('warning'); - self::assertTrue($this->checker->isEligible($this->paymentMethod([]), 1, 'EUR', 'US')); - self::assertTrue($this->checker->isEligible($this->paymentMethod([]), 99999999, 'EUR', null)); + self::assertNull($this->checker->isEligible($this->paymentMethod([]), 1, 'EUR', 'US')); + self::assertNull($this->checker->isEligible($this->paymentMethod([]), 99999999, 'EUR', null)); } - public function testPaymentMethodWithoutGatewayConfigIsFailOpen(): void + public function testPaymentMethodWithoutGatewayConfigIsUndecided(): void { - self::assertTrue($this->checker->isEligible(new PaymentMethod(), 1000, 'EUR', 'FR')); + self::assertNull($this->checker->isEligible(new PaymentMethod(), 1000, 'EUR', 'FR')); } - public function testMalformedBlockIsFailOpenAndLogged(): void + public function testMalformedBlockIsUndecidedAndLogged(): void { $this->logger->expects(self::once())->method('warning'); $paymentMethod = $this->paymentMethod(['payment_method' => ['default_amount' => ['min' => 'abc']]]); - self::assertTrue($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'FR')); + self::assertNull($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'FR')); + } + + /** + * Merchant settings alone (left by a logout, or saved before the first capture succeeded) carry + * no PayPlug limits: the caller falls back to the live account check. + */ + public function testBlockWithoutCapturedLimitsIsUndecided(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => [ + 'configured_amount' => ['min' => 1000, 'max' => 5000], + 'configured_countries' => ['BE'], + ]]); + + self::assertNull($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); } public function testNonEurCurrencyIsHidden(): void @@ -62,9 +79,12 @@ public function testDelegatesToPproConfig(): void self::assertFalse($this->checker->isEligible($paymentMethod, 2000001, 'EUR', 'BE')); } - public function testEmptyEffectiveRangeIsHiddenAndLogged(): void + /** + * The empty range is logged once by the refresher that stores it, not on every checkout render. + */ + public function testEmptyEffectiveRangeIsHiddenWithoutLogging(): void { - $this->logger->expects(self::once())->method('warning'); + $this->logger->expects(self::never())->method('warning'); $paymentMethod = $this->paymentMethod(['payment_method' => $this->block([ 'default_amount' => ['min' => 30, 'max' => 1000], @@ -74,6 +94,31 @@ public function testEmptyEffectiveRangeIsHiddenAndLogged(): void self::assertFalse($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); } + public function testRecentDisabledFlagHidesTheMethod(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => $this->block([ + 'enabled' => false, + 'refreshed_at' => '2026-10-19T09:00:00+00:00', + ])]); + + self::assertFalse($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); + } + + /** + * PayPlug may have activated the method since: a stale disabled flag no longer hides it, and + * PayPlug decides at payment time. The stored amount and country limits still apply. + */ + public function testStaleDisabledFlagNoLongerHidesTheMethod(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => $this->block([ + 'enabled' => false, + 'refreshed_at' => '2026-10-19T08:59:59+00:00', + ])]); + + self::assertTrue($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); + self::assertFalse($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'FR')); + } + /** * @param array $overrides * @@ -88,6 +133,8 @@ private function block(array $overrides = []): array 'default_amount' => ['min' => 30, 'max' => 2000000], 'configured_amount' => ['min' => 30, 'max' => 2000000], 'configured_countries' => null, + 'live' => false, + 'refreshed_at' => self::NOW, ], $overrides); } diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php index ec9efc8c..1a2c841e 100644 --- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -23,9 +23,12 @@ use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; +use Symfony\Component\Clock\MockClock; final class SupportedMethodsProviderTest extends TestCase { + private const NOW = '2026-10-20T09:00:00+00:00'; + private CurrencyContextInterface&MockObject $currencyContext; private PayPlugApiClientFactoryInterface&MockObject $clientFactory; @@ -60,7 +63,7 @@ protected function setUp(): void $this->clientFactory, new AccountAmountRangeResolver(), $this->logger, - new PproEligibilityChecker(new NullLogger()), + new PproEligibilityChecker(new NullLogger(), new MockClock(self::NOW)), new AccountAllowedCountriesResolver(), ); } @@ -326,7 +329,7 @@ public function testProvide_withMixedList_filtersCorrectly(): void } // ------------------------------------------------------------------------- - // provide() — PPRO methods read their stored configuration, never the API + // provide() — PPRO methods read their stored configuration, the account only until it is stored // ------------------------------------------------------------------------- public function testPproMethodReadsStoredConfigAndNeverCallsTheApi(): void @@ -341,9 +344,35 @@ public function testPproMethodReadsStoredConfigAndNeverCallsTheApi(): void self::assertCount(1, $this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'BE')); } - public function testPproMethodWithoutStoredConfigIsKept(): void + /** + * Until the PayPlug limits are first stored (right after the upgrade, or after a logout), the + * method is filtered live from the account, as it was before the limits were stored. + */ + public function testPproMethodWithoutStoredConfigFallsBackToTheAccount(): void { - $this->clientFactory->expects(self::never())->method('createForPaymentMethod'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('wero', ['BE', 'DE'])); + + $method = $this->buildPaymentMethod(WeroGatewayFactory::FACTORY_NAME, []); + + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'FR')); + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 29, 'EUR', 'BE')); + self::assertCount(1, $this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'BE')); + } + + public function testPproMethodWithMerchantSettingsOnlyFallsBackToTheAccount(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('bancontact', ['BE'])); + + $method = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME, [ + 'payment_method' => ['configured_amount' => ['min' => 1000, 'max' => null], 'configured_countries' => ['BE']], + ]); + + self::assertEmpty($this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'FR')); + } + + public function testPproMethodWithoutStoredConfigIsKeptWhenTheAccountCannotBeRead(): void + { + $this->apiClient->method('getAccount')->willThrowException(new ConnectionException('timeout')); $method = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME, []); @@ -435,13 +464,15 @@ public function testScalapayLegacyMinMaxKeysAreIgnored(): void self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000, 'EUR')); } - public function testPproMalformedStoredBlockKeepsMethod(): void + public function testPproMalformedStoredBlockFallsBackToTheAccount(): void { + $this->apiClient->expects(self::once())->method('getAccount')->willReturn($this->buildAccount(500, 200000)); + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ 'payment_method' => ['default_amount' => ['min' => '500']], ]); - self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1, 'EUR')); + self::assertEmpty($this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1, 'EUR')); } // ------------------------------------------------------------------------- @@ -539,7 +570,7 @@ public function testNonPproGatewayIsKeptWhenFactoryThrowsGatewayConfigurationExc $clientFactory, new AccountAmountRangeResolver(), $this->logger, - new PproEligibilityChecker($this->logger), + new PproEligibilityChecker($this->logger, new MockClock(self::NOW)), new AccountAllowedCountriesResolver(), ); @@ -758,6 +789,8 @@ private function storedBlock(array $overrides = []): array 'default_amount' => ['min' => 30, 'max' => 2000000], 'configured_amount' => ['min' => 30, 'max' => 2000000], 'configured_countries' => null, + 'live' => false, + 'refreshed_at' => self::NOW, ], $overrides); } diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index 9e3b8624..f393f100 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -38,5 +38,5 @@ payplug_sylius_payplug_plugin: api_error: The operation could not be performed because of a PayPlug error. The payment was not changed; try again in a moment. unexpected_error: The operation may have been performed by PayPlug, but it could not be recorded here. Check the payment in your PayPlug portal before trying again, to avoid capturing or cancelling twice. ppro: - refresh_failed: "PayPlug could not be reached: the amount and country limits shown are the last ones saved." + refresh_failed: "The PayPlug limits of this payment method could not be refreshed: the amount and country limits shown are the last ones saved. Details are in the payplug log channel." settings_adjusted: "PayPlug changed the limits of this payment method: your amount range or billing countries were adjusted to fit them. Check them below." diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index 2467f348..cab06cd3 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -38,5 +38,5 @@ payplug_sylius_payplug_plugin: api_error: L'opération n'a pas pu être effectuée à cause d'une erreur PayPlug. Le paiement n'a pas été modifié ; réessayez dans quelques instants. unexpected_error: L'opération a peut-être été effectuée par PayPlug, mais elle n'a pas pu être enregistrée ici. Vérifiez le paiement dans votre portail PayPlug avant de réessayer, pour ne pas capturer ou annuler deux fois. ppro: - refresh_failed: "PayPlug n'a pas pu être joint : les limites de montant et de pays affichées sont les dernières enregistrées." + refresh_failed: "Les limites PayPlug de ce moyen de paiement n'ont pas pu être actualisées : les limites de montant et de pays affichées sont les dernières enregistrées. Le détail figure dans le canal de logs payplug." settings_adjusted: "PayPlug a modifié les limites de ce moyen de paiement : votre plage de montants ou vos pays de facturation ont été ajustés pour les respecter. Vérifiez-les ci-dessous." diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index cce54556..d7198fed 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -38,5 +38,5 @@ payplug_sylius_payplug_plugin: api_error: L'operazione non è stata eseguita a causa di un errore PayPlug. Il pagamento non è stato modificato; riprova tra qualche istante. unexpected_error: L'operazione potrebbe essere stata eseguita da PayPlug, ma non è stato possibile registrarla qui. Verifica il pagamento nel tuo portale PayPlug prima di riprovare, per non catturare o annullare due volte. ppro: - refresh_failed: "Impossibile contattare PayPlug: i limiti di importo e di paese mostrati sono gli ultimi salvati." + refresh_failed: "Impossibile aggiornare i limiti PayPlug di questo metodo di pagamento: i limiti di importo e di paese mostrati sono gli ultimi salvati. I dettagli sono nel canale di log payplug." settings_adjusted: "PayPlug ha modificato i limiti di questo metodo di pagamento: l'intervallo di importi o i paesi di fatturazione sono stati adattati per rispettarli. Verificali qui sotto." From 2fc52649bbe0b820c35532b3c6e7d48b29960505 Mon Sep 17 00:00:00 2001 From: hdelaforce-payplug Date: Mon, 26 Oct 2026 16:06:09 +0100 Subject: [PATCH 3/3] PRE-3677: fix PPRO env fallback and merchant settings --- CHANGELOG.md | 10 ++- UPGRADE.md | 23 +++-- doc/ppro_display_eligibility.md | 14 +-- src/Ppro/PproConfig.php | 5 +- src/Ppro/PproConfigurationRefresher.php | 33 +------ src/Ppro/PproEligibilityChecker.php | 22 ++++- src/Ppro/PproMerchantSettings.php | 84 ++++++++++++++++++ src/Ppro/PproSettingsAdjuster.php | 6 +- src/Provider/SupportedMethodsProvider.php | 5 +- .../form/ppro_eligibility.html.twig | 6 +- .../Config/PproEligibilityTemplateTest.php | 72 ++++++++++++++++ tests/PHPUnit/Config/PproTranslationsTest.php | 1 + .../PproEligibilityTypeExtensionTest.php | 19 ++++ tests/PHPUnit/Ppro/PproConfigTest.php | 8 ++ .../Ppro/PproConfigurationRefresherTest.php | 41 ++++++++- .../Ppro/PproEligibilityCheckerTest.php | 86 ++++++++++++++++++- .../Provider/SupportedMethodsProviderTest.php | 45 ++++++++++ translations/messages.en.yml | 3 +- translations/messages.fr.yml | 3 +- translations/messages.it.yml | 3 +- 20 files changed, 423 insertions(+), 66 deletions(-) create mode 100644 src/Ppro/PproMerchantSettings.php create mode 100644 tests/PHPUnit/Config/PproEligibilityTemplateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ee33d35a..d0cf376e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,17 +47,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 method and refreshed when the admin page is opened, after an OAuth connection, after a save that switches the LIVE mode and after a payment PayPlug refuses for its country or amount (the admin page and a refusal reuse limits refreshed less than 5 minutes ago): once they are stored, the checkout no - longer calls the PayPlug API for these methods, which are filtered live from the account until then + longer calls the PayPlug API for these methods, which are filtered live from the account and by the + merchant settings until then (or while the stored limits belong to the other LIVE/TEST environment) - A PPRO method PayPlug reports as not enabled is hidden for 24 hours after the last refresh, then shown again for PayPlug to decide at payment time +- When PayPlug no longer allows any of the billing countries the merchant selected, the PPRO method + is hidden until new countries are selected, instead of being shown for every allowed country - A logout from PayPlug clears the stored PayPlug limits of a PPRO method (the merchant range and countries are kept), and a warning is shown when they cannot be captured after a connection - A Bancontact, Wero or Scalapay payment refused by PayPlug for its billing country or amount sends the customer back to the order page with a message to choose another payment method - A PPRO amount outside the PayPlug limits, or an empty country selection, is refused when saving the payment method instead of being saved with the payment method disabled -- The Scalapay minimum/maximum amount override becomes the merchant range of the new PPRO settings - when the PayPlug limits are first stored, adjusted to them if needed (see [UPGRADE.md](UPGRADE.md)) +- The Scalapay minimum/maximum amount override keeps narrowing the checkout until the PayPlug limits + are first stored, then becomes the merchant range of the new PPRO settings, adjusted to them if + needed (see [UPGRADE.md](UPGRADE.md)) ### Removed - Drop Payum support diff --git a/UPGRADE.md b/UPGRADE.md index c619ced7..72fa0263 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -39,13 +39,14 @@ stored, the checkout no longer reads the PayPlug API for these methods. What changes for a merchant already in production, in this order: -1. **The Scalapay minimum/maximum amount override is carried over** when the PayPlug limits are - first captured: the former `min_amount` and `max_amount` keys of a Scalapay gateway config become - the merchant range, adjusted to the PayPlug limits if they no longer fit (the payment method page - then shows a notice). They are not read again afterwards. +1. **The Scalapay minimum/maximum amount override is carried over.** Until the PayPlug limits are + first captured, the former `min_amount` and `max_amount` keys of a Scalapay gateway config keep + narrowing the checkout. At the first capture they become the merchant range, adjusted to the + PayPlug limits if they no longer fit (the payment method page then shows a notice), unless the + merchant entered a range in the meantime. They are not read again afterwards. 2. **Until the PayPlug limits are first captured, these methods are filtered live from the PayPlug - account**, by amount and billing country, as before the upgrade (the merchant settings apply once - the limits are stored). The limits are captured when the payment method page is opened in the + account**, by amount and billing country, as before the upgrade, and by the merchant settings + already saved (including the former Scalapay range). The limits are captured when the payment method page is opened in the admin, after an OAuth (re)connection, after a save that switches the LIVE mode, or after a payment refused by PayPlug for its country or amount. To capture them right after the upgrade, run once: @@ -59,8 +60,10 @@ What changes for a merchant already in production, in this order: 3. Every country allowed by PayPlug is accepted by default, and the merchant range is empty, which means "the PayPlug limits" (shown as the placeholder of the minimum and maximum fields). At least one country must stay selected, and an amount outside the PayPlug limits is refused when saving (the payment method is no longer saved and disabled instead). -4. **Fallback.** Without stored limits, or with stored limits that cannot be read, the payment method - is filtered live from the PayPlug account as before; if the account cannot be read either, it is +4. **Fallback.** Without stored limits, with stored limits that cannot be read, or with limits fetched + in the other environment (TEST vs LIVE, when the refresh after a switch failed), the payment + method is filtered live from the PayPlug account and by the merchant settings; if the account + cannot be read either, it is shown and PayPlug arbitrates at payment time: this feature never blocks the checkout. When PayPlug refuses a payment for its billing country or amount, the limits are refreshed and the customer is sent back to the order page to choose another payment method; the payment request @@ -68,7 +71,9 @@ What changes for a merchant already in production, in this order: 5. The stored limits move only on the events listed in point 2, not on a schedule. The admin page and a refused payment reuse limits refreshed less than 5 minutes ago instead of calling PayPlug again. An empty minimum or maximum, like a selection of every country, follows PayPlug's changes. A value - the merchant entered is kept, and only adjusted when PayPlug narrows its limits below it. A method + the merchant entered is kept, and only adjusted when PayPlug narrows its limits below it. When none + of the merchant's countries is allowed any more, the method is hidden until new countries are + selected (a warning is shown on the payment method page). A method PayPlug reports as not enabled is hidden for 24 hours after the last refresh; past that, it is shown again and PayPlug decides at payment time. 6. A logout from PayPlug clears the stored PayPlug limits (the merchant range and countries are diff --git a/doc/ppro_display_eligibility.md b/doc/ppro_display_eligibility.md index 4c0e30c5..eb888ec8 100644 --- a/doc/ppro_display_eligibility.md +++ b/doc/ppro_display_eligibility.md @@ -42,19 +42,23 @@ The PayPlug limits are stored with the payment method. They are refreshed: payment methods not connected yet are reported as skipped. Once the limits are stored, the checkout no longer calls PayPlug to decide whether to show these -payment methods. Before that (right after the upgrade, or after a logout from PayPlug), they are -filtered live from the PayPlug account, as before the upgrade. +payment methods. Before that (right after the upgrade, or after a logout from PayPlug), and while +the stored limits belong to the other environment (TEST vs LIVE), they are filtered live from the +PayPlug account, as before the upgrade, and by the merchant settings already saved. An empty minimum or maximum, like a selection of every country, follows PayPlug's changes. A value the merchant entered is kept; when PayPlug narrows its limits below it, it is adjusted at the next -refresh and a notice is shown on the payment method page. +refresh and a notice is shown on the payment method page. When none of the selected countries is +allowed by PayPlug any more, the payment method is hidden at checkout, never shown more widely than +the merchant chose, until new countries are selected; the payment method page shows a warning. If PayPlug reports the payment method as not enabled on the account, it is hidden at checkout and a -warning is shown on the payment method page. The payment method itself stays enabled in Sylius. +warning is shown on the payment method page, as of the last refresh. The payment method itself stays enabled in Sylius. PayPlug may enable it later: 24 hours after the last refresh, the method is shown again and PayPlug decides at payment time. -A Scalapay range set before the upgrade becomes the merchant range when the limits are first stored. +A Scalapay range set before the upgrade keeps narrowing the checkout, and becomes the merchant range +when the limits are first stored, unless a range was entered in the meantime. Logging out from PayPlug clears the stored PayPlug limits but keeps the merchant range and countries. diff --git a/src/Ppro/PproConfig.php b/src/Ppro/PproConfig.php index a57fe77d..50b9b16c 100644 --- a/src/Ppro/PproConfig.php +++ b/src/Ppro/PproConfig.php @@ -53,7 +53,8 @@ public static function fromArray(mixed $stored): ?self * Combines freshly fetched PayPlug limits ($api) with the merchant settings of $previous: a * configured amount is kept but clamped into the new bounds (an empty side keeps following the * PayPlug limit, a range left empty falls back to the PayPlug limits), and configured countries - * are intersected with the new allowed list (an empty intersection falls back to all countries). + * are intersected with the new allowed list (an empty intersection hides the method until the + * merchant picks new countries, rather than widening it to every allowed country). */ public static function fromApiLimits(self $api, ?self $previous): self { @@ -97,7 +98,7 @@ public function adjustsSettingsOf(?self $previous): bool */ public function isEligible(int $amount, ?string $billingCountry, bool $trustDisabledFlag = true): bool { - if (($trustDisabledFlag && !$this->enabled) || $this->hasEmptyAmountRange()) { + if (($trustDisabledFlag && !$this->enabled) || $this->hasEmptyAmountRange() || [] === $this->configuredCountries) { return false; } diff --git a/src/Ppro/PproConfigurationRefresher.php b/src/Ppro/PproConfigurationRefresher.php index 1bca36c3..ce1bb45f 100644 --- a/src/Ppro/PproConfigurationRefresher.php +++ b/src/Ppro/PproConfigurationRefresher.php @@ -7,7 +7,6 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PproPaymentMethods; -use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAllowedCountriesResolver; use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver; use Psr\Clock\ClockInterface; @@ -22,11 +21,6 @@ final class PproConfigurationRefresher implements PproConfigurationRefresherInte /** How recent stored limits must be for refreshIfStale() to keep them without asking PayPlug. */ private const MIN_REFRESH_INTERVAL = '-5 minutes'; - /** Where the Scalapay-only form stored the merchant range, in cents, before the `payment_method` block. */ - private const LEGACY_SCALAPAY_MIN_AMOUNT = 'min_amount'; - - private const LEGACY_SCALAPAY_MAX_AMOUNT = 'max_amount'; - public function __construct( private PayPlugApiClientFactoryInterface $apiClientFactory, private AccountAmountRangeResolver $amountRangeResolver, @@ -53,7 +47,8 @@ public function refresh(PaymentMethodInterface $paymentMethod, bool $flush = tru // made meanwhile (credentials, merchant settings) is kept, and only this block is replaced. $this->entityManager->refresh($gatewayConfig); $config = $gatewayConfig->getConfig(); - $previous = $this->readPrevious($gatewayConfig, $config); + // Before the first refresh, a Scalapay gateway's former range seeds the merchant range. + $previous = PproMerchantSettings::read($gatewayConfig->getFactoryName(), $config); $refreshed = PproConfig::fromApiLimits($apiLimits, $previous); $config[PproConfig::CONFIG_KEY] = $refreshed->toArray(); @@ -123,30 +118,6 @@ private function fetchApiLimits(PaymentMethodInterface $paymentMethod, string $a ); } - /** - * The merchant settings to keep. Before any `payment_method` block exists, a Scalapay gateway - * still holds the range its former Scalapay-only form saved: it seeds the merchant range once. - * - * @param array $config - */ - private function readPrevious(GatewayConfigInterface $gatewayConfig, array $config): ?PproConfig - { - $stored = $config[PproConfig::CONFIG_KEY] ?? null; - if (null !== $stored || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) { - return PproConfig::fromArray($stored); - } - - $min = self::readLegacyAmount($config[self::LEGACY_SCALAPAY_MIN_AMOUNT] ?? null); - $max = self::readLegacyAmount($config[self::LEGACY_SCALAPAY_MAX_AMOUNT] ?? null); - - return null === $min && null === $max ? null : new PproConfig(null, true, null, null, null, $min, $max, null); - } - - private static function readLegacyAmount(mixed $amount): ?int - { - return \is_int($amount) && $amount >= 0 ? $amount : null; - } - /** * Logged here, once per refresh, rather than on every checkout render that hides the method. */ diff --git a/src/Ppro/PproEligibilityChecker.php b/src/Ppro/PproEligibilityChecker.php index b6789f40..66ca09ef 100644 --- a/src/Ppro/PproEligibilityChecker.php +++ b/src/Ppro/PproEligibilityChecker.php @@ -51,9 +51,25 @@ public function isEligible( return $config->isEligible($amount, $billingCountryCode, $trustDisabledFlag); } + /** + * The merchant's own range and countries, applied on top of the live account check while no + * usable PayPlug limits are stored: those come from the account then, not from the stored block. + */ + public function isAllowedByMerchantSettings( + PaymentMethodInterface $paymentMethod, + int $amount, + ?string $billingCountryCode, + ): bool { + $gatewayConfig = $paymentMethod->getGatewayConfig(); + $settings = PproMerchantSettings::readMerchantOnly($gatewayConfig?->getFactoryName(), $gatewayConfig?->getConfig() ?? []); + + return null === $settings || $settings->isEligible($amount, $billingCountryCode); + } + /** * Null when nothing is stored, when only merchant settings are (the PayPlug limits were never - * captured, or were cleared by a logout), or when the stored block is malformed (logged). + * captured, or were cleared by a logout), when the limits were fetched in the other environment + * (TEST vs LIVE), or when the stored block is malformed (logged). */ private function readStoredConfig(PaymentMethodInterface $paymentMethod): ?PproConfig { @@ -67,6 +83,8 @@ private function readStoredConfig(PaymentMethodInterface $paymentMethod): ?PproC ]); } - return null === $config?->refreshedAt ? null : $config; + $live = true === ($gatewayConfig?->getConfig()['live'] ?? false); + + return null === $config?->refreshedAt || $config->live !== $live ? null : $config; } } diff --git a/src/Ppro/PproMerchantSettings.php b/src/Ppro/PproMerchantSettings.php new file mode 100644 index 00000000..77809379 --- /dev/null +++ b/src/Ppro/PproMerchantSettings.php @@ -0,0 +1,84 @@ + $config the whole gateway config + * + * @return PproConfig|null null when nothing readable is stored and there is no legacy range + */ + public static function read(?string $factoryName, array $config): ?PproConfig + { + $stored = PproConfig::fromArray($config[PproConfig::CONFIG_KEY] ?? null); + if (ScalapayGatewayFactory::FACTORY_NAME !== $factoryName || !self::awaitsLegacyRange($stored)) { + return $stored; + } + + $min = self::readLegacyAmount($config[self::LEGACY_SCALAPAY_MIN_AMOUNT] ?? null); + $max = self::readLegacyAmount($config[self::LEGACY_SCALAPAY_MAX_AMOUNT] ?? null); + if (null === $min && null === $max) { + return $stored; + } + + return new PproConfig( + $stored?->name, + $stored->enabled ?? true, + $stored?->allowedCountries, + $stored?->defaultMin, + $stored?->defaultMax, + $min, + $max, + $stored?->configuredCountries, + $stored?->live, + $stored?->refreshedAt, + ); + } + + /** + * The merchant's own narrowing only, without the PayPlug limits stored next to it. + * + * @param array $config the whole gateway config + */ + public static function readMerchantOnly(?string $factoryName, array $config): ?PproConfig + { + $settings = self::read($factoryName, $config); + + return null === $settings ? null : new PproConfig( + null, + true, + null, + null, + null, + $settings->configuredMin, + $settings->configuredMax, + $settings->configuredCountries, + ); + } + + private static function awaitsLegacyRange(?PproConfig $stored): bool + { + return null === $stored || + (null === $stored->refreshedAt && null === $stored->configuredMin && null === $stored->configuredMax); + } + + private static function readLegacyAmount(mixed $amount): ?int + { + return \is_int($amount) && $amount >= 0 ? $amount : null; + } +} diff --git a/src/Ppro/PproSettingsAdjuster.php b/src/Ppro/PproSettingsAdjuster.php index 4d6d7db2..2bde814f 100644 --- a/src/Ppro/PproSettingsAdjuster.php +++ b/src/Ppro/PproSettingsAdjuster.php @@ -53,8 +53,8 @@ public static function intersectCountries(?array $configured, ?array $allowed): return $configured; } - $intersection = array_values(array_intersect($configured, $allowed)); - - return [] === $intersection ? null : $intersection; + // Empty when none of the merchant's countries is allowed any more: the method is then hidden, + // never shown more widely than the merchant chose. + return array_values(array_intersect($configured, $allowed)); } } diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index 80645f7e..73c0b5f3 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -64,7 +64,7 @@ public function provide( } // A PPRO method is filtered from its stored PayPlug limits; until they are stored, it is - // filtered live from the account like every other method. + // filtered live from the account like every other method, then by the merchant settings. $isSupported = PproPaymentMethods::isPpro($factoryName) ? $this->pproEligibilityChecker->isEligible($paymentMethod, $paymentAmount, $activeCurrencyCode, $billingCountryCode) : null; @@ -75,7 +75,8 @@ public function provide( $accounts[$memoKey] = $this->readAccountLimits($paymentMethod, $gatewayConfig); } - $isSupported = $this->isWithinAccountLimits($accounts[$memoKey], $gatewayConfig, $paymentAmount, $activeCurrencyCode, $billingCountryCode); + $isSupported = $this->isWithinAccountLimits($accounts[$memoKey], $gatewayConfig, $paymentAmount, $activeCurrencyCode, $billingCountryCode) && + (!PproPaymentMethods::isPpro($factoryName) || $this->pproEligibilityChecker->isAllowedByMerchantSettings($paymentMethod, $paymentAmount, $billingCountryCode)); } if (!$isSupported) { diff --git a/templates/admin/payment_method/form/ppro_eligibility.html.twig b/templates/admin/payment_method/form/ppro_eligibility.html.twig index 6c258363..46e31dd1 100644 --- a/templates/admin/payment_method/form/ppro_eligibility.html.twig +++ b/templates/admin/payment_method/form/ppro_eligibility.html.twig @@ -24,9 +24,13 @@ })) }} {{ sylius_test_html_attribute('payplug-ppro-eligibility') }} > - {% if ppro is iterable and ppro['enabled']|default(true) is same as(false) %} + {# `??`, not `default`: Twig's `default` filter also replaces `false` and `[]`. #} + {% if ppro is iterable and (ppro['enabled'] ?? true) is same as(false) %} {% endif %} + {% if ppro is iterable and (ppro['configured_countries'] ?? null) is same as([]) %} + + {% endif %} {% if config_form.ppro_min_amount.vars.disabled %} {% endif %} diff --git a/tests/PHPUnit/Config/PproEligibilityTemplateTest.php b/tests/PHPUnit/Config/PproEligibilityTemplateTest.php new file mode 100644 index 00000000..c01dbe21 --- /dev/null +++ b/tests/PHPUnit/Config/PproEligibilityTemplateTest.php @@ -0,0 +1,72 @@ +render(['enabled' => false])); + } + + public function testNoDisabledAlertForAnEnabledMethod(): void + { + self::assertStringNotContainsString('ui.ppro.disabled_by_payplug', $this->render(['enabled' => true])); + self::assertStringNotContainsString('ui.ppro.disabled_by_payplug', $this->render([])); + } + + public function testNoCountryLeftAlertShowsWhenTheMerchantSelectionIsEmpty(): void + { + self::assertStringContainsString('ui.ppro.no_country_left', $this->render(['configured_countries' => []])); + } + + public function testNoCountryLeftAlertIsHiddenOtherwise(): void + { + self::assertStringNotContainsString('ui.ppro.no_country_left', $this->render(['configured_countries' => null])); + self::assertStringNotContainsString('ui.ppro.no_country_left', $this->render(['configured_countries' => ['BE']])); + } + + /** + * @param array $pproBlock + */ + private function render(array $pproBlock): string + { + $template = file_get_contents(self::TEMPLATE); + self::assertIsString($template); + + $twig = new Environment(new ArrayLoader(['ppro' => $template])); + foreach (['stimulus_controller', 'stimulus_target', 'sylius_test_html_attribute', 'form_row', 'asset'] as $function) { + $twig->addFunction(new TwigFunction($function, static fn (): string => '', ['is_safe' => ['html']])); + } + $twig->addFilter(new TwigFilter('trans', static fn (string $key): string => $key)); + $twig->addFilter(new TwigFilter('sylius_format_money', static fn (int $amount): string => (string) $amount)); + + $field = ['vars' => ['disabled' => false]]; + + return $twig->render('ppro', [ + 'hookable_metadata' => ['context' => ['form' => [ + 'gatewayConfig' => ['config' => [ + 'ppro_min_amount' => $field, + 'ppro_max_amount' => $field, + 'ppro_countries' => $field, + ]], + 'vars' => ['data' => ['gatewayConfig' => ['config' => ['payment_method' => $pproBlock]]]], + ]]], + ]); + } +} diff --git a/tests/PHPUnit/Config/PproTranslationsTest.php b/tests/PHPUnit/Config/PproTranslationsTest.php index 8655bac2..2a8ba76c 100644 --- a/tests/PHPUnit/Config/PproTranslationsTest.php +++ b/tests/PHPUnit/Config/PproTranslationsTest.php @@ -49,6 +49,7 @@ public static function pproKeys(): iterable 'payplug_sylius_payplug_plugin.ui.ppro.countries_label', 'payplug_sylius_payplug_plugin.ui.ppro.not_connected', 'payplug_sylius_payplug_plugin.ui.ppro.disabled_by_payplug', + 'payplug_sylius_payplug_plugin.ui.ppro.no_country_left', 'payplug_sylius_payplug_plugin.error.ppro_not_available', ], 'validators' => [ diff --git a/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php index 81cb45e8..29282803 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/PproEligibilityTypeExtensionTest.php @@ -23,6 +23,25 @@ final class PproEligibilityTypeExtensionTest extends TypeTestCase { private const CONNECTED = ['live' => true, 'live_client' => ['client_id' => 'id', 'client_secret' => 'secret']]; + private string $previousLocale; + + protected function setUp(): void + { + // The placeholders are formatted with the default locale: pin it so the expected values do + // not depend on the locale of the machine running the tests. + $this->previousLocale = \Locale::getDefault(); + \Locale::setDefault('en'); + + parent::setUp(); + } + + protected function tearDown(): void + { + \Locale::setDefault($this->previousLocale); + + parent::tearDown(); + } + protected function getTypes(): array { $translator = $this->createMock(TranslatorInterface::class); diff --git a/tests/PHPUnit/Ppro/PproConfigTest.php b/tests/PHPUnit/Ppro/PproConfigTest.php index e3488b1b..63efaf6b 100644 --- a/tests/PHPUnit/Ppro/PproConfigTest.php +++ b/tests/PHPUnit/Ppro/PproConfigTest.php @@ -169,6 +169,14 @@ public function testDisabledByApiIsNotEligible(): void self::assertFalse($this->config(['enabled' => false])->isEligible(1000, 'BE')); } + public function testNoConfiguredCountryLeftHidesTheMethodEverywhere(): void + { + $config = $this->config(['configured_countries' => []]); + + self::assertFalse($config->isEligible(1000, 'BE')); + self::assertFalse($config->isEligible(1000, null)); + } + public function testDisabledFlagIsIgnoredWhenNotTrusted(): void { $config = $this->config(['enabled' => false, 'allowed_countries' => ['BE']]); diff --git a/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php b/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php index e5c3649e..52a11f6e 100644 --- a/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php +++ b/tests/PHPUnit/Ppro/PproConfigurationRefresherTest.php @@ -299,7 +299,11 @@ public function testIntersectsConfiguredCountriesWithNewAllowed(): void self::assertSame(['BE'], $this->storedBlock($paymentMethod)['configured_countries']); } - public function testResetsConfiguredCountriesToNullWhenIntersectionIsEmpty(): void + /** + * None of the merchant's countries is allowed any more: the method is hidden until the merchant + * picks new ones, rather than widened to every country PayPlug allows. + */ + public function testEmptyCountryIntersectionHidesTheMethod(): void { $this->client->method('getAccount')->willReturn(self::sampleAccount()); $paymentMethod = $this->paymentMethod(WeroGatewayFactory::FACTORY_NAME, [ @@ -309,7 +313,7 @@ public function testResetsConfiguredCountriesToNullWhenIntersectionIsEmpty(): vo $result = $this->refresher->refresh($paymentMethod); self::assertTrue($result->adjusted); - self::assertNull($this->storedBlock($paymentMethod)['configured_countries']); + self::assertSame([], $this->storedBlock($paymentMethod)['configured_countries']); } public function testKeepsConfiguredCountriesWhenApiHasNoRestriction(): void @@ -393,6 +397,39 @@ public function testScalapayLegacyRangeIsNotReadOnceABlockIsStored(): void self::assertSame(['min' => null, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); } + /** + * Countries saved before the first successful refresh create the block without an amount range: + * the legacy range is still carried over, since the form never showed it. + */ + public function testScalapayLegacyRangeIsCarriedOverWhenOnlyCountriesWereSaved(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 5000, + 'max_amount' => 90000, + 'payment_method' => ['configured_amount' => ['min' => null, 'max' => null], 'configured_countries' => ['FR']], + ]); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => 5000, 'max' => 90000], $this->storedBlock($paymentMethod)['configured_amount']); + self::assertSame(['FR'], $this->storedBlock($paymentMethod)['configured_countries']); + } + + public function testScalapayLegacyRangeIsNotReadOverAnEnteredRange(): void + { + $this->client->method('getAccount')->willReturn(self::sampleAccount()); + $paymentMethod = $this->paymentMethod(ScalapayGatewayFactory::FACTORY_NAME, [ + 'min_amount' => 5000, + 'max_amount' => 90000, + 'payment_method' => ['configured_amount' => ['min' => 1000, 'max' => null]], + ]); + + $this->refresher->refresh($paymentMethod); + + self::assertSame(['min' => 1000, 'max' => null], $this->storedBlock($paymentMethod)['configured_amount']); + } + public function testLegacyRangeKeysAreOnlyReadForScalapay(): void { $this->client->method('getAccount')->willReturn(self::sampleAccount()); diff --git a/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php b/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php index c9d95386..11aa3708 100644 --- a/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php +++ b/tests/PHPUnit/Ppro/PproEligibilityCheckerTest.php @@ -4,6 +4,7 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Ppro; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Ppro\PproEligibilityChecker; use PHPUnit\Framework\MockObject\MockObject; @@ -119,6 +120,85 @@ public function testStaleDisabledFlagNoLongerHidesTheMethod(): void self::assertFalse($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'FR')); } + /** + * Limits fetched in the other environment (TEST vs LIVE) are not the ones the payment method now + * runs on, for instance when the refresh after a LIVE switch failed: the caller falls back to the + * live account check until they are refreshed. + */ + public function testBlockFromTheOtherEnvironmentIsUndecided(): void + { + $paymentMethod = $this->paymentMethod(['live' => true, 'payment_method' => $this->block(['live' => false])]); + + self::assertNull($this->checker->isEligible($paymentMethod, 1000, 'EUR', 'BE')); + } + + public function testMerchantSettingsAllowEverythingWhenNoneAreStored(): void + { + self::assertTrue($this->checker->isAllowedByMerchantSettings($this->paymentMethod([]), 1, 'US')); + self::assertTrue($this->checker->isAllowedByMerchantSettings(new PaymentMethod(), 1, 'US')); + } + + public function testMerchantSettingsApplyTheConfiguredRangeAndCountries(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => [ + 'configured_amount' => ['min' => 1000, 'max' => 5000], + 'configured_countries' => ['BE'], + ]]); + + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 999, 'BE')); + self::assertTrue($this->checker->isAllowedByMerchantSettings($paymentMethod, 1000, 'BE')); + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 5001, 'BE')); + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 1000, 'FR')); + self::assertTrue($this->checker->isAllowedByMerchantSettings($paymentMethod, 1000, null)); + } + + /** + * Only the merchant's own settings: the PayPlug side comes from the live account check, so stored + * PayPlug limits (possibly stale, or from the other environment) are not applied again. + */ + public function testMerchantSettingsIgnoreTheStoredPayPlugLimits(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => $this->block([ + 'enabled' => false, + 'allowed_countries' => ['DE'], + 'default_amount' => ['min' => 5000, 'max' => 9000], + 'configured_amount' => ['min' => null, 'max' => null], + ])]); + + self::assertTrue($this->checker->isAllowedByMerchantSettings($paymentMethod, 1000, 'BE')); + } + + public function testMerchantSettingsWithNoCountryLeftHideTheMethod(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => ['configured_countries' => []]]); + + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 1000, 'BE')); + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 1000, null)); + } + + public function testMerchantSettingsUseTheLegacyScalapayRange(): void + { + $paymentMethod = $this->paymentMethod(['min_amount' => 5000, 'max_amount' => 90000], ScalapayGatewayFactory::FACTORY_NAME); + + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 4999, 'FR')); + self::assertTrue($this->checker->isAllowedByMerchantSettings($paymentMethod, 5000, 'FR')); + self::assertFalse($this->checker->isAllowedByMerchantSettings($paymentMethod, 90001, 'FR')); + } + + public function testLegacyRangeKeysAreIgnoredForOtherFactories(): void + { + $paymentMethod = $this->paymentMethod(['min_amount' => 5000, 'max_amount' => 90000]); + + self::assertTrue($this->checker->isAllowedByMerchantSettings($paymentMethod, 1, 'FR')); + } + + public function testMalformedMerchantSettingsAllowEverything(): void + { + $paymentMethod = $this->paymentMethod(['payment_method' => ['configured_amount' => ['min' => 'abc']]]); + + self::assertTrue($this->checker->isAllowedByMerchantSettings($paymentMethod, 1, 'US')); + } + /** * @param array $overrides * @@ -141,11 +221,11 @@ private function block(array $overrides = []): array /** * @param array $config */ - private function paymentMethod(array $config): PaymentMethod + private function paymentMethod(array $config, string $factoryName = WeroGatewayFactory::FACTORY_NAME): PaymentMethod { $gatewayConfig = new GatewayConfig(); - $gatewayConfig->setFactoryName(WeroGatewayFactory::FACTORY_NAME); - $gatewayConfig->setGatewayName(WeroGatewayFactory::FACTORY_NAME); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setGatewayName($factoryName); $gatewayConfig->setConfig($config); $paymentMethod = new PaymentMethod(); diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php index 1a2c841e..6f04222e 100644 --- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -370,6 +370,51 @@ public function testPproMethodWithMerchantSettingsOnlyFallsBackToTheAccount(): v self::assertEmpty($this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'FR')); } + /** + * The fallback is the account check plus the merchant's own settings, which the stored block + * keeps without PayPlug limits (saved before the first refresh, or left by a logout). + */ + public function testPproFallbackAppliesTheStoredMerchantSettings(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('wero', ['BE', 'DE'])); + + $method = $this->buildPaymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'payment_method' => ['configured_amount' => ['min' => 1000, 'max' => null], 'configured_countries' => ['BE']], + ]); + + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 999, 'EUR', 'BE')); + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'DE')); + self::assertCount(1, $this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'BE')); + } + + /** + * Until the first refresh carries it over, the Scalapay range set before the upgrade still + * narrows the checkout, as it did before. + */ + public function testScalapayFallbackAppliesTheLegacyRange(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(500, 200000)); + + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => 5000, 'max_amount' => 90000]); + + self::assertEmpty($this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 4999, 'EUR')); + self::assertCount(1, $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 5000, 'EUR')); + self::assertEmpty($this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 90001, 'EUR')); + } + + public function testPproBlockFromTheOtherEnvironmentFallsBackToTheAccount(): void + { + $this->apiClient->method('getAccount')->willReturn($this->buildAccountWithCountries('wero', ['BE'])); + + $method = $this->buildPaymentMethod(WeroGatewayFactory::FACTORY_NAME, [ + 'live' => true, + 'payment_method' => $this->storedBlock(['live' => false, 'allowed_countries' => ['FR']]), + ]); + + self::assertCount(1, $this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'BE')); + self::assertEmpty($this->provider->provide([$method], WeroGatewayFactory::FACTORY_NAME, 1000, 'EUR', 'FR')); + } + public function testPproMethodWithoutStoredConfigIsKeptWhenTheAccountCannotBeRead(): void { $this->apiClient->method('getAccount')->willThrowException(new ConnectionException('timeout')); diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 24411435..25f9820c 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -124,7 +124,8 @@ payplug_sylius_payplug_plugin: countries_help: The payment method is displayed only for customers whose billing address is in one of the selected countries. countries_label: Countries not_connected: The amount and country limits are loaded from PayPlug once this payment method is connected to your PayPlug account. - disabled_by_payplug: PayPlug reports this payment method as not enabled on your account, so it is hidden at checkout. Contact PayPlug to enable it. + disabled_by_payplug: At the last refresh of its limits, PayPlug reported this payment method as not enabled on your account. It is hidden at checkout for 24 hours after that refresh, then shown again and PayPlug accepts or refuses the payment. Contact PayPlug to enable it. + no_country_left: None of the billing countries you selected is still allowed by PayPlug, so this payment method is hidden at checkout. Select the countries to display it for below. integrated_payment: card_holder.title: 'Cardholder name' card_holder.error: 'Invalid Name and/or Last Name.' diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index cdd3993c..9a8b5eac 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -142,7 +142,8 @@ payplug_sylius_payplug_plugin: countries_help: Le moyen de paiement s'affiche uniquement pour les clients dont l'adresse de facturation est dans un des pays sélectionnés. countries_label: Pays not_connected: Les limites de montant et de pays sont chargées depuis PayPlug une fois ce moyen de paiement connecté à votre compte PayPlug. - disabled_by_payplug: PayPlug indique que ce moyen de paiement n'est pas activé sur votre compte, il est donc masqué au checkout. Contactez PayPlug pour l'activer. + disabled_by_payplug: Lors de la dernière actualisation de ses limites, PayPlug indiquait que ce moyen de paiement n'est pas activé sur votre compte. Il est masqué au checkout pendant 24 heures après cette actualisation, puis de nouveau affiché, et PayPlug accepte ou refuse le paiement. Contactez PayPlug pour l'activer. + no_country_left: Aucun des pays de facturation que vous avez sélectionnés n'est encore autorisé par PayPlug, ce moyen de paiement est donc masqué au checkout. Sélectionnez ci-dessous les pays pour lesquels l'afficher. integrated_payment: card_holder.title: 'Nom du titulaire de la carte' card_holder.error: 'Nom et/ou prénom invalide(s).' diff --git a/translations/messages.it.yml b/translations/messages.it.yml index ac83e288..8d245bb4 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -124,7 +124,8 @@ payplug_sylius_payplug_plugin: countries_help: Il metodo di pagamento viene mostrato solo ai clienti il cui indirizzo di fatturazione si trova in uno dei paesi selezionati. countries_label: Paesi not_connected: I limiti di importo e di paese vengono caricati da PayPlug una volta collegato questo metodo di pagamento al tuo account PayPlug. - disabled_by_payplug: PayPlug indica che questo metodo di pagamento non è attivo sul tuo account, quindi è nascosto al checkout. Contatta PayPlug per attivarlo. + disabled_by_payplug: All'ultimo aggiornamento dei suoi limiti, PayPlug indicava che questo metodo di pagamento non è attivo sul tuo account. È nascosto al checkout per 24 ore dopo questo aggiornamento, poi viene mostrato di nuovo e PayPlug accetta o rifiuta il pagamento. Contatta PayPlug per attivarlo. + no_country_left: Nessuno dei paesi di fatturazione che hai selezionato è ancora autorizzato da PayPlug, quindi questo metodo di pagamento è nascosto al checkout. Seleziona qui sotto i paesi per cui mostrarlo. integrated_payment: card_holder.title: 'Titolare della carta' card_holder.error: 'Nome e/o Cognome non valido(i).'