Skip to content

fix(adyen): send the shopper locale so redirect pages match the Drop-in language - #824

Merged
acasazza merged 8 commits into
fix/adyen-dropin-session-keyfrom
fix/adyen-shopper-locale-v5
Sep 1, 2026
Merged

fix(adyen): send the shopper locale so redirect pages match the Drop-in language#824
acasazza merged 8 commits into
fix/adyen-dropin-session-keyfrom
fix/adyen-shopper-locale-v5

Conversation

@acasazza

Copy link
Copy Markdown
Member

Targets fix/adyen-dropin-session-key (#818) rather than main, so it lands inside the work already open there.

The bug

Adyen uses two locales and only one of them was travelling:

  • locale in the Core configuration is client-side only — it selects the Drop-in's translation bundle.
  • the shopper locale in the payment request is what Adyen uses for the pages it renders, in particular the hosted page a redirect method sends the shopper to.

payment_request_data carried payment_method, return_url, origin, redirect_from_issuer_method, shopper_ip and browser_info, but no shopper locale, so Adyen fell back to the merchant account default or the country code. A Drop-in correctly rendered in English then handed over to a Klarna page in Italian for an IT market.

This is a port of ddfb9a7a, which fixed it on the v4 line and never reached v5.

What changed

shopper_locale is sent, derived from the same value that drives the Drop-in. Commerce Layer's language_code is a bare ISO 639-1 code, so getAdyenShopperLocale expands it to the language-REGION form Adyen expects (enen-US, itit-IT). A language it cannot expand confidently yields undefined and the key is omitted, preserving Adyen's own fallback rather than sending a locale it may reject. ca is expanded too, which ddfb9a7a missed.

AdyenPaymentConfig.shopperLocale overrides the derived value, camelCase as a component option. Unlike in ddfb9a7a it also moves the Drop-in's own locale: an integration that sets it would otherwise get a Klarna page in Italian behind a Drop-in in English, which is the original mismatch chosen on purpose. Nothing changes for an integration that does not set it.

Attributes are snake_case. ddfb9a7a sent shopperLocale, which is Adyen's name but not the one this payload takes — the API maps snake_case attributes onto Adyen's camelCase. shopperInteraction had the same problem and is now shopper_interaction; Ecommerce is Adyen's default for a request carrying browser_info, which is why nothing visibly broke while it was not arriving.

Two cleanups in their own commit: the locale prop defaulted to en_US, now a named DEFAULT_LOCALE in the language-REGION form Adyen documents (their client normalizes the underscore, the payment request does not); and AdyenGateway no longer casts order.language_code to Stripe's StripeElementLocale to feed an Adyen prop — ?? undefined launders the null without the lie.

Commits

ff111221 fix(adyen): send shopper_locale so redirect pages match the Drop-in language
c7ec0082 chore(adyen): spell the default locale the way Adyen documents it
58844107 fix(adyen): send shopper_interaction in snake_case like its neighbours

The port is revertible on its own.

Tests

specs/payment_source/AdyenPayment.shopperLocale.spec.tsx — 6 cases: derived from the order language, not from the country code, config override, the override moving the Drop-in with it, omission when the language cannot be expanded, fallback to the locale prop. specs/utils/adyenShopperLocale.spec.ts — 4 cases on the expansion, including en_US and PT_br normalization.

Full suite green: 1032 tests in react-components, 129 in core-components, 99 in react-hooks-components. pnpm typecheck unchanged from the baseline on this branch (22 pre-existing errors, none in the touched files).

Still unverified

The snake_case attribute name is not documented — the API reference lists only payment_method, origin, return_url and browser_info inside payment_request_data. If a redirect method still comes up in the wrong language, the attribute name is the first thing to check against a real order.

🤖 Generated with Claude Code

Alessandro Casazza and others added 3 commits August 27, 2026 19:48
…anguage

Port of ddfb9a7, which fixed this on the v4 line and never reached v5.

Adyen uses two locales and only one of them was travelling:

- `locale` in the Core configuration is client-side only — it selects the
  Drop-in's translation bundle.
- the shopper locale in the payment request is what Adyen uses for the pages it
  renders itself, in particular the hosted page a redirect method sends the
  shopper to.

`payment_request_data` carried `payment_method`, `return_url`, `origin`,
`redirect_from_issuer_method`, `shopper_ip` and `browser_info`, but no shopper
locale, so Adyen fell back to the merchant account default or the country code.
A Drop-in correctly rendered in English then handed over to a Klarna page in
Italian for an IT market.

`shopper_locale` is now sent, derived from the same value that drives the
Drop-in. Commerce Layer's `language_code` is a bare ISO 639-1 code, so
`getAdyenShopperLocale` expands it to the `language-REGION` form Adyen expects
(`en` -> `en-US`, `it` -> `it-IT`). When a language cannot be expanded
confidently it returns undefined and the field is omitted, preserving Adyen's
current fallback rather than sending a locale it may reject. `ca` is expanded
too, which ddfb9a7 missed.

The attribute is snake_case, like the other Commerce Layer attributes in that
payload — it is the API that maps them onto Adyen's camelCase names. ddfb9a7
sent `shopperLocale` instead, which is the name Adyen uses but not the one this
payload takes.

`AdyenPaymentConfig.shopperLocale` overrides the derived value, camelCase as a
component option. Unlike in ddfb9a7 it also moves the Drop-in's own `locale`:
an integration that sets it would otherwise get a Klarna page in Italian behind
a Drop-in in English, which is the original mismatch chosen on purpose. Nothing
changes for an integration that does not set it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the shopperLocale work walked past.

The `locale` prop defaulted to `en_US`. Adyen's client normalizes that — it
replaces the underscore and then matches against its supported list — so it
worked, but the payment request has no such normalizer, and the same string in
both places would not have. It is now a named `DEFAULT_LOCALE` in the
`language-REGION` form, which is also Adyen's own fallback value.

`AdyenGateway` cast `order.language_code` to `StripeElementLocale`, importing a
Stripe type to feed an Adyen prop. It compiled only because that prop is
`string`. `language_code` is `string | null` and the prop is
`string | undefined`, so the cast was laundering the null: `?? undefined` does
that without claiming the value is something it is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`payment_request_data` is a Commerce Layer payload and the API maps its
snake_case keys onto Adyen's camelCase names. `shopperInteraction` was written
with Adyen's own spelling, so it went into the payload under a name that side
does not take — the same mistake the shopper locale nearly shipped with.

`Ecommerce` is Adyen's own default for this field, so a payment that has been
working was very likely not relying on it arriving. It arrives now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@acasazza acasazza added bug Something isn't working components Components package labels Aug 27, 2026
@acasazza acasazza self-assigned this Aug 27, 2026
…accepted

Selecting a payment method could enable `PlaceOrderButton` while the privacy &
terms checkbox was still unchecked.

Acceptance travelled from `<PrivacyAndTermsCheckbox>` to `PlaceOrderButton`
through `localStorage["privacy-terms"]`, and the write that happens at *mount*
notified nobody. A `"true"` left behind by an earlier visit — the unmount
cleanup only runs on a React unmount, never on a tab close or a hard navigation
— was read by `PlaceOrderContainer` as `isPermitted: true`. The checkbox then
mounted and correctly wrote `"false"`, but none of the container's effect
dependencies changed, so `isPermitted` stayed stale. The button's own effect
does re-run on `paymentSource?.id`, i.e. exactly when a payment method is
picked, and it read that stale value.

Acceptance now lives in a module-level store keyed by order id, which notifies
its subscribers. It is deliberately not persisted: a reload starts from
"not accepted", so what the shopper sees can no longer diverge from what gates
the button.

- add `termsAcceptanceStore`: in-memory, per-order, notifying
- add the public `useTermsAndConditions()` hook, so a custom consent control has
  a supported channel now that `localStorage` is gone
- `placeOrderPermitted` takes `termsAccepted` as a parameter instead of reading
  a global, and reports `termsBlocking`
- warn in development when acceptance is required but no control collects it,
  from an effect so a late-mounting checkbox cannot raise a false alarm
- drop `PLACE_ORDER_RECHECK_EVENT`, made redundant by the store
- drop a dead `isFree && !isPermitted` line: `setNotPermitted` is a state
  setter, so the branches below always overwrote it in the same effect pass

Two existing tests claimed to cover this and covered nothing: the suite mocks
`getCardDetails` to `{ brand: "" }`, which already falsifies the first factor of
the enabling condition, so they passed on the wrong factor and would have passed
with the gate deleted. Both now run with the condition live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alessandro Casazza and others added 4 commits August 31, 2026 17:38
Selecting a Drop-in method that needs no input — Klarna, PayPal — enabled
`PlaceOrderButton` while privacy & terms were still unaccepted, undoing the gate
added in 819280f.

`onSelect` and `onChange` answered `isValid` by writing
`placeOrderButtonRef.current.disabled = false` straight onto the DOM node, which
skips `isPermitted` — where the terms check lives — entirely. React never
repaired it either: its own `disabled` prop had not changed, so no re-render
reconciled the node. Observed on a live checkout as a fiber saying
`disabled: true` over a DOM saying `disabled: false`, with the reducer reporting
`isPermitted: false, termsBlocking: true`.

Both writes were redundant as well as harmful. Each sits right after
`ref.current.onsubmit = …` and `setPaymentRef({ ref })`, and `onsubmit` is in the
button's own effect dependencies — so the button already re-runs and enables
itself, but through `&& isPermitted`, which respects the terms.

The two remaining writes in the post-authorization paths are left alone: they
precede a programmatic `.click()` and belong to a different flow, worth revisiting
on its own terms.

- add regression coverage for both entry points, asserting on the DOM node's
  `disabled` rather than on React state — the blind spot that let the vacuous
  `place-order.spec.tsx` "disabled" tests pass on the wrong factor
- capture the Drop-in's options in the test double, so `onSelect` is reachable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two remaining `placeOrderButtonRef.current.disabled = false` writes look like
leftovers of the pattern just removed from the `isValid` handlers, and the obvious
next cleanup is to delete them for symmetry or swap the `.click()` for
`setPlaceOrder`. Both would break paying by redirect.

By the time these run Adyen has already authorized the payment and only the order
is left to place. Terms acceptance lives in memory and does not survive the reload
a redirect method (Klarna, iDEAL) causes, so the button is legitimately disabled
on the way back — and `.click()` on a disabled button is a no-op, which would leave
the shopper charged for an order that is never placed.

The click cannot become `setPlaceOrder` either: `handleClick` additionally guards
against already-placed and draft orders, drives the loading state, and fires the
integrator's `onClick`. The Apple/Google Pay branch alongside does call
`setPlaceOrder` directly, but express payments bypass that logic on purpose.

Comments only — no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`placeOrderPermitted` arms the gate on `privacyUrl && termsUrl`, so an order
carrying exactly one of the two silently requires no acceptance: the checkbox is
never rendered and the button enables with nothing ticked. Nothing distinguishes
that from a deliberate opt-out, which makes it the last remaining way to reach a
live place-order button without accepting anything.

The gate itself is left alone. Requiring acceptance on a single URL would be the
worse trade: `<PrivacyAndTermsCheckbox>` renders a link for each, so one of them
would point nowhere — and it would start blocking checkouts that integrators have
had working. The ambiguity is surfaced instead, following the
`useMissingTermsCheckboxWarning` precedent: development-only, from an effect, and
silent in production.

Both URLs absent stays quiet — that is the opt-out, not a mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(place-order): keep the button disabled until privacy & terms are accepted
@pkg-pr-new

pkg-pr-new Bot commented Aug 31, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@commercelayer/core-components@824
npm i https://pkg.pr.new/@commercelayer/react-components@824
npm i https://pkg.pr.new/@commercelayer/react-hooks-components@824

commit: 68a225c

@acasazza
acasazza requested review from gciotola and pfferrari and a lite review from Copilot and removed request for Copilot August 31, 2026 16:33
@acasazza
acasazza merged commit dd68ce5 into fix/adyen-dropin-session-key Sep 1, 2026
3 checks passed
@acasazza
acasazza deleted the fix/adyen-shopper-locale-v5 branch September 1, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working components Components package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants