Skip to content

SL-386 Fix orders lost when paying with a different card brand than selected - #348

Open
TLabutis wants to merge 3 commits into
feature/react-admin-settingsfrom
SL-386-saferpay-fields-different-card-brand
Open

SL-386 Fix orders lost when paying with a different card brand than selected#348
TLabutis wants to merge 3 commits into
feature/react-admin-settingsfrom
SL-386-saferpay-fields-different-card-brand

Conversation

@TLabutis

@TLabutis TLabutis commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Saferpay Fields let the shopper type any card into a form that was opened for one specific brand. When the brands differed, return.php resolved the checkout flow from the brand Saferpay reported back rather than from how the payment was started, so the flow fell through to the redirect branch and createAndValidateOrder() was never called. The money was authorized at Saferpay, saferpay_order.authorized stayed 0 and the shopper sat on the waiting screen forever.

Reloading that page made it worse: postProcess asserted again, and under a business licence the assert posts Payment/v1/Transaction/Authorize, which is state-changing. Saferpay answered TRANSACTION_IN_WRONG_STATE and the order was marked failed.

What changed

The flow is resolved from how the payment was initialized. PaymentTypeProvider::getForReturn() keys off the field token and the saved-card flag instead of the returned brand. The real brand is still recorded as the order's payment label, it just no longer decides the flow.

The return page cannot process the same payment twice. SaferPayTransactionProcessedGuard short-circuits postProcess when saferpay_order is already authorized or captured, and the page redirects straight to the success controller instead of asserting again. The lock is skipped when the request is the Saferpay notification, so the hosted payment page leg is not blocked by its own return page.

The Fields form only accepts brands the merchant enabled. EnabledCardBrandsProvider filters the card brands through the existing payment restrictions, PaymentFormAssetLoader maps them to the SDK's lowercase names per payment option, and inline-fields.js passes them to SaferpayFields.init as paymentMethods. InitializeRequest sends the same list. A mismatched card is now refused at entry rather than at the end.

Checkout shows a single "Cards" option. Enabled by default for existing shops through the 2.1.0 upgrade. Supporting it meant teaching the rest of the checkout that PAYMENT_CARDS stands for several brands: restrictions resolve to whether any enabled brand passes country and currency checks, saved cards are looked up across every enabled brand and labelled with the brand they were stored under, and alias registration is no longer suppressed under grouping.

One "Use Saferpay Fields" setting replaces the per-brand toggles. With cards grouped, the per-brand "Saferpay Fields" column did nothing (grouping was read before the toggles), and a shop without a Fields access token got an unpayable Cards option because only the frontend JS checked the token. Following the official Saferpay plugins on Magento, Shopware and WooCommerce, card form rendering is now a single switch in Payment Processing, and PaymentTypeProvider::get() falls back to the Payment Page whenever the business licence, the setting or the access token is missing, on both the checkout and return legs. The back office shows a warning when Fields is on but the token is empty. The 2.1.0 upgrade derives the setting from the old toggles (Payment Page only when toggles exist and all are off) and generates missing access tokens from the stored API credentials, logging failures without failing the upgrade.

American Express joins FIELD_SUPPORTED_PAYMENT_METHODS.

Known limits

  • V PAY and myOne have no SDK brand name. A shop that enables either gets an unrestricted Fields form, because a partial list would decline a card Saferpay itself accepts.
  • An order paid through the grouped option carries CARD as its payment label, not the brand actually used. Quoted separately.
  • The saferpay_field table, entity and creator stay in place; the payment methods refresh service still writes brand snapshots there. Removing the schema is out of scope.

Tests

64/64 unit tests pass. New coverage for PaymentTypeProvider (full decision tree incl. token fallback and test-mode suffix), EnabledCardBrandsProvider, CardAliasRegistrationGuard and the grouped-cards restriction path.

Manual verification on PS 8.2.3 against a live Saferpay TEST account: Fields mode (inline form, order authorized), switch off (checkout option becomes saferpayPaymentType=basic, redirect to the hosted payment page, order authorized), token-empty fallback (no dead form, redirect works, back office warning shown), and the upgrade path (setting derived from toggles, real token generated and idempotent on rerun). Earlier full verification of the card brand fix is in the PR comment below.

