Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,12 +43,33 @@ 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 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 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 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
- 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
Expand All @@ -61,6 +88,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

Expand All @@ -81,6 +111,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`).
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
84 changes: 84 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,90 @@ 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); 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 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, 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:

```bash
bin/console payplug:ppro:refresh
Comment thread
hdelaforce-payplug marked this conversation as resolved.
Resolved
```

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, 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
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. 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. 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
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:

| 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**, 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` 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`
Comment thread
hdelaforce-payplug marked this conversation as resolved.
Resolved
(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

1. Skip the faulty migration
Expand Down
65 changes: 65 additions & 0 deletions assets/admin/controllers/ppro-eligibility_controller.js
Original file line number Diff line number Diff line change
@@ -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));
}
}
59 changes: 59 additions & 0 deletions assets/admin/controllers/ppro-eligibility_validation.js
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 64 additions & 0 deletions assets/admin/controllers/ppro-eligibility_validation.test.js
Original file line number Diff line number Diff line change
@@ -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')));
});
4 changes: 4 additions & 0 deletions assets/controllers.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"apple-pay": {
"enabled": true,
"fetch": "lazy"
},
"ppro-eligibility": {
"enabled": true,
"fetch": "lazy"
}
}
},
Expand Down
Loading
Loading