Changelog

Release notes for the fix and the Use Saferpay Fields setting added under the 2.1.0 release section.

Tadas Labutis added 2 commits August 27, 2026 13:17
Work in progress, deliberately not ready for review.

A shopper who selected one card brand and typed a card of another was left
with an authorized payment and no order. On the return leg the module resolved
the flow from the brand Saferpay reported instead of how the transaction was
initialized, so a brand without saferpay_field.active never reached the
Fields completion path and the checkout hung on Awaiting payment. Reloading
then sent a second Authorize, which Saferpay refused with
TRANSACTION_IN_WRONG_STATE, failing the order.

PaymentTypeProvider::getForReturn() now decides from the field token and the
saved-card selection, so any card typed into the Fields form completes.
SaferPayTransactionProcessedGuard reads the authorized and captured flags off
the saferpay_order row before the assert, and return.php takes the same lock
key the notification uses, so a reload or a parallel notification cannot
authorize twice. CardAliasRegistrationGuard centralises whether an alias is
worth requesting, and AssertService::createObjectsFromAssertResponse() now
takes that decision as a bool instead of re-deriving it from the posted card
option. CardPaymentGroupingService::mergeCurrencies() limits the grouped
Cards option to the currencies its own brands support.

Deliberately left open, all four need closing before this becomes a PR:

- SAFERPAY_GROUP_CARDS is flipped only in SaferPayConfig::getInstallDefaults(),
  which fresh installs read and nothing else does, so existing shops keep the
  per-brand list and the single Cards option does not actually ship. Needs an
  upgrade-script write of the configuration value.
- Refusing a brand the merchant has not enabled is not implemented at all.
  InitializeRequest still strips PaymentMethods when a field token is present
  and inline-fields.js passes no brand restriction, so a disabled brand is
  accepted and authorized.
- CardAliasRegistrationGuard::shouldRegister() returns !SAFERPAY_GROUP_CARDS,
  so defaulting grouping on silently disables saved cards. That contradicts
  keeping single-brand checkouts working as before and needs a product
  decision, not just code.
- No test evidence yet on the reported scenario: no Visa-selected then
  Mastercard-paid run, no return-page reload test, no Payment Page regression
  pass.
The Fields SDK accepted any brand the shopper typed because
InitializeRequest strips PaymentMethods whenever a field token is set and
inline-fields.js passed no restriction, so a Mastercard could be entered
into a form opened as Visa. The companion fix already resolves the return
flow from the field token; this narrows what the form accepts in the first
place so a brand the merchant did not enable is refused at entry.

EnabledCardBrandsProvider filters SaferPayConfig::CARD_BRANDS through
PaymentRestrictionValidation, PaymentFormAssetLoader turns that into the
SDK's lowercase brand names per payment option, and inline-fields.js feeds
them to SaferpayFields.init as paymentMethods. InitializeRequest carries
the same list to the Initialize call. AMEX joins
FIELD_SUPPORTED_PAYMENT_METHODS.

The grouped "Cards" option is now the default presentation, enabled for
existing shops by the 2.1.0 upgrade. Supporting it meant teaching the rest
of the checkout that PAYMENT_CARDS stands for several brands:
BasePaymentRestrictionValidation resolves it to whether any enabled brand
passes country and currency checks, SaferPayCardAliasRepository looks
saved cards up across every enabled brand and the template prefixes each
with the brand it was stored under, and CardAliasRegistrationGuard stops
suppressing alias registration under grouping. return.php only takes the
processing lock when the request is not the Saferpay notification, so the
hosted payment page leg is no longer blocked by its own return page.

V PAY and myOne have no SDK brand name, so a shop that enables either
leaves its Fields form unrestricted rather than declining a card Saferpay
would accept. Naming the real brand on an order paid through the grouped
option is not included.
@TLabutis

Copy link
Copy Markdown
Contributor Author

Verification

Run on PrestaShop 8.2.3 against a live Saferpay TEST account (JSON API 1.50, business licence on). Five real authorizations, orders 15 to 18. Every result below was confirmed in the database and in ps_saferpay_log, not only on the confirmation screen.

How it behaves now

Checkout shows one Cards option instead of a row per brand. Selecting it renders the Saferpay Fields form inline, restricted to the brands the merchant enabled - the SDK receives the brand map built by PaymentFormAssetLoader, for example {"AMEX":["amex"],"MASTERCARD":["mastercard"],"VISA":["visa"],"CARD":["amex","mastercard","visa"]}. Saved cards appear under the option prefixed with the brand they were stored under (VISA xxxx xxxx xxxx 0001), and choosing "use a new card" swaps the saved-card list for the Fields form without reloading the page.

A card outside the enabled brands is refused while it is being typed. A card inside them completes and creates the order, whichever brand it is.

Results

# Scenario Expected Result
1 Grouped Cards, MASTERCARD Fields flag off, paid with Mastercard 9030003150000007 - the exact original defect condition Order created Assert returned PaymentMeans.Brand.PaymentMethod = MASTERCARD, log took the Not redirect payment selected, creating order branch, order 15 authorized, transaction l46rxvbxrChtSAt0x8Avbvb3l0tA
2 Reload the return URL for that cart No second authorization return - Payment already processed, skipping assert, no ApiRequest POST, order count and state unchanged
3 Grouping off, Mastercard PAN typed into the Visa form Refused at entry Card field gets has-error, submit blocked with "Please check the following: Card number", no order, no saferpay_order row, no API call
4 Same form, Visa PAN 9010003150000001 Pays through Order 16, payment label VISA, authorized
5 Only VISA enabled, grouping on, paid with the saved card Saved-card branch works Brand map narrowed to {"VISA":["visa"],"CARD":["visa"]}, order 17 authorized through the usingSavedCard path
6 Fields off and grouping off - hosted Saferpay Payment Page Unaffected saferpayPaymentType=basic, redirect to test.saferpay.com/VT2/mpp/..., order 18 created in validation, return leg asserted, then the notify webhook hit Order already exists, returning without duplicating
7 Reload the return URL on the Payment Page flow (isWebhook=1) No damage Payment already processed, skipping assert, order still authorized

Unit suite: OK (57 tests, 68 assertions).

Not covered by this run

  • PS 1.7.6.1 and PS 9.1 checkout, and the third-party one-page checkout themes under views/js/front/opc/.
  • Multistore.
  • V PAY and myOne, which have no SDK brand name and so leave the form unrestricted.
  • SaferPayTransactionProcessedGuard has no unit test; it is covered only by the manual runs above.

One decision needed before merge

With SAFERPAY_GROUP_CARDS on, the grouped option reports hosted_iframe even when every brand's "Saferpay Fields" toggle is off, because PaymentTypeProvider::isHostedIframeRedirect() returns true for PAYMENT_CARDS before it reads isActiveByName(). Since upgrade/install-2.1.0.php turns grouping on for every shop, an upgrade can move a merchant from the Payment Page to inline Fields without them changing a setting. Either the grouped option is Fields by definition and we document that, or the per-brand toggle has to win.

With cards grouped into one option, PaymentTypeProvider read SAFERPAY_GROUP_CARDS
before the per-brand saferpay_field toggles, so the "Saferpay Fields" column in
the back office did nothing, and a shop without a Fields access token got an
unpayable Cards option because only inline-fields.js checked the token. Official
Saferpay plugins on other platforms use one integration-mode choice plus an
automatic Payment Page fallback, and this adopts that shape.

PaymentTypeProvider now takes the Configuration adapter and resolves the flow
from business licence, SAFERPAY_USE_FIELDS and a non-empty FIELDS_ACCESS_TOKEN,
falling back to BASIC otherwise; getForReturn() keeps keying off the field
token, so both legs agree. PaymentFormAssetLoader skips the Fields SDK when the
setting is off. The settings page replaces the per-method column with a single
switch in Card Display plus an amber warning when the token is missing. The
2.1.0 upgrade derives the setting from the old toggles (off only when rows
exist and none is active) and generates missing access tokens from stored
credentials, logging failures without failing the upgrade.

The saferpay_field table, entity and creator stay untouched; the refresh
service still writes brand snapshots there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant