diff --git a/CONTEXT.md b/CONTEXT.md index 3b1f2e47..c4e0dfc9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,7 +6,7 @@ This context covers the React components and state that let a storefront pay for **Payments Model**: Which of the two mutually exclusive payment models an order uses. Two values, named after the order relationship that carries the payment: **`payment_source`** (the older model: `payment_gateways` + `payment_methods` + a per-gateway payment source) and **`payment_sessions`** (the newer model: `payment_settings` + `payment_sessions`). An order is bound to one model for its whole life; it can never switch. A third transient value, `undetermined`, means the order data needed to decide has not loaded yet. -The API version and the Payments Model are two different things: the version says what the API *can* express (`available_payment_settings` exists only from `2026-05`), the order says which model it *uses*. API version `2026-05` is backward compatible and serves both models, so two organizations on the same version can be on different Payments Models, and a single response can carry both `available_payment_methods` and `available_payment_settings`. +The API version and the Payments Model are two different things: the version says what the API _can_ express (`available_payment_settings` exists only from `2026-05`), the order says which model it _uses_. API version `2026-05` is backward compatible and serves both models, so two organizations on the same version can be on different Payments Models, and a single response can carry both `available_payment_methods` and `available_payment_settings`. _Avoid_: legacy vs new (ages badly), payments version (collides with the API version, e.g. `2026-05`), v1/v2 **Payment Method**: @@ -26,7 +26,7 @@ One intended payment against an order, for an `amount_cents`, through a Payment _Avoid_: payment source, charge, payment intent **Payment Authorization**: -The record proving a Payment Session's money was actually taken. A session is only a stated *intent* to pay; a session with a `succeeded` Payment Authorization is the one and only evidence of payment on the `payment_sessions` model. Also what makes the order placeable. +The record proving a Payment Session's money was actually taken. A session is only a stated _intent_ to pay; a session with a `succeeded` Payment Authorization is the one and only evidence of payment on the `payment_sessions` model. Also what makes the order placeable. _Avoid_: authorized session, payment (as a synonym for the session) **Current Payment Session**: @@ -34,15 +34,15 @@ The Payment Session the shopper's selection points at — the one paying whateve _Avoid_: pending session, selected payment method, "the payment session" (an order has several) **Placeable**: -Whether the API would accept placing the order. Two distinct things share the word: `order.placeable` and the `_placeable` trigger — but they are **not** two ways to ask the same question, and only one of them is usable. `order.placeable` is transient and served **only in an update response**, never on a `GET`, so it can never gate a button on render. The `_placeable` trigger is a `PATCH` that *validates* the order: 200 with the whole order on success, 422 with a JSON:API `errors` array on failure. Because a failed validation persists nothing, repeating it is cheap. Payment coverage is checked by a default payment rule whose threshold an organization can change — so placeability is a server judgement, never a client calculation. +Whether the API would accept placing the order. Two distinct things share the word: `order.placeable` and the `_placeable` trigger — but they are **not** two ways to ask the same question, and only one of them is usable. `order.placeable` is transient and served **only in an update response**, never on a `GET`, so it can never gate a button on render. The `_placeable` trigger is a `PATCH` that _validates_ the order: 200 with the whole order on success, 422 with a JSON:API `errors` array on failure. Because a failed validation persists nothing, repeating it is cheap. Payment coverage is checked by a default payment rule whose threshold an organization can change — so placeability is a server judgement, never a client calculation. _Avoid_: "can be placed" (ambiguous between the attribute and the check), validated **Reusable Session**: A Payment Session the library may adopt instead of creating a new one: same Payment Setting, `status` still `unpaid`, not past `expires_at`, and with no Payment Authorization in a terminal failure state. Anything failing that predicate is abandoned in place, not deleted — an `unpaid` session counts toward nothing, and a sales-channel token may be refused the delete anyway. -_Avoid_: pending session, stale session, orphan session (the last one is what an abandoned session *becomes*) +_Avoid_: pending session, stale session, orphan session (the last one is what an abandoned session _becomes_) **Applied Gift Card**: -A gift card the shopper has spent on an order — a Payment Session against a `payment_setting_gift_cards` setting. Additive rather than an alternative: an order carries zero or more of them *plus* at most one other session for the difference. Its `amount_cents` is what it covers **of this order**, capped by the server to whatever was still owed — never the card's balance, which a session does not carry at all. Removable for free until it is authorized; after that only a refund could return the money, and the balance is debited the instant the authorization succeeds. +A gift card the shopper has spent on an order — a Payment Session against a `payment_setting_gift_cards` setting. Additive rather than an alternative: an order carries zero or more of them _plus_ at most one other session for the difference. Its `amount_cents` is what it covers **of this order**, capped by the server to whatever was still owed — never the card's balance, which a session does not carry at all. Removable in one of two ways, and the shopper's gesture is the same for both: **discarded** for free while nothing has been charged, or **refunded** once it has — the balance is debited the instant the authorization succeeds, and a charged session cannot be deleted at all. A refund is only available while the order is still `pending`; after placement a storefront token has no grant for it, and the card cannot come off. _Avoid_: gift card discount (it is a payment, not a discount), gift card balance (a different number) **Remaining Amount**: @@ -65,6 +65,42 @@ _Avoid_: saved card (informal), wallet In this codebase, the React component that wires a specific gateway's UI/SDK and drives Payment Source creation for that gateway (e.g. `StripeGateway`, `AdyenGateway`). _Avoid_: using "gateway" to mean the Payment Method +**Sessions Flow**: +The Adyen integration mode in which the gateway is driven from the browser: Commerce Layer creates an **Adyen Session**, the **Drop-in** takes it from there, and `adyen-web` calls Adyen directly for the payment, the 3DS action and the authentication result. The counterpart is the **Advanced Flow**, where Commerce Layer makes those calls instead. Only the Sessions Flow is implementable in this library, because the Advanced Flow needs `payment_authorization.response_data`, which is withheld from sales-channel and customer tokens. Note the consequence that reads as a bug and is not one: the `/payments` call Commerce Layer makes for a Sessions Flow authorization carries no payment method and fails Adyen `14_006` — it is not the call that charges, and the outcome arrives later by webhook. +_Avoid_: drop-in flow (the Drop-in is used in both), client-side flow (ambiguous — the Advanced Flow also has browser code) + +**Adyen Session**: +The gateway-side session, distinct from the **Payment Session** that owns it. Created by Commerce Layer when the Payment Session is created, and readable as `payment_session.response_data.{id, sessionData}` — Adyen's own field names, passed through verbatim. Everything Adyen must know is fixed at that moment: `PATCH { _refresh: true }` is a no-op for Adyen, so a session with the wrong `returnUrl` or no `shopperReference` cannot be corrected, only replaced. Expires after a day, from a value Commerce Layer chooses and sends. +_Avoid_: session (an order has Payment Sessions; say which), payment session (a different resource) + +**Drop-in**: +Adyen's hosted UI component, rendered by `adyen-web` into a container this library provides. It owns the card fields, their PCI iframes, the 3DS challenge and — in the **Sessions Flow** — the call that actually charges. Its own Pay button is suppressed (`showPayButton: false`), because `` starts the charge instead; that is what keeps the privacy-and-terms gate in front of every payment. +_Avoid_: Adyen widget, card form (it is more than the fields) + +**Redirect Return**: +The shopper coming back from a 3DS page hosted elsewhere, identified by `redirectResult` in the URL. Not an edge case and not avoidable: native 3DS2 is requested server-side for every Adyen payment, so the redirect variant happens whenever the card is not enrolled — the issuer's choice, not the integration's. Resuming needs no UI, since `submitDetails` is a method on the `adyen-web` core rather than on the **Drop-in**. `redirectResult` is single-use. Terms acceptance does not survive the navigation, which is why this is the one path where the library places the order without a click. +_Avoid_: 3DS callback (nothing calls back; the shopper navigates), redirect flow (it is one branch of the Sessions Flow, not a flow) + +**Payment Gateway Handoff**: +How a **Payment Gateway** component and `` reach each other. An external store keyed by order id — not context, because the two are siblings in a checkout rather than parent and child, the same shape terms acceptance already uses and for the same reason. Two axes, answering different questions. **Who collects**: the host, in which case the store carries the `submit` the button calls and whether the gateway is ready for it; the gateway itself, when the method has a **Gateway-Owned Button**; or nobody, on an order paying by bank transfer or gift card alone. And **whether a collection has already happened without us** — see **Out-of-Band Collection**. `submit` answers with one of four outcomes, and the two that look alike matter most: a **verdict** means no money moved and a rollback is safe, while an **unknown** outcome — a network failure, an expired gateway session, a cancelled overlay — means the payment may have gone through and nothing may be undone. Still gateway-neutral where it counts: the button reads who collects, never which gateway it is. +_Avoid_: payment ref (the `payment_source`-model mechanism, which publishes a form ref instead), submit handler + +**Gateway-Owned Button**: +A payment method whose own control performs the payment, so the checkout's place-order button cannot. The consequence is that the privacy-and-terms gate cannot sit in front of the place-order click for that method — it has to sit inside the method's own click. Two methods qualify and they arrive there differently. PayPal's `submit` throws outright, because a popup needs a real user gesture and PayPal's rules require their branded button to be the thing clicked. Google Pay's `submit` works, and is still no use: it calls `loadPaymentData()`, which Google requires inside the click's gesture, and the place sequence charges gift cards before it ever gets there — after that round trip the gesture is spent and the sheet never opens. Apple Pay is the same case, and adds one of its own: whether the page's domain is registered for Apple Pay on the merchant account is settled at merchant validation, _after_ the shopper has tapped — so unlike every other method, being offered is not evidence it can work, and it has to be asked for rather than defaulted. So rendering your own button is still a different thing from owning the click, but for a wallet the deciding question is the gesture rather than the API. +_Avoid_: express payment (a different entry point into the checkout, before an address exists), self-submitting method + +**Gift Card Moment**: +The point in a **Gateway-Owned Button** flow at which gift cards are charged, which differs per method and is not a preference. It must be before the gateway takes money — a refused payment must never leave gift cards charged after it — and it cannot be anywhere that costs the click's user gesture. For PayPal that leaves the click itself: `beforeSubmit` runs after the popup is open and rejecting it hangs the popup for good. For Google Pay the click is exactly where it cannot go, and `onAuthorized` serves instead — it fires after the shopper has chosen a card and before `/payments`, it has no gesture constraint, and rejecting it shows the reason inside Google's own sheet, which stays open for another attempt. The hook does not generalise: PayPal has one too, and using it would make the payment conditional on `actions.order` and turn a deliberate rejection into a parse error nobody can tell apart. The moment is per method, and so are the reasons. +_Avoid_: pre-authorization (means something else on a `PaymentAuthorization`), gift card hook + +**Out-of-Band Collection**: +Money collected without the shopper pressing the checkout's place-order button, leaving the order still to be placed. Two things produce it and they are one mechanism: returning from a 3DS redirect, where the page reloaded and nobody clicked anything, and a **Gateway-Owned Button**, where the click was never ours. In both, the library places the order on its own initiative — the only paths where it does — and in both the privacy-and-terms gate was satisfied earlier rather than skipped: before the redirect, or inside the method's own click. +_Avoid_: auto-place (`auto_place` on a Payment Setting is a different thing, and server-side), silent place + +**Client Key**: +The public Adyen credential the browser needs, `payment_setting_adyens.public_key`. Reachable by a sales-channel or customer token through exactly one request — the order with `available_payment_settings` included — because listing payment settings is refused and there is no other way to learn a setting's id. It is optional and unvalidated server-side, so a payment setting that works for server-side charges can carry none, and a setting in that state is skipped rather than offered. +_Avoid_: public key (ambiguous across gateways — Stripe's is a publishable key), API key (the secret credential, never served) + ## Relationships - An **Order** is on exactly one **Payments Model**, permanently @@ -78,6 +114,14 @@ _Avoid_: using "gateway" to mean the Payment Method - An **Order** carries zero or more **Applied Gift Cards** and at most one other **Payment Session**; that is the only split payment supported - Changing the Applied Gift Cards invalidates the other **Payment Session**: its `amount_cents` is fixed at creation, so once the **Remaining Amount** moves that session is not stale but wrong - A **Customer Payment Source** belongs to a **Customer**; selecting one sets the **Order**'s Payment Source +- A **Payment Session** against `payment_setting_adyens` owns exactly one **Adyen Session**, reachable only through its `response_data`; replacing one means replacing the other, because neither can be updated after creation +- The **Drop-in** takes the money, but the **Payment Authorization** is created afterwards and is only a record: in the **Sessions Flow** its own gateway call fails by construction, and it reaches `succeeded` from Adyen's webhook +- A **Payment Authorization** on the Sessions Flow never reaches `requires_action` — the shopper's 3DS happens before it exists +- A refused payment leaves the **Payment Session** `unpaid` but eventually carries a failed **Payment Authorization**, so the session must be replaced rather than retried +- A **Payment Gateway** reaches `` only through the **Payment Gateway Handoff**; they are siblings in a checkout, so no context connects them +- A method with a **Gateway-Owned Button** cannot be collected by ``, so the gate moves inside that method's own click and the button disables itself with that as the reason +- An **Out-of-Band Collection** is the only circumstance in which the library places an order without a click — and both of its causes leave the terms accepted earlier, not skipped +- A **Payment Session** for a card and one for PayPal are the same resource through the same **Payment Setting**: which method paid is not recorded on it, because `payment_instrument` is empty for Adyen and `client_data.payment_method` must never be written ## Example dialogue @@ -103,7 +147,44 @@ _Avoid_: using "gateway" to mean the Payment Method - "the session's type" was used to mean the gateway (e.g. "manual") — but `payment_session.type` is always the literal `"payment_sessions"` (the resource type). The gateway is `payment_session.payment_setting.type` (e.g. `payment_setting_manuals`). When someone says "the session type", ask which one they mean. - "the payment is done" was used for both a created **Payment Session** and a taken payment — resolved: only a `succeeded` **Payment Authorization** means paid; a session on its own means nothing was taken. - "placeable" was used for both the readable order attribute and the `_placeable` validation trigger — resolved in the glossary above; when someone says "check if it's placeable", ask whether they mean reading the attribute or asking the API. -- "set payment source" was used to mean both the async operation that creates/attaches a Payment Source *and* the reducer action that stores it in state — resolved: the operation is `setPaymentSource(...)`, the reducer action is `dispatch({ type: "setPaymentSource" })`. +- "set payment source" was used to mean both the async operation that creates/attaches a Payment Source _and_ the reducer action that stores it in state — resolved: the operation is `setPaymentSource(...)`, the reducer action is `dispatch({ type: "setPaymentSource" })`. + +## Example dialogue — Adyen + +> **Dev:** "The Drop-in has its own Pay button. Do I hide `` when Adyen is selected?" +> **Domain expert:** "The other way round. Hide Adyen's and drive it from ours. Its button charges the card directly, so it would take the money before the shopper accepted the terms — and that gate is a legal requirement of the checkout, not a property of the payment model." + +> **Dev:** "The authorization I created came back with a 422 from Adyen in `response_data`. Did the payment fail?" +> **Domain expert:** "No — that call isn't the one that charges. In the **Sessions Flow** `adyen-web` already charged; Commerce Layer's own call has no payment method and always fails `14_006`. The authorization sits at `pending` until Adyen's `AUTHORISATION` webhook settles it. Which is also why the placeability loop needs longer for Adyen than for a bank transfer: you're waiting on a webhook, not a local job." + +> **Dev:** "Card refused. I'll put the Drop-in back to `ready` so they can try another one." +> **Domain expert:** "Not on that session. The refusal will land a failed **Payment Authorization** on it, and a later success can't move a failed record to succeeded — the retry would be lost silently. Delete the **Payment Session** and make a new one. And warn the designer that the card fields come back empty either way; Adyen tears down the PCI iframes." + +> **Dev:** "Where do I get the **Client Key** from? There's no payment gateway resource any more." +> **Domain expert:** "`setting.public_key`, off the order's `available_payment_settings` — which this library already includes on every fetch. Better than the old model, where you had to create a payment source first just to read the key. But check it's actually there: it's optional and unvalidated, so a setting that charges fine server-side can have none, and then we skip it." + +> **Dev:** "Should I show the 'save this card' checkbox for guests too? The order has a customer record." +> **Domain expert:** "No — that record is often just an email. Adyen would store the token against it, and the next visitor who types that address would see the card's last four digits and be able to pay with it. Gate on the token: `isGuestToken`." + +> **Dev:** "The shopper came back from a 3DS page. Which component picks that up?" +> **Domain expert:** "None of the visible ones. `submitDetails` is on the `adyen-web` core, so the resume needs no UI at all — it runs from ``, which is the only thing guaranteed to be mounted. If it lived in the Adyen component, an accordion that reopened on a different step would leave a charged card on an unplaced order." + +## Example dialogue — PayPal + +> **Dev:** "I'll hide Adyen's PayPal button with `showPayButton: false` and call `dropin.submit()` from ours, like we do for cards." +> **Domain expert:** "Neither half works. `showPayButton: false` doesn't hide PayPal's button, it deletes the whole component — the shopper gets an accordion that opens on nothing. And `submit` on PayPal throws by design: their button has to be the thing clicked, because a popup needs a real user gesture." + +> **Dev:** "Then the terms gate is gone for PayPal?" +> **Domain expert:** "It moves. PayPal's own `onClick` gets an `actions.reject()` that aborts before the popup opens and before any Adyen call, and `onInit` lets you render the buttons disabled until consent. So the gate sits on the method's click instead of ours. Two mechanisms, because which one applies depends on who owns the click — and that's a property of the method, not of our code." + +> **Dev:** "`onPaymentCompleted` fired, so we're paid — I'll place the order." +> **Domain expert:** "Place it, yes, but you're not paid. `Pending` and `Received` reach that callback as success, and PayPal produces them far more than cards do. The authorization stays `pending` and the loop waits, which is right — just don't tell the shopper the money is taken." + +> **Dev:** "The shopper closed the PayPal popup. Do I give their gift cards back?" +> **Domain expert:** "No. A closed overlay arrives on `onError`, and every `onError` is an unknown outcome — the payment may have gone through. Same as a refused card: the gift cards stay applied and charged, they're spendable on the retry, and there's a control to remove one if the shopper gives up." + +> **Dev:** "I'll record `paypal` in the session's `client_data` so the recap can name it." +> **Domain expert:** "Don't — that one key is a tripwire. Writing `client_data.payment_method` makes the API call Adyen's `/payments` for the authorization, which 422s, which fails the authorization, which invalidates the session for good. The recap not naming PayPal is a gap we've filed; that would be an unrecoverable order." ## Example dialogue — gift cards diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md index 1438fb60..1c2577fd 100644 --- a/docs/adr/2026-08-18-payment-session-lifecycle.md +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -11,8 +11,8 @@ This ADR covers how the library creates, reuses and reads those sessions, and wh **Payment Authorization** fits. The place-order sequence itself is a separate decision: see `2026-08-18-place-order-split-by-payments-model.md`. -This iteration implements **`payment_setting_manuals` only**. Progress against the full -set is tracked at the bottom of this document. +This iteration implements **`payment_setting_manuals` only**. Progress against the full set is +tracked in `2026-09-02-adyen-payment-setting.md`. ### What the API actually does @@ -21,7 +21,7 @@ public docs are wrong (see "Sources that are wrong", below). **`amount_cents` is optional on create, and an explicit value is silently capped.** `PaymentSessionCreate` requires only `payment_setting`. Omitting `amount_cents` makes the -server default it to the order's *remaining* amount, not the total: +server default it to the order's _remaining_ amount, not the total: ```ruby # app/models/payment_session.rb:181-189 @@ -54,7 +54,7 @@ PAYMENT_TAKEN_STATES = %w(authorized paid partially_paid).freeze ``` The `payment_sessions` table has only `expires_at`, `created_at`, `updated_at` — there is -no `authorized_at`/`paid_at`. Transaction resources are the opposite: they *do* carry one +no `authorized_at`/`paid_at`. Transaction resources are the opposite: they _do_ carry one timestamp per state. **Transactions share one state machine across all four types.** `PaymentAuthorization`, @@ -81,13 +81,13 @@ runs in a Sidekiq job (`Workers::PaymentTransaction`, queue `payments`) dispatch ### Creation: eager, on selection, with reuse -Selecting a Payment Setting creates the Payment Session immediately. The selection *is* the +Selecting a Payment Setting creates the Payment Session immediately. The selection _is_ the session — that is what makes it survive a reload. Create with **`payment_setting` and `order` only**. Never send `amount_cents`; let the server size the session against the remaining amount. -> **Superseded in one place.** Gift cards after the first *do* send an explicit +> **Superseded in one place.** Gift cards after the first _do_ send an explicit > `amount_cents`, because the server's remainder does not move until a session is > authorized and gift cards are authorized at place time — so it would size every card for > the whole order. See `2026-08-20-gift-cards-as-payment-sessions.md`. The rule above still @@ -95,6 +95,7 @@ server size the session against the remaining amount. Before creating, **reuse** an existing session when all of these hold: +- it **is the order's current selection** (see the correction below), **and** - its `payment_setting.id` matches the selected setting, **and** - `status === "unpaid"`, **and** - `expires_at` is absent or in the future, **and** @@ -111,18 +112,59 @@ The session is always **searched for**, never read positionally. `payment_sessio wrong today for orders carrying a gift-card session and will be wrong for everyone once split payment is supported. -**The selection is single per order, and it is the most recent live session.** This -followed from the decision not to delete: switching setting leaves the previous session on -the order, so a per-setting reading of "is this selected?" would light up every setting the -shopper has ever tried at once — a radio group with several selections. Taking the newest -keeps the group coherent without deleting anything a token may be refused. - -Consequence for the reuse rule above: with only one setting implemented, the *adopt* branch -is currently unreachable through the UI, because a reusable session already reads as -selected and the radio ignores a click on the current selection. What is reachable, and -covered by tests, is the retry path — a burnt session does not count as the selection, so -clicking again creates a fresh one. The adopt branch is kept because it becomes live as -soon as a second setting exists. +**The selection is single per order, and it is the most recent live session.** A per-setting +reading of "is this selected?" would light up every setting the shopper has ever tried at +once — a radio group with several selections. Taking the newest keeps the group coherent +whatever else is on the order. + +> **Corrected (2026-09-08).** This paragraph used to justify itself with "the decision not +> to delete", and switching setting genuinely left the previous session behind — which +> contradicted the reformulated rule two sections down, _sessions that took no money are +> deleted_. The contradiction was not academic: an order accumulated one session per +> setting the shopper had ever tried, and it is how the reuse bug above was found in the +> first place. +> +> Switching now deletes the session it supersedes, **after** the new one exists and best +> effort, skipping anything holding money. The recency rule stays and is not redundant: it +> is what makes the selection correct in the window before the delete, and if the delete is +> refused. Deletion is tidying; recency is the invariant. +> +> The stated reason was wrong too. `discardPaymentSession` and +> `invalidateCurrentPaymentSession` both delete `unpaid` sessions with a storefront token +> and are covered by green end-to-end tests. What the API refuses is deleting a session with +> **transactions attached** — which is why the guard is "took no money", not "is deletable". + +Consequence for the reuse rule above: with only one setting implemented, the _adopt_ branch +is unreachable through the UI, because a reusable session already reads as selected and the +radio ignores a click on the current selection. What is reachable, and covered by tests, is +the retry path — a burnt session does not count as the selection, so clicking again creates +a fresh one. The adopt branch is kept because it becomes live as soon as a second setting +exists. + +### Correction (2026-09-08): only the current selection may be adopted + +The paragraph above ends one step too early. The adopt branch did become live as soon as a +second setting existed, and it was **incompatible with the rule beside it**: the selection +is the newest session, and adopting changes no timestamp. + +So a shopper who picked Adyen, switched to bank transfer, then changed their mind could +never get back. Their first Adyen session was still unpaid, unexpired and the right size, so +it was adopted, so nothing was created, so the newest session stayed the bank transfer one — +and the radio never moved. Clicking again did the same thing again. Found on a real order +carrying two unpaid sessions nine seconds apart, and it was not Adyen-specific: any +`A → B → A` gets stuck on `B`. + +The fix is the first bullet added above: `findReusablePaymentSession` now adopts only the +session that already **is** the selection. Nothing is lost, because both reasons reuse +exists for — a remount and a page reload — are exactly the cases where the candidate is the +selection. Switching back now creates a session that can be selected, at the cost of one +more inert `unpaid` row, which this ADR already accepts as costing nothing. + +Worth naming the shape of the miss, because it is the kind this design invites: two rules +were individually correct and unreachable together, and the ADR said so — it recorded that +the branch was untestable through the UI and would "become live" later, without asking what +it would collide with when it did. A branch that cannot be reached is a branch whose +interaction with everything else has not been thought through. ### Sessions that took no money are deleted; everything else is abandoned @@ -195,7 +237,7 @@ Reading the selection back requires `payment_sessions.payment_authorization` in cannot be told apart from a burnt one. Because selection now round-trips to the API, the radio does not light up on click. A -per-setting pending indicator is required, and it is *not* the selection. +per-setting pending indicator is required, and it is _not_ the selection. An organization on the new model with only unimplemented settings configured gets a checkout with **no payment options and no explanation**. A development-only `console.warn` @@ -203,7 +245,7 @@ fires whenever `` skips a setting; it is not public API and shou removed once the table below is complete. `` must stay mounted even when the order turns out to be on the older -model. It registers the payment-session includes, and that has to happen *before* the order +model. It registers the payment-session includes, and that has to happen _before_ the order is fetched: adding an include afterwards does not trigger a refetch, so a component mounted only once the model is known would never receive its data. Consumers that mount it conditionally will see an order whose `payment_sessions` never expand. @@ -217,7 +259,7 @@ rather than two entries in one list. No `` was needed. **Auto-selecting a single setting is deliberately absent.** `` offers `autoSelectSinglePaymentMethod` and the symmetric prop belongs here, but the obvious -condition is a trap: `` renders only the *implemented* settings, so "one +condition is a trap: `` renders only the _implemented_ settings, so "one entry rendered" is not "one option offered". An organization with five settings configured and one implemented would have its shoppers silently committed to bank transfer while the card options it pays for stay invisible — and that is the situation for every organization @@ -238,14 +280,8 @@ is precisely why neither may act without the shopper. ### Payment Setting implementation status -| Setting | Type literal | Status | -| --- | --- | --- | -| Manual | `payment_setting_manuals` | ✅ implemented | -| Stripe | `payment_setting_stripes` | ⬜ not implemented | -| Adyen | `payment_setting_adyens` | ⬜ not implemented | -| Braintree | `payment_setting_braintrees` | ⬜ not implemented | -| External | `payment_setting_externals` | ⬜ not implemented | -| Gift card | `payment_setting_gift_cards` | ✅ implemented — see `2026-08-20-gift-cards-as-payment-sessions.md` | +Moved to `2026-09-02-adyen-payment-setting.md`, which keeps the single table and names the +ADR behind each row. ### Gift cards @@ -286,5 +322,5 @@ Three official sources were found to contradict `core-api` during the design of above. A fourth, for honesty: during this design `ensure_pending` was assumed from its name to -require a pending order. It does the opposite — it *promotes* a draft order and returns +require a pending order. It does the opposite — it _promotes_ a draft order and returns `true` for every other status (`app/models/order.rb:966-969`). diff --git a/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md b/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md index 406556f0..67640aa6 100644 --- a/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md +++ b/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md @@ -62,7 +62,7 @@ code cannot be applied twice while the first session is still `unpaid` or bound to another market all fail the same lookup and produce `gift_card_code: doesn't match any active gift card` (422, `source.pointer` ending `/gift_card_code`). A duplicate gives `has already been taken`. There is no way to tell the -shopper *which* it was. +shopper _which_ it was. **Once coverage is complete the API gives no clean signal.** Creating another session defaults its amount to zero and fails `numericality: { greater_than: 0 }` — a 422 about @@ -125,7 +125,7 @@ amount (`payment-section.tsx:1082-1106`) and never gates its input on the remain ### Applying or removing invalidates the session paying the difference `amount_cents` is set once and never updatable, so a session created against a different -remainder is not stale but *wrong*: it would still read as the shopper's selection, and at +remainder is not stale but _wrong_: it would still read as the shopper's selection, and at place time we would authorize more than is owed. Both operations therefore delete it, as part of the same domain operation — binding the @@ -149,6 +149,18 @@ token cannot clear the transactions, and only a refund would return the balance iteration does not implement. Its remove control is therefore not rendered at all, rather than rendered and failing. +> **Reopened** by `2026-09-02-adyen-payment-setting.md`. The premise above — "only a refund +> would return the balance, which this iteration does not implement" — is no longer true: the +> Adyen work implements the refund, because a card refused after the gift cards were charged +> needs one. So a charged gift card _can_ now come off the order, by refunding rather than +> deleting, and the control is rendered for it. +> +> Everything else in this section stands. Deleting a charged session is still not an option +> the API offers, so the refunded session is left in place; it lands on `refunded`, and that +> status is what drops it out of the applied list. And the new control is narrower than it +> looks: a storefront token may only refund a gift card while the order is still `pending`, so +> on a placed order there is nothing to offer and the control disappears again. + ### Place: gift cards first, stop at the first failure `placeOrderWithPaymentSessions` takes the order and authorizes the gift cards in sequence, @@ -159,11 +171,30 @@ Gift cards go first as a client-side safety property; nothing server-side enforc authorization shrinks what the next session may take, and a gift card charged after a failed method payment would leave the shopper's balance spent on an order that never got placed. +> **What this ordering is really for (2026-09-09).** Stated as above it reads as a rule about +> recoverability, which is true but secondary. The primary reason it holds is **simplicity**: +> charging the gift cards first is what lets a failure _abort_, and so keeps the library out of +> a partially-paid state — money taken, order unplaced, something still owed — that nothing here +> implements. `canAddGiftCard` says as much in its own comment. +> +> The cost is that "before the money" is a different moment for every gateway, so it has been +> found four separate times. Authorizing the gift cards **last** would collapse those four into +> one and require that intermediate state to be designed instead. That is written up, with the +> risk it moves and the UI questions it opens, in +> `2026-09-09-gift-cards-authorized-last.md` — proposed, not decided. + On a failure partway, it stops and reports. The cards already charged stay charged: carrying on would only charge more for an order that is not going to be placed, and **no rollback is implemented**. The gift card list is itself the recovery surface — after a reload the shopper sees which cards were charged and what is left to pay. +> **Added (2026-09-09): smallest first.** Since nothing is rolled back, the order the cards +> are charged in decides how much is stranded when a later one fails — and a card fails +> because its balance went elsewhere, which is uncorrelated with its size. Charging the small +> ones first therefore minimises what is left behind: with a $5 and a $50 card, a failure on +> the second strands $5 instead of $50. It affects nothing else, because a session's +> `amount_cents` is fixed at creation, not at charge time. A card of unknown size sorts last. + On a **timeout** nothing is touched at all, because the payment may well have succeeded. See `2026-08-18-place-order-split-by-payments-model.md`. @@ -237,11 +268,5 @@ nothing — but it is why every read searches the array rather than indexing it. ### Payment Setting implementation status -| Setting | Type literal | Status | -| --- | --- | --- | -| Manual | `payment_setting_manuals` | ✅ implemented | -| Gift card | `payment_setting_gift_cards` | ✅ implemented | -| Stripe | `payment_setting_stripes` | ⬜ not implemented | -| Adyen | `payment_setting_adyens` | ⬜ not implemented | -| Braintree | `payment_setting_braintrees` | ⬜ not implemented | -| External | `payment_setting_externals` | ⬜ not implemented | +Moved to `2026-09-02-adyen-payment-setting.md`, which keeps the single table and names the +ADR behind each row. diff --git a/docs/adr/2026-09-02-adyen-payment-setting.md b/docs/adr/2026-09-02-adyen-payment-setting.md new file mode 100644 index 00000000..35e7492f --- /dev/null +++ b/docs/adr/2026-09-02-adyen-payment-setting.md @@ -0,0 +1,715 @@ +# Adyen as a Payment Setting: the client-side Drop-in only + +**Date:** 2026-09-02 +**Status:** accepted +**Scope:** `payment_setting_adyens` on the `payment_sessions` model + +## Context + +Adyen is the first Payment Setting that takes a card. Manual and gift card both ship +without a gateway UI: selecting them _is_ the whole interaction, and the money moves at +place time. A card needs a form, an SDK, a 3DS round trip and a gateway that can refuse — +none of which the two shipped settings exercise. + +The binding constraint is that **everything must work in the browser under a +sales-channel or customer token**. This package has no server side, so any design that +needs an integration credential is not a design we can have. That single constraint +decides most of what follows, and it is the reason this ADR diverges from the +`examples-new-payments` playground on the one decision where the playground had a server +available and we do not. + +### In scope + +The **client-side Drop-in**, also called the _sessions flow_: Commerce Layer creates an +Adyen session, the browser hands its id and blob to `AdyenCheckout`, and adyen-web talks +to Adyen directly. Cards only. Including the return from a 3DS redirect, which is not +optional — see below. + +### Out of scope, and why + +- **The advanced flow.** Adyen is called by Commerce Layer, and the 3DS result is relayed + back through `payment_authorizations._payment_details`. It needs an integration token, + because `payment_authorization.response_data` — which carries the action the shopper must + perform — is withheld from sales-channel and customer tokens + (`config/attributes/payment_transaction.yml:104-113`). A component library cannot hold + that credential. +- **Express / wallet payments** (Apple Pay, Google Pay, PayPal as an express button). A + different entry point into the checkout — before an address exists — with its own + interaction to design. Also blocked on reading `public_key` before an order exists; see + _Assumptions and known gaps_. +- **Saved cards through Commerce Layer's `payment_wallets`.** The playground's ADR 0003 + makes `payment_wallets` the source of truth and uses the Drop-in only to enter a fresh + card. That decision was taken while designing the _advanced_ flow, where reuse runs + through `_internal_version: "WalletCvv"` and a server-side relay. In the sessions flow + reuse is entirely Adyen's: the Drop-in posts `storedPaymentMethodId` plus an encrypted + CVC to `/sessions/{id}/payments` and Commerce Layer never sees it. We therefore use + **Adyen's wallet as the source of truth** and ignore the `payment_wallets` Commerce Layer + creates. See _Saving a card_. + +### What the API actually does + +Verified in `core-api` at `40967361d`. None of this is documented, and parts of the SDK +types are wrong (see the existing note in `2026-08-18-payment-session-lifecycle.md`). + +**The Adyen session lives in `payment_session.response_data`.** Creating the session makes +Commerce Layer call Adyen `/sessions`; the response lands in `response_data`, from which the +browser reads Adyen's own field names, `id` and `sessionData`. That attribute is deliberately +readable by sales-channel tokens — `config/attributes/payment_session.yml:115-123` carries no +`prohibited` key and is documented _"used by client"_ — while `payment_session.options` is +`prohibited: [read, write]` on the same resource. + +**Only `return_url` reaches Adyen from `client_data`.** +`app/models/payment/payload/adyen/session/base.rb:10-23` reads `client_data` exactly once: + +```ruby +payload[:returnUrl] = client_data[:return_url] +``` + +Every other key is dropped at session creation. They come back into play in the `/payments` +payload, which the sessions flow never uses. + +**A session's Adyen payload is fixed at creation, and cannot be refreshed.** +`Payment::Session::Adyen` does not override `refresh`, so it inherits +`Payment::Session::Base#refresh; true; end` (`app/models/payment/session/base.rb:33`) — +`PATCH { _refresh: true }` on an Adyen session is a **no-op**. Anything Adyen must know has +to be sent on the `POST`. + +**`expires_at` is Commerce Layer's number, pushed to Adyen.** +`Payment::Session::Adyen::EXPIRATION = 1.day`; `PaymentSession#set_expiration` +(`app/models/payment_session.rb:195-198`) sets `expires_at ||= Time.current + ew`, and +`session/base.rb:21` sends it as `expiresAt`. A client-supplied value wins. Adyen's echoed +`expiresAt` sits unread in `response_data`. + +**Commerce Layer's own `/payments` call fails, by construction, and that is load-bearing.** +In the sessions flow the card data never reaches Commerce Layer, so +`Payment::Payload::Adyen::Payments::Base#payment_data` returns `nil`, `.compact` drops the +key (`app/models/payment/payload/adyen/payments/base.rb:23,37,60-69`), and Adyen answers +`14_006` — _required object 'paymentMethod' is not provided_. The Adyen Ruby client raises +only on `401`/`403`, so nothing is rescued; `Payment::Session::Adyen#authorize!` +(`app/models/payment/session/adyen.rb:68-77`) has **no** status check, unlike its own +`#create` which does `if result.status >= 300`; and the error body carries no `resultCode`, +so `action_by_status` (`app/models/payment/session/base.rb:54-70`) matches no branch and — +having **no `else`** — fires no AASM event. + +**The authorization therefore stays `pending`**, with the 422 in `response_data` and every +timestamp null. It is settled later by Adyen's `AUTHORISATION` webhook, which finds the +session by `merchantReference == payment_session.token` +(`app/models/payment/event_handler/adyen.rb:132-135`) and calls `succeed!` +(`:177-188`). `pending` is a legal source for `succeed`, so it lands. + +**`requires_action` never occurs in this flow.** `action_by_status` is reachable only from +`#authorize!` and `#payment_details`; the first sees `resultCode: nil` and the second is +never invoked, because adyen-web relays the authentication result to Adyen itself. The +authorization goes `pending → succeeded`. + +**A refusal is reported to Commerce Layer, and it burns the authorization.** The same +`AUTHORISATION` webhook with `success: "false"` creates a `failed` `PaymentAuthorization` +on the session (`event_handler/adyen.rb:49-59`, spec-verified at +`spec/models/payment/event_handler/adyen_spec.rb:106-115`). The **session** stays `unpaid`, +because no AASM hook fires on failure — but a later success on the same session takes the +"authorization already exists" branch and calls `succeed!` on a `failed` record, which is +not a legal transition (`app/models/payment_transaction.rb:42-46`), is not silenced +(`whiny_transitions` is at its default) and is not retried (`retry: 0`). The retry's +success would never land. + +**A sales-channel token may refund a gift card, and only that.** +`app/abilities/base_abilities/sales_channel_ability.rb:26`: + +```ruby +can :create, PaymentRefund, payment_session: { payment_type: 'GIFT_CARD', order: { status: Order::STATE_PENDING.to_s } } +``` + +Gift card only, order in `pending` **exactly** — `draft` is excluded. `payment_capture` is a +required relationship, and one always exists because the gift card client hard-codes +`auto_capture?` to `true`. Nothing validates order status beyond that ability, so refunding +during a failed checkout works. It does move the order to `payment_status: refunded` while +`status` is still `pending`. + +**`public_key` is readable, through one request.** +`config/attributes/payment_setting_adyen.yml:26-35` carries neither `prohibited` nor +`confidential`, so it survives the sales-channel filter in +`app/resources/concerns/resource_fields.rb:18-23`; and +`?include=available_payment_settings` serializes per-provider, not as the polymorphic base +(`spec/api/orders_spec.rb:1701-1718`). Listing payment settings is blocked for sales +channels (`app/controllers/api/base_controller.rb:139-142`), so the order include is the +only discoverable path. It is also **more** than the older model gave: on +`payment_gateways`, `public_key` is `fetchable: false` and there is no +`can :read, PaymentGateway` anywhere — the key reached the browser only by delegation onto +a payment source that had to be created first. + +**`public_key` is optional and unvalidated.** `app/models/payment_setting_adyen.rb:10` +validates `api_key`, `merchant_account` and `webhook_endpoint_secret`, not this. A working +server-side Adyen setting can have a null `public_key`. + +**`available_payment_settings` does not filter disabled settings.** +`app/models/concerns/order_payments.rb:169-175` returns `market.payment_settings` with no +`.enabled`, unlike `PaymentMethod.for_jwt(jwt).enabled` on the older model. + +**`auto_place` fires from the session's transition, so the webhook path is covered.** +`app/models/payment_session.rb:30-36` runs `order.place! if auto_place?` in the `authorize` +`after_commit` — whichever route settled the authorization. **`auto_capture` is inert for +Adyen**: it is only ever called from `Payment::Session::Base#authorize!` +(`base.rb:72-92`), and `Payment::Session::Adyen` overrides that method without calling it. +Adyen captures come from the `CAPTURE` webhook, driven by the capture delay in Adyen's +Customer Area. + +**`_internal_version: "Tokenization"` is creatable by a sales-channel token.** +`config/attributes/payment_session.yml:172-181` is `creatable: true` with no `prohibited` +key, and there is an explicit spec for it. It makes +`Payment::Payload::Adyen::Session::Tokenization` inject `shopperReference` (from +`customer.shopper_reference`), `storePaymentMethodMode: 'askForConsent'` and +`recurringProcessingModel: 'CardOnFile'` — but only `next unless c = order.customer`, so a +customer-less order gets none of the three. It is the **only** client-reachable way to get +a `shopperReference` into the Adyen session. + +### What adyen-web v6 actually does + +Verified against `6.42.0`, the version this package installs. + +**The sessions flow owns 3DS completely.** `redirect`, `threeDS2Challenge` and +`threeDS2DeviceFingerprint` are pre-seeded in the component registry +(`core/core.registry.ts:21-26`), and `makePaymentsCall` / +`makeAdditionalDetailsCall` fall through to the session when no `onSubmit` / +`onAdditionalDetails` is given. There is nothing for us to wire, and no +`_payment_details` to relay. + +**Nothing in the library reads the URL.** No `URLSearchParams`, no `location.search`. A +redirect return is resumed by calling `checkout.submitDetails({ details: { redirectResult } })` +— a **`Core`** method (`core/core.ts:164-206`), not a Drop-in one. It returns `void`; the +outcome arrives on `onPaymentCompleted` / `onPaymentFailed`. + +**The session blob is cached in `localStorage`, unreliably.** Key +`adyen-checkout__session`, holding only `{ id, sessionData }`, rehydrated **iff** the +constructor is given an `id` with no `sessionData` and the stored id matches. When +`localStorage` throws — private mode, a sandboxed iframe — the library silently swaps in an +in-memory store, so the blob does not survive navigation and the failure looks like a +generic `NETWORK_ERROR`. The library also never clears the entry. + +**`showPayButton` belongs on the `Core`, not on the `Dropin`.** The Drop-in forwards only +`{ elementRef, isDropin }` to its children (`components/Dropin/elements/createElements.ts:50-62`), +so `new Dropin(checkout, { showPayButton: false })` visibly does nothing. It must be set on +`AdyenCheckout({ … })` or per method under `paymentMethodsConfiguration.card`. + +**`dropin.submit()` throws when nothing is selected** — a plain `Error('No active payment +method.')` — and silently no-ops, showing validation, when the form is invalid +(`components/Dropin/Dropin.tsx:102-119`, `UIElement.tsx:254-271`). `dropin.isValid` is the +guard. + +**A refusal leaves the instance usable but the form destroyed.** `handleFailedResult` +(`UIElement.tsx:479-486`) disables nothing and does not reset the status; `sessionData` is +refreshed even for a refused response, so the session is designed to be re-POSTed. But the +error screen unmounts the card subtree and with it the PCI secured-field iframes, so coming +back gives an empty form whatever route is taken. + +**The two entry points cannot be mixed.** `@adyen/adyen-web` resolves to `dist/es` and is +tree-shakable but requires an explicit `paymentMethodComponents`; `@adyen/adyen-web/auto` +registers everything, is marked side-effectful, and resolves to `dist/es-legacy`. Importing +both puts two copies of the library in the bundle. This package already imports `/auto`, in +`payment_source/AdyenPayment.tsx:3-17`. + +**`environment: 'live'` is enough, and `live_url_prefix` is not used.** v6 has zero +occurrences of it; it talks to `checkoutshopper-{test,live,live-us,live-au,live-apse,live-in,live-nea}.adyen.com` +(`core/Environment/constants.ts:1-10`). The regional variant is not derivable from anything +Commerce Layer exposes. `core/core.ts:90-101` throws synchronously on a `test_`/`live_` key +pointed at the wrong host. + +**There is no session-expiry handling.** `expiresAt` is returned by `/setup` and never read. +An expired session surfaces as a generic `NETWORK_ERROR` and fires **both** `onError` and +`onPaymentFailed`. + +**`enableStoreDetails` leaks past the server.** `components/Card/Card.tsx:82-88` is +`props.session?.configuration?.enableStoreDetails ?? props.enableStoreDetails` — nullish, so +when the session says nothing the client's value decides, and `enableStoreDetails: true` +alone renders the save checkbox and emits `storePaymentMethod`. Compare `installmentOptions` +in the same file, where the session wins hard and warns. The default is `false`, so nothing +bites us, but the asymmetry is worth knowing. + +**`paymentMethodsResponse` takes priority over the session's own list** +(`core/core.ts:391-393`), so stored cards can be _painted_ client-side without a +`shopperReference`. They cannot be charged. Never pass it. + +## Decision + +### The Drop-in charges; `` starts it + +`showPayButton: false` on the `Core`, and `` calls +`dropin.submit()`. + +The alternative — let the Drop-in's own Pay button charge — bypasses the +privacy-and-terms gate, which +`2026-08-18-place-order-split-by-payments-model.md` establishes as _"a legal requirement of +the checkout, not a property of the payment model"_. It also buys nothing: the money and the +placement are separated by an asynchronous callback either way, so the continuation machinery +is needed identically. It would add a second button and remove a legal gate in exchange for +no code saved. + +Payment and placement are therefore two moments, and the second is reachable from **three** +entry points: the Drop-in completing in page, the Drop-in completing after a redirect +return, and a session that already carries an authorization when the page loads. + +**`placeOrderWithPaymentSessions` is not modified.** It already does the right thing: +`needsAuthorization` skips a session that has one, the authorization it creates stays +`pending`, `hasAuthorizationInFlight` makes the loop wait rather than report, and an order +placed by `auto_place` is recognised by the `status === "placed"` branch. `requires_action` +stays out of `IN_FLIGHT_TRANSACTION_STATUSES` (`payment_sessions/types.ts:78-85`) because +this flow never reaches it. + +### The gateway handoff is a store, and it is gateway-neutral + +`` and `` are siblings in a checkout, not +parent and child, so context cannot carry the call. The handoff is an external store read +through `useSyncExternalStore` and keyed by order id — the idiom this model **already chose** +for the same problem: terms acceptance travels through `utils/termsAcceptanceStore.ts` and +`hooks/useTermsAndConditions.ts:32-56`, not through context, for exactly this reason. +`PlaceOrderContext` stays exclusive to the `payment_source` model. + +A gateway registers `{ submit, isReady }` plus the redirect `resumePhase`. `submit()` resolves +— never rejects — with one of **four** outcomes, because `dropin.submit()` returns `void` and +every result arrives by callback: + +- **`completed`** — money taken, run the place sequence. +- **`incomplete`** — the form is empty or invalid. `dropin.submit()` shows Adyen's own + validation and settles nothing, so without this the caller would wait forever. Nothing to + report: this is a stop, not a failure. +- **`failed`** — a verdict, carrying Adyen's `resultCode`. No money moved on the card, so the + burnt session can be deleted. Note that "a rollback would be safe" is not the same as "a + rollback is wanted": nothing is given back here, for the reasons below. +- **`unknown`** — a network failure, an expired Adyen Session, an SDK error. Emerged while + writing the code: `onError` and `onPaymentFailed` are different events, and collapsing them + would have made the rollback unsafe. **The payment may have gone through**, so nothing is + refunded and nothing is deleted — refunding could take back money for a card that did + charge, and the Payment Session is the record Adyen's webhook settles against. This is + `placeOrderWithPaymentSessions`'s `timedOut` reasoning, one step earlier. + +The contract is **neutral** — "if a gateway has registered a handoff for this order, await +it" — not because Stripe is next, but because it keeps the button shallow. A button that +knew about setting types and about the Adyen component's shape would be deeper than it needs +to be, which is precisely how `PlaceOrderButtonPaymentSource` reached 598 lines. + +### Gift cards are authorized before the submit + +The place handler authorizes the gift cards, then calls `submit()`, then calls +`placeOrderWithPaymentSessions` — which skips the gift cards it finds already authorized. + +This preserves the charge order that `2026-08-20-gift-cards-as-payment-sessions.md` +established, and it is only possible because we own the submit: if Adyen's own button started +the charge, that moment would not be ours. + +**The order is refetched between the two.** `placeOrderWithPaymentSessions` skips a session +that already carries a live authorization by reading the order it was _handed_, so passing the +pre-authorization copy on would authorize the same cards again and take the money twice. The +refetch is not a refresh for the screen's benefit; it is what makes that skip work. It also keeps the property that makes the +flow forgiving — a gift card is removable for free right up to the point the shopper commits. + +The exposure it creates is real: a refused card leaves gift cards charged. What it is **not** +is a reason to give them back automatically — see below, where trying that is what taught us +otherwise. + +> **Qualified (2026-09-09).** This paragraph used to end by ruling out the reverse order +> outright: it would trade a **common** failure for a **rare** but **unrecoverable** one — a card +> charged for the remainder, the gift cards unpaid, `canAddGiftCard` already false and no way +> out. Every clause of that is still true _as long as there is no way to settle the residual_, +> which is the assumption it was resting on. +> +> Remove that assumption — offer the shopper the outstanding amount to pay by another means — and +> the reverse order becomes a live option that also deletes the four per-gateway places where +> gift cards are charged today, this one included. It is not adopted, and adopting it needs an +> intermediate state designed rather than avoided. See +> `2026-09-09-gift-cards-authorized-last.md`. + +### A refused payment burns the Commerce Layer session + +Delete the `payment_session` best-effort and never retry on it. **Nothing is created in its +place.** + +Retrying in place walks into the AASM transition described above, and the timing is not +observable from the browser: immediately after the refusal the `failed` authorization has not +yet arrived, so the session still reads as the current selection and as reusable. Deleting is +deterministic where waiting is not. + +The two mechanisms cover the same hole from opposite sides. If the delete succeeds, the order +is clean and reuse cannot find it. If it fails — because the `failed` authorization landed and +`dependent: :restrict_with_exception` blocks it — then `findCurrentPaymentSession` and +`findReusablePaymentSession` exclude it anyway, both already rejecting a terminal-failure +authorization. Failures are swallowed, following `invalidateCurrentPaymentSession`. + +Local state saying "this session is burnt" was rejected: it is a second notion of a valid +session living in the browser, which the lifecycle ADR has already turned down once. + +**The delete belongs to ``, not to the gateway component**, and the first +implementation had it the other way round. Two reasons, both found by building it: + +Re-selecting the setting to get a fresh session does not work from inside the gateway +component's failure handler: `selectSetting` reuses before creating, and it reads the order +held in context — which still contains the session just deleted. It would adopt it, handing the +shopper back the same burnt Adyen Session. + +So the shopper re-picks the payment method after a refusal. That is one extra click, and it is +also the moment they see what the failed attempt left behind. + +**The gift cards are not given back, on any path.** This replaced an automatic refund, and +the reversal is the most important thing this ADR records — because the automatic version +shipped, was exercised against a real 3DS failure, and was wrong. + +What it did: on a refusal the button refunded every gift card it had authorized in that +attempt. The reasoning was that a refusal is a verdict, so the rollback is safe. It is safe, +and it is still wrong, because **a refused card is not the end of a checkout — it is the +ordinary middle of one.** The shopper tries another card. Taking their credit back at that +moment destroys exactly what the next attempt needs, and they cannot simply re-apply it: +`canAddGiftCard` is false while anything is authorized, and the codes would have to be typed +again from a screen that no longer shows them. + +The observed run, on order `qkykhrppJk`: two gift cards worth $18 charged, the 3DS password +failed, both refunded 28 seconds later, the shopper re-picked Adyen and succeeded on the second +attempt — and ended with $53 on their card, no gift cards, and $18 outstanding on an order that +would not accept a gift card any more. Every step behaved as designed. + +On the redirect path the automation could not even be attributed: the cards were charged on a +previous page load, and which of them _this_ attempt authorized went with it. So there was +never going to be one rule for both paths. + +**The shopper gets a control instead.** `` +now renders for a charged card and refunds it, where before it rendered nothing at all — see +the reopening note in the gift card ADR. That is deliberately a control rather than more +automation: on the redirect path the library genuinely cannot tell which cards this attempt +charged, but the shopper knows they want their money back, and a control puts that judgement +where the knowledge is. The rule is `giftCardRemoval` in `core-components`, which answers +`discard`, `refund`, or nothing. + +Two limits are worth stating because they are not obvious from the control: + +- **A refund needs the order to be `pending`.** The grant names that status exactly, so after + placement there is nothing to offer — and that is precisely the timed-out place, where the + cards are charged and the shopper most wants them back. A storefront cannot give it to them. +- **A card is not removable while its charge is settling.** Between the authorization and the + capture, the delete is refused (transactions attached) and the refund has no capture to point + at. It lasts seconds and resolves itself, so it reads the same as "cannot" rather than getting + a state of its own: a control that appears, fails, then works is worse than one that appears + a moment late. + +### The redirect return is resumed headlessly, and the library places the order + +The redirect is **not optional**. `nativeThreeDS: 'preferred'` is hard-coded server-side for +every Adyen payment, so the variant is the issuer's choice, not ours: a card not enrolled for +native 3DS2 redirects whatever we configure. Restricting the offered methods does not avoid it. + +Resuming needs no DOM. `submitDetails` is a `Core` method, so the resume is + +``` +AdyenCheckout({ clientKey, environment, session: { id, sessionData }, onPaymentCompleted, … }) +checkout.submitDetails({ details: { redirectResult } }) +``` + +with no container, no mount and no UI. It therefore lives in an internal hook called by +``, which the lifecycle ADR **already requires** to stay mounted — so the +resume cannot be lost to an application's decision about which step to render. Putting it in +`` would make it depend on that decision, and an accordion that +renders the payment step collapsed after a reload would leave a charged card on an unplaced +order. That is how the playground's redirect breaks, by a different route. + +`{ id, sessionData }` come from `order.payment_sessions[].response_data`, not from the +`sessionId` query parameter. The order is the source of truth, and it is the only version +that survives a different browser, cleared storage or private mode — where adyen-web's +`localStorage` cache silently is not there. + +`redirectResult` is single-use, so the resume is latched by a ref and the URL is cleaned with +`history.replaceState`. + +**In this one path the library places the order without a click, and skips the terms gate.** +Terms acceptance does not survive the navigation, and requiring it again would leave anyone +who declines with a paid, unplaced order. The reasoning that makes this defensible is that +acceptance already happened _before_ the redirect — without it the place button was not +clickable. The library exposes `isResumingRedirect` so an application can render the checkbox +as accepted and disabled and the button as pending; that presentation is the application's, +per `2026-09-01-presentation-belongs-to-the-application.md`. + +### Saving a card uses Adyen's wallet, not Commerce Layer's + +Send `_internal_version: "Tokenization"` when the token is an authenticated customer's, and +let the Drop-in render its own native save checkbox and its own saved cards. + +The gate is `!isGuestToken(accessToken)` (`utils/isGuestToken.ts`), **not** +`order.customer != null`. Commerce Layer puts a customer on nearly every order that has an +email, and `Customer#shopper_reference` falls back to the email — so gating on the order +would store a token against a guest's email and show that card, with its last four digits and +expiry, to the next visitor who types the same address. There is precedent for the token gate +in this repository: `reducers/PlaceOrderReducer.ts:257-263` gates +`_save_payment_source_to_customer_wallet` the same way. + +Commerce Layer still creates a `payment_wallet` server-side from Adyen's +`RECURRING_CONTRACT` webhook. **We do not read it.** An organization that does not want the +records disables that webhook — note it is `RECURRING_CONTRACT`, a standard notification, not +Adyen's separate Tokenization webhook (`recurring.token.created`), which the playground's ADR +0003 says must not be enabled at all because Commerce Layer 500s on it. Not verified by us. + +No remove control is rendered. `onDisableStoredPaymentMethod` needs an Adyen API key we do not +have, and `showRemovePaymentMethodButton` is `false` by default. The gap is missing +credentials, not a choice. + +### Setting-type-specific create attributes live in a table + +`client_data.return_url` and `_internal_version` must be on the `POST` — the Adyen session is +built there and `_refresh` is inert — and the `POST` is made by ``, which +today knows nothing about any gateway. + +A declarative table beside `IMPLEMENTED_SETTING_TYPES` maps a setting type to the extra +attributes its creation needs. Not an `if` — `` already carries one branch for +gift cards and one for unimplemented types, and a third would start a pattern. Not moving +creation into `` either: that breaks the invariant the whole model +rests on, that **the selection is the session**. With creation deferred to a child, nothing +exists for `findCurrentPaymentSession` to read, the radio does not light up, and a reload loses +the choice. + +The return URL is **built, not copied**: origin plus pathname, the query preserved minus +`redirectResult` and `sessionId`, the fragment dropped. A raw `window.location.href` bakes a +previous attempt's `redirectResult` into the next session, and a checkout using a fragment +would have Adyen append its query _after_ the `#`. There is no prop: the value is computed +where the session is created, and a prop there would sit on a generic component. + +### What the shopper is told + +`{ code: resultCode }` and no message. + +`onPaymentFailed` gives only `resultCode` — `Refused`, `Cancelled`, `Error`. `refusalReason` +does not exist in this API, and `payment_authorization.response_data` is withheld from our +tokens. There is nothing else. Writing copy here would put payment wording, in one hard-coded +language, in a package that cannot know the checkout's locale — the problem mfe-checkout +already works around by passing `label` to the gift card buttons. `resultCode` _is_ a code, it +comes from Adyen, and the application maps it. + +`disableFinalAnimation: true`, because the session is recreated on a refusal and Adyen's error +screen would only flash before the remount. + +### Configuration surface + +Flat props on ``, not a config object: +`environment?`, `locale?`, `containerClassName?`, and `children?` as a function receiving +`{ isReady, isSubmitting, isResumingRedirect, errors }`. The legacy `AdyenPaymentConfig` — +eleven keys, one already `@deprecated`, three callbacks — is what +`2026-09-01-presentation-belongs-to-the-application.md` stopped doing. + +- **`clientKey`** is not a prop. It is `setting.public_key`, from the + `available_payment_settings` include this library already registers for every consumer + (`hooks/useOrderState.ts:129-146`). +- **`environment`** defaults to `test` for a `test_`-prefixed key and `live` otherwise, and + the prop exists for the regional live endpoints, which nothing in the API can tell us. Note + the divergence: `payment_gateways/AdyenGateway.tsx:70` derives it from the JWT `test` claim + instead. The key prefix is the better source — it is the value that must match the host, and + adyen-web throws on a mismatch — but the two Adyen components in this package now disagree, + deliberately. +- **`locale`** is exposed with its constraint documented: adyen-web builds `i18n` once and + ignores later updates, so changing it on a mounted Drop-in requires a `key` that remounts. + mfe-checkout does not need this (its language is fixed at load), a custom checkout might. +- **`@adyen/adyen-web/auto`**, matching the legacy component, and `allowPaymentMethods: +["scheme"]` on the `Core`. With `/auto` everything is registered, and + `paymentMethodComponents` only _adds_, so `allowPaymentMethods` is how one restricts. + Restricting is not about the bundle. + > **Corrected 2026-09-07** by `2026-09-07-paypal-through-adyen.md`. This said the wallets + > "render their own pay buttons and submit themselves, which would bypass + > `` and the terms gate". Half right, and the operative half wrong. + > `showPayButton: false` — which this integration sets on the `Core` — does not hide those + > buttons: `Paypal.componentToRender()` returns `null` when it is false + > (`src/components/PayPal/Paypal.tsx:243`), and that is the only thing `UIElement.render()` + > renders. The SDK script is never downloaded. Apple Pay, Google Pay and Amazon Pay do the + > same, while Card degrades gracefully. So allowing a wallet here would have shipped **an + > empty accordion panel**, not a bypassed gate. The restriction was right; its reason was + > not. And the wallets are not alike: Apple Pay's and Google Pay's own buttons call + > `this.submit`, so a host button _can_ drive them — PayPal's `submit` throws by design, and + > it is the only one that categorically cannot. +- **The component renders its own mount target, and `children` renders after it.** Everywhere + else in this library a function child _replaces_ the default markup. Here it cannot: the + Drop-in attaches to that element, so handing it to a render prop would let an application + that forgot to render it produce a payment form that silently never appears. `children` is + for the chrome around it. Found by writing the mfe-checkout side — which is the ADR on + presentation earning its keep a second time. +- **`adyen.css` is imported by the application**, not by the package. 138 KB is not a cost to + impose on every consumer that does not use Adyen, and the import is fully manual — no file + in the package pulls it in. Theming needs nothing from us either: all 136 `--adyen-sdk-*` + tokens are `var(name, fallback)` with no `:root` block, so an application scopes a theme by + declaring them on a wrapper. + +### Placeability attempts are per-gateway + +The global defaults stay as they are — `DEFAULT_PLACEABLE_ATTEMPTS = 8` at +`DEFAULT_PLACEABLE_INTERVAL_MS = 500` — because they are right for manual and gift card, whose +authorization is a local Sidekiq job. The Adyen branch passes its own, longer and more spaced: +the wait is a **webhook round trip from Adyen**. Four seconds is not it. + +Exhausting them is still **not** a payment failure. The webhook may arrive a moment later, and +what to show then is the question the place-order ADR leaves open. + +### Two fixes to `` that are not about Adyen + +Both are pre-existing, both live in the lines this work already touches. + +- **Settings with `disabled_at` are filtered out.** `available_payment_settings` does not do + it, and `` did not either, so a disabled gateway was still offered. +- **An Adyen setting with no `public_key` is skipped**, with the same development-only + `console.warn` used for unimplemented types, saying why. `public_key` is optional and + unvalidated server-side, so this is reachable on a working organization. The lifecycle ADR's + reasoning applies unchanged: a radio button that does nothing when clicked is worse for the + shopper than no radio button. From the shopper's side the case is indistinguishable from an + unimplemented setting — the option cannot be used. + +## Considered options + +- **Let the Drop-in's Pay button charge, and force the place button afterwards** — the legacy + `placeOrderButtonRef.current.click()` with `disabled = false`. Rejected: bypasses the terms + gate, needs the same continuation machinery anyway, and re-imports an escape hatch the new + model was split to avoid. +- **Require another click after a redirect return.** Rejected: the card is already charged, so + anyone who does not re-accept the terms is left with a paid, unplaced order. +- **Resume the redirect from `sessionId` in the query string**, as Adyen's own documentation + assumes. Rejected in favour of the order, which survives a different browser and private + mode. adyen-web's `localStorage` cache remains the fallback if the stored blob turns out to + be required. +- **Retry a refused payment on the same session** (`dropin.setStatus('ready')`). Rejected: the + `succeed!`-from-`failed` transition means the retry's success never lands. +- **Track burnt sessions in local state.** Rejected: a second notion of session validity in the + browser. +- **Commerce Layer's `payment_wallets` as the source of truth for saved cards**, as the + playground's ADR 0003 decided. Rejected _for this flow_: reuse there runs through the + advanced flow and an integration token. Not a contradiction of that ADR so much as a + different flow with a different constraint. +- **Gate `Tokenization` on `order.customer`.** Rejected: shows a saved card to anyone who types + a known email address. +- **The tree-shakable `@adyen/adyen-web` entry point with `paymentMethodComponents: [Card]`.** + Rejected: the legacy component imports `/auto`, and mixing entry points ships two copies of + adyen-web. +- **Gate the place button on `dropin.isValid`.** Rejected: validity changes on every keystroke, + and subscribing across the seam would re-render the button on each character. A disabled + button with no explanation is also worse than a form that shows its own validation. `isReady` + is exposed so an application that wants the behaviour can build it. +- **A `config` object prop, mirroring `PaymentMethodConfig`.** Rejected by the presentation ADR. +- **Restricting the Drop-in to avoid the redirect.** Impossible — the redirect is the issuer's + choice. + +## Consequences + +**`placeOrderWithPaymentSessions` and `payment_sessions/types.ts` are unchanged.** The core +domain layer needed nothing for the first card gateway. That is the strongest evidence the +place-order split was cut in the right place. + +**A refused card leaves the gift cards charged, and that is the intended state.** They are +still applied, still counted, and still paying for the retry. If the shopper gives up instead, +each card carries its own remove control, which refunds it — and a refund raises the remainder +by itself, because the session lands on `refunded`. + +**A refused card costs the shopper their typed card details, and their payment-method +selection.** Adyen's error screen unmounts the PCI secured-field iframes, so the form is empty +on every route back — including "retry the same card" — and deleting the burnt Payment Session +leaves the radio group with nothing selected. + +**Two derivations were asking half a question, and `holdsMoney` is the answer to the whole +one.** `hasLiveAuthorization` says only that an authorization exists and did not fail — and it +stays true after a refund, because a refund changes the _session_, not the authorization, which +keeps `succeeded` forever. So `canAddGiftCard` went false for good once any card had been +charged and given back, and `` renders on it: the shopper whose +card had just been refunded could never apply another one. The remainder had the same flaw for +method sessions. Both now ask `holdsMoney`, which is the two halves conjoined and named, so the +next question of the form "is this still paying for the order?" has one thing to call. + +**`isLiveGiftCard` was checking a relationship nobody includes.** It hid a refunded gift card +by reading `payment_refunds`, which needs `payment_sessions.payment_refunds` in the order's +`include` — and nothing registers it, nor does `ResourceIncluded` permit it. So the check never +fired against a real order: a refunded card went on being listed and on being deducted from the +remainder, which would have shown a coverage the order did not have, sized the next session +against the wrong amount, and kept the place-order button live on the strength of it. It now +reads the session's `status`, a plain attribute that is always served. The spec that covered the +old behaviour was green throughout, because its fixture described a state the API never sends; +it is now a regression guard against reintroducing the array check. + +**Deleting the burnt session can surface an older one as the selection.** +`findCurrentPaymentSession` takes the most recent live non-gift-card session, so a shopper who +tried bank transfer earlier on the same order sees that option selected again after a card +refusal. Not wrong — it _is_ their most recent surviving choice, exactly as the lifecycle ADR +defines it — but surprising, and it is the visible cost of not recreating the session. + +**The redirect path ships without an end-to-end test.** `nativeThreeDS: 'preferred'` is +hard-coded server-side, so the variant cannot be provoked on demand; it happens only when the +card is not enrolled. Coverage is unit-level, reusing the `@adyen/adyen-web` mock idiom already +in `specs/payment_source/AdyenPayment.spec.tsx` — a `vi.hoisted` capture object and a +`FakeDropin` exposing `mount`/`submit`/`remove`/`handleAction`, which lets a test invoke the +handlers the component installed. This is not an oversight; it is the consequence of a +server-side default we cannot override. + +**Two Adyen components in one package derive `environment` differently.** Deliberate, recorded +above, and it should converge when the legacy component is eventually retired. + +**A consumer with a `fields[payment_sessions]` sparse fieldset breaks the Drop-in.** The Adyen +session is read from `payment_session.response_data`; an allowlist that omits it produces a +session the Drop-in cannot boot from. mfe-checkout restricts only `fields[orders]` and four +other types, so it is unaffected. + +**Adyen locks a `clientKey` to authorized origins, matched on scheme, host and port**, and a +rejected origin is indistinguishable from a network failure — validation is server-side at +Adyen, and adyen-web surfaces a CORS block as `NETWORK_ERROR`. Local development against a real +organization needs the origin registered in Adyen's Customer Area. The playground documents +spoofing a production domain over HTTPS on 443 for exactly this reason. + +**Nothing handles an expired Adyen session.** `expires_at` is a day, `_refresh` is inert, and +adyen-web never reads `expiresAt` — so a checkout left open past the window fails as a generic +network error. `findReusablePaymentSession` already excludes expired sessions, so re-selecting +produces a fresh one; what is missing is telling the shopper why. + +### Assumptions this design rests on + +Listed in order of what they would cost if wrong. + +1. **Adyen accepts the initial `sessionData` after `/payments` has rotated it.** This is the + pivot of the redirect resume, inferred from Adyen's own documentation telling integrators to + re-instantiate with the values their server returned. If it is rejected, the fallback is to + pass the `id` alone and let adyen-web rehydrate from `localStorage` — which is silently + unavailable in private mode and from another browser. **Verify against the real gateway.** +2. ~~**Correctness depends on a missing `else` in `core-api`.**~~ **Retired 2026-09-07.** The + worry was that the authorization stayed `pending` only because `action_by_status` had no + default branch and `#authorize!` lacked the `status >= 300` check its sibling `#create` had — + so "fixing" that asymmetry would kill every Drop-in payment. `core-api` `2c1145339` did add + the check, and added a guard in the same commit: + `skip_authorize?` returns true when the session has neither a `payment_wallet` nor a + `client_data['payment_method']`, which is exactly our case, so **no `/payments` call is made + at all**. Same outcome, reached without talking to Adyen — and now pinned by an upstream + spec, _"does not call Adyen and leaves the authorization pending, waiting for the webhook"_. + The ask this entry made has been answered. + + **In its place, a tripwire.** `client_data.payment_method` is `creatable`/`updatable` for a + sales-channel token with no `prohibited` key. Writing it — to record which method the shopper + picked, which is precisely what one wants once the Drop-in offers more than cards — flips + `skip_authorize?` to `false`. Commerce Layer then calls `/payments` with it, Adyen answers + 422, the new `apply_response` puts the authorization in **`failed`**, `succeed` from there is + illegal, and `509bbb9a1` now takes the session to the new `invalidated` state, which blocks + further transactions. **Unrecoverable, and silent. Never write `payment_method` into + `client_data`.** + +3. **That `public_key` is served to sales-channel tokens is not spec-covered in `core-api`** — + the `payment_setting_adyen` factory does not even set it. The attribute config and the read + filter both say yes, and the playground reads it from a browser under a storefront token, but + the guarantee the whole integration rests on is untested upstream. +4. **Disabling `RECURRING_CONTRACT` is how an organization avoids the `payment_wallets` + records.** Taken as given, not verified. It is a standard notification an organization may + rely on for other things. +5. **`resultCode: "Pending"` and `"Received"` are unreachable with cards only.** They map to + Commerce Layer's `require_action` and `process` states, and `Pending` is in + `ACTION_STATES[:require_action]` without a matching entry in `NEXT_ACTION_TYPES` — a + `requires_action` authorization with `next_action_type: nil`. Restricting to `scheme` keeps + us out of it; adding iDEAL or Klarna later walks into it. + +### Payment Setting implementation status + +Single source; the tables in `2026-08-18-payment-session-lifecycle.md` and +`2026-08-20-gift-cards-as-payment-sessions.md` point here. + +| Setting | Type literal | Status | +| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Manual | `payment_setting_manuals` | ✅ implemented — `2026-08-18-payment-session-lifecycle.md` | +| Gift card | `payment_setting_gift_cards` | ✅ implemented — `2026-08-20-gift-cards-as-payment-sessions.md` | +| Adyen | `payment_setting_adyens` | ✅ implemented — client-side Drop-in, cards only, this ADR. Methods within it: `2026-09-07-paypal-through-adyen.md` | +| Stripe | `payment_setting_stripes` | ⬜ not implemented | +| Braintree | `payment_setting_braintrees` | ⬜ not implemented | +| External | `payment_setting_externals` | ⬜ not implemented | + +Deferred, each needing its own design: the Adyen advanced flow, express/wallet payments, saved +cards through Commerce Layer's `payment_wallets`, settling a partially-paid order, and +`autoSelectSinglePaymentSetting` — whose condition the lifecycle ADR works out but leaves +unwritten until the rendered list and the real one converge. With three of six settings +implemented, they have not yet. diff --git a/docs/adr/2026-09-07-apple-pay-through-adyen.md b/docs/adr/2026-09-07-apple-pay-through-adyen.md new file mode 100644 index 00000000..287efd2b --- /dev/null +++ b/docs/adr/2026-09-07-apple-pay-through-adyen.md @@ -0,0 +1,313 @@ +# Apple Pay through Adyen: the same wallet, asked for rather than offered + +**Date:** 2026-09-07 +**Status:** accepted, verified by hand on Safari (2026-09-08) +**Scope:** Apple Pay offered inside the Adyen Drop-in, on the `payment_sessions` model + +## Context + +`2026-09-07-google-pay-through-adyen.md` said Apple Pay was deferred because it is unverifiable +in this stack: `isAvailable()` rejects unless the document is `https:`, and it needs Safari with +a card in Wallet, which Playwright's WebKit is not and does not have. That has not changed. + +What changed is the domain. `checkout.gciotola.commercelayer.dev`, served through ngrok, is +already registered for Apple Pay on the Adyen merchant account, so a human can run it on Safari +even though a test cannot. Deliberately **no e2e** — there is nothing Playwright could assert. + +`examples-new-payments` was read for what applies. Its Drop-in +(`src/components/adyen-payment-form.tsx`) turns out to say nothing about this problem: it never +sets `showPayButton: false`, so every method self-submits and there is no place button and no +terms gate to reconcile. Its useful findings are in `docs/express-payment-gaps.md`, and belong +to the **advanced** flow, where there is no session to source configuration from — hence +`CL_APPLE_PAY_MERCHANT_ID` / `_MERCHANT_NAME` env vars, without which the component throws on +`configuration.merchantName` or Adyen answers `702 Required field 'merchantIdentifier' is null`. +None of that applies here, and the session response proves it: `applepay.configuration` arrives +as `{ merchantId, merchantName }` already. + +## Decision + +### Apple Pay is Google Pay, and the code says so + +`ApplePay.js` and `GooglePay.js` are the same shape in every respect that matters: + +| | Apple Pay | Google Pay | +| ------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Own button | `componentToRender` gated on `showPayButton`, `onClick: this.submit` | identical | +| Click | `new Promise((resolve, reject) => onClick(resolve, reject)).then(() => session.begin())` | `…then(showGooglePayPaymentSheet)` | +| Before the money | `handleAuthorization().then(makePaymentsCall)` | identical | +| A rejected `onAuthorized` | `STATUS_FAILURE` + `handleFailedResult` | `transactionState: "ERROR"` + `handleFailedResult` | + +So one `walletConfiguration()` builds both, and Apple Pay was added by calling it a second time. +The gate stays on the click and synchronous, because `session.begin()` waits on it inside the +gesture; the gift cards stay on `onAuthorized`; and the self-abort flag that stops a rejected +`onAuthorized` being read as a refusal was already there and needed nothing. + +`countryCode` and `amount` are not passed either: `Core#initializeCore` merges them from the +session setup response, which is where Apple Pay's request reads them from. + +### A rejection is an `ApplePayError`, not a string + +The one thing the wallets do not share. Adyen types Google Pay's `reject` as +`(error?: PaymentDataError | string)` and Apple Pay's as `(error?: ApplePayJS.ApplePayError)`, and +`completePayment` shows Apple's own generic wording for anything else. So `walletConfiguration` +is generic over the error type and each method supplies its own constructor — +`new ApplePayError("unknown", undefined, message)`, `"unknown"` being the only code that is not +about a contact field. + +The constructor is looked up on `globalThis` when needed rather than at module load: Safari-only, +absent from the DOM lib, and a module-scope read would fix the answer before hydration. Where the +global is missing we reject with `undefined` rather than the string — unreachable in practice, +since no `ApplePayError` means no Apple Pay button was ever rendered, and handing Apple a string +it discards would be worse than handing it nothing. + +Whether Safari renders our message or its own wording for `"unknown"` is not something the DOM +contract promises. This is written to be correct, not to guarantee the copy, and it is one of the +things the live run should look at. + +### Apple Pay is opt-in — the one method that is + +Cards, PayPal and Google Pay are in the defaults, because for those being offered is evidence +they can work: each either works or filters itself out, through `isReadyToPay()`, funding +eligibility, or `isAvailable()`. + +Apple Pay does not have that property. Its availability check asks only whether the browser and +device can pay at all. Whether _this domain_ is registered for Apple Pay on the merchant account +is settled later, at merchant validation, **after the shopper has tapped** — so on an +unregistered domain the button renders and then fails. The registration is out-of-band (Adyen's +Management API `addApplePayDomains`, plus `.well-known/apple-developer-merchantid-domain-association` +served publicly at that exact origin) and nothing in a browser can detect it. + +So `"apple_pay"` must be asked for. This is a narrow exception to "offer everything that has been +built", and it is the safe direction: a consumer who has done the registration adds one array +entry, while one who has not is never handed a control that cannot work. `mfe-checkout` opts in, +with a comment saying that doing so is a claim about its domains. + +Note this is separate from the Client Key's **Allowed origins**, which the same origin must also +be in or the `checkoutshopper…/applePay/sessions` preflight is CORS-blocked. Two settings, two +failure modes, and only the second looks like a network error. + +## Consequences + +**Nothing is covered by a test that runs.** Fifteen unit specs cover the configuration, the gate, +the gift-card moment, the `ApplePayError` dressing and the self-abort — all against a fake +Drop-in. Everything past the tap is a human on a device. + +**And that device is an iPhone, not the Mac.** Adyen's TEST environment cannot process a real +card, so the card stage needs one of Apple's published sandbox test cards, and those can only be +added to Wallet by a **sandbox tester** Apple Account — created in App Store Connect, which means +the Apple Developer Program is genuinely required for this part, if not for the domain. Apple's +instructions are to _"sign out of iCloud and sign into your test device with your sandbox tester +account"_, and its list of supported sandbox devices is iPhone, iPad and Apple Watch — the Mac is +not on it. + +So the least invasive route is to put the sandbox tester on a phone, add a test card there by +manual entry, and open the tunnelled checkout URL in **Safari on that phone**. The Mac's iCloud +stays untouched, and the code path is identical: same Drop-in, same gate on the click, same gift +cards on `onAuthorized`. The device's region has to be one where Apple Pay is available. + +**The live run should confirm, in this order:** the row appears in Safari on the registered +domain; tapping opens the sheet (the gesture survived the gate); on-device auth completes and the +order places; and a gift card that cannot be charged shows something legible in the sheet rather +than Apple's generic failure. + +**Three methods now share one code path and one bug surface.** That is the point, and it also +means a regression in `walletConfiguration` breaks Apple Pay silently, since only two of the +three are tested end to end. + +**Klarna remains the open one**, blocked on `Session::Base` carrying `lineItems` and +`shopperEmail` — see ask 1 in the PayPal ADR. + +## What the first Safari run established (2026-09-07) + +**The sheet opens.** Which was the one thing about this design that could have been wrong: the +gate on the click is synchronous, `session.begin()` waits on it, and Safari accepted the gesture. +The wallet pattern therefore holds for both wallets, and the Google Pay e2e that asserts the same +property is guarding something real. + +**Then it closes immediately with `ApplePay - Something went wrong on ApplePayService`.** That +string has exactly one origin in `adyen-web`, and it is worth writing down because the message +names the wrong thing entirely. From `ApplePayService.js`: + +```js +onvalidatemerchant(event, onValidateMerchant) { + return new Promise((resolve, reject) => onValidateMerchant(resolve, reject, event.validationURL)) + .then(data => this.session.completeMerchantValidation(data)) + .catch(e => { console.error(e); this.session.abort(); this.options.onError(e) }) +} +``` + +`options.onError` is reached from nowhere else — every other handler catches into its own +`complete*` call. So the message means **merchant validation failed**, and nothing else can +produce it. + +Which rules out the shopper's card and their Apple account: `onvalidatemerchant` fires when the +sheet opens, before any card has been chosen. An Apple _sandbox tester_ account would change +nothing here, and Adyen's test environment accepts a real card in Wallet anyway. The first +instinct — "I am signed in with my personal Apple ID" — is a reasonable read of the symptom and +not the cause. + +What actually runs is `ApplePay#validateMerchant`, a POST to +`checkoutshopper-test.adyen.com/checkoutshopper/v1/applePay/sessions?clientKey=…` carrying +`{ displayName: configuration.merchantName, domainName: window.location.hostname, initiative: "web", +merchantIdentifier: configuration.merchantId }` — all four from the session, none of them ours. +So the two things it can be about are the **origin** and the **merchant account**, which is why +they are two separate prerequisites in the decision above. + +**The diagnosis path, for next time.** `console.error(e)` runs immediately before the message, so +the real cause is the line _above_ it in Safari's console, and the Network tab has the +`v1/applePay/sessions` request with its status and body. A request that never gets a response is +the Client Key's Allowed origins; a response saying no is the Apple Pay domain registration on +that merchant account. + +`domainName` is overridable by a prop on Adyen's component. We do not expose it, and should not +until something needs the served hostname and the registered one to differ. + +### What the validation actually answered + +```json +{ + "status": 422, + "errorCode": "000", + "errorType": "validation", + "message": "Payment Services Exception pspId=2EA0… unauthorized to process transactions on behalf of merchantId=000000000326725 reason=000000000326725 is not a registered merchant in WWDR and isn't properly authorized via Mass Enablement, either." +} +``` + +A response arrived, which settles the first prerequisite: the origin **is** in the Client Key's +Allowed origins, or the preflight would have been CORS-blocked and there would be nothing to read. + +The message names both routes into Apple and says neither is open for this merchant account. +`WWDR` is Apple's Worldwide Developer Relations CA, behind Apple Pay certificates — that is the +_own certificate_ route. _Mass Enablement_ is Apple's programme letting a PSP enable Apple Pay for +its sub-merchants at scale — the _Adyen's certificate_ route. So Apple Pay exists on the Adyen +merchant account, which is why `/setup` advertises it with a configuration, and the Apple side of +it was never completed. + +The shape of the identifier says which route the account is on: a merchant identifier you create +yourself is reverse-DNS (`merchant.io.commercelayer.…`), and `000000000326725` is numeric, i.e. +Adyen's own. So the account is on Adyen's certificate and the missing piece is the enablement of +that merchant account with Apple — which the error says the PSP does not have, and which no +dashboard setting can grant. + +**Nothing here is ours to fix.** `merchantIdentifier` reaches the browser from +`applepay.configuration` in the session, which comes from the Adyen account configuration through +Commerce Layer. This library never composes it, and there is no code change that could. + +**It does vindicate the opt-in default.** An account in this state advertises Apple Pay in the +session and passes `isAvailable()` — the device can pay — so a consumer who got the method by +default would have shipped a button that opens a sheet and closes it again. Being offered really +is not evidence it can work. + +**And it was the domain — the message names the wrong thing entirely.** +`examples-new-payments` had recorded this same `merchantIdentifier` +(`000000000326725` on `CommerceLayerInc245_NEW_PAYMENTS_TEST`) on 2026-07-13, against the domain +`examples-new-payments.netlify.app`. The ngrok domain we now serve was never registered on that +account. + +Settled by running Adyen's validation endpoint twice with only `domainName` changed: + +| `domainName` | Answer | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `checkout.gciotola.commercelayer.dev` | `422` — "…pspId=… unauthorized to process transactions on behalf of merchantId=000000000326725 … is not a registered merchant in WWDR and isn't properly authorized via Mass Enablement, either." | +| `examples-new-payments.netlify.app` | `200` — a live Apple merchant session, valid for one hour | + +So the pspId **is** authorized for that merchantId, the Mass Enablement **is** in place, and the +only difference between a 422 and a working merchant session is whether the domain is registered. +**An unregistered Apple Pay domain is reported as a message about the merchant, and never mentions +the domain at all.** That is the trap worth writing down: read literally, it sends you to Adyen +support to ask for an enablement that already exists. + +The fix is one Management API call — `POST /v3/merchants/{merchantId}/paymentMethodSettings/{paymentMethodId}/addApplePayDomains` +with `{ "domains": [...] }`, since the Customer Area tile is read-only after creation. Nothing in +this library, and no Apple Developer account: registering a domain under Adyen's certificate does +not need one. + +**The diagnostic ordering to keep.** `console.error(e)` before the message, then the response body +of `v1/applePay/sessions`, then — before reading that body literally — the same call with a domain +known to be registered. The third step is what turns an ambiguous message into an answer, and it +costs a minute. + +**One observation left open.** What the shopper saw was +`ApplePay - Something went wrong on ApplePayService`, because `onError` reports the SDK's own +message. That is fine for a developer and meaningless to a shopper, and it is not specific to +Apple Pay — the card path does the same with any `onError`. Worth deciding separately whether +gateway internals belong in a shopper-facing error at all. + +## Why the own-certificate route needs a server, and cannot be stubbed + +Checked at the source, because it decides where this library stops. + +Merchant validation is not an Adyen step with an Adyen policy attached. When Safari displays the +sheet it fires `onvalidatemerchant` with a `validationURL`, and per Apple's own merchant +integration guidance **"the merchant server builds a session request payload and posts it to the +Apple Pay servers using two-way TLS. The certificate used for this connection is the merchant +identity certificate."** The identity certificate and its private key are packaged as a `.p12` to +make that call. + +Two consequences follow, and neither is negotiable: + +**A browser cannot make that call.** `fetch()` cannot present a client certificate, and the +private key could not safely live in a page if it could. So the own-certificate route requires an +endpoint — a small one, a single route that performs the mTLS POST and returns the response, but +an endpoint. This library is storefront-only by construction, so that endpoint is the +application's, never ours. + +**It cannot be faked in test.** What must reach `completeMerchantValidation` is Apple's own +merchant session object, and Apple is explicit that it **"should not be modified in any way, +otherwise merchant validation will fail."** A local handler returning a plausible-looking `OK` has +nothing to return: the value is opaque signed data only Apple can mint. There is no stub, in test +or anywhere else — which is a useful thing to know before building one. + +**And the two routes are mutually exclusive per merchant account.** Adyen can only perform the +mTLS with a certificate it holds. On the own-certificate route you upload the _payment processing_ +certificate to Adyen and keep the _identity_ certificate — so Adyen has nothing to present, which +is exactly why its own instructions have you put the PEM on your server. A merchant account is +therefore on one route or the other, and the integration has to match it. + +### The hybrid, if it is ever wanted + +`adyen-web` already has the seam: `onValidateMerchant: props.onValidateMerchant || this.validateMerchant`, +typed `(resolve: (merchantSession: unknown) => void, reject: (error?: string) => void, validationURL: string) => Promise`. +So the shape a hybrid would take is one passthrough prop: + +- **absent** — Adyen's certificate, Adyen's `/applePay/sessions`, no server anywhere. What is + implemented today, and the right default: a merchant with no backend gets Apple Pay. +- **provided** — the application's own endpoint, holding its own identity certificate. + +We would hold no certificate and make no request; we would forward a callback. That is inside a +storefront library's remit, and it is the only version of "configurable validation" that is. + +Not built, because it is inert until an endpoint exists on the other side, and because the shorter +path to a working test — Adyen completing the Mass Enablement on the merchant account — needs no +Apple Developer work and no code at all. The trigger for building it is an application that has +its own certificate and endpoint and wants this component in front of them. + +## It works (2026-09-08) + +Apple Pay through the Drop-in completes on the `payment_sessions` model. Verified by hand, +because no test can do it: Safari, a registered domain, a sandbox tester with one of Apple's +published test cards. + +Three things had to be true, and each was a separate blocker discovered in turn: + +1. **The domain registered on the right merchant account.** Adyen granted the + "Payment methods read and write" role, and `checkout.gciotola.commercelayer.dev` was added to + the Apple Pay domains of `PM32CTG22322865PKXRTP6GR5` on + `CommerceLayerInc245_NEW_PAYMENTS_TEST`. The 422 that had looked like a missing enablement was + only ever this. +2. **A sandbox tester holding an Apple test card.** Adyen's TEST environment cannot process a + real card, which is what the July failure had been all along — the ticket answer about test + cards was right, two blockers early. +3. **A second macOS user**, signed into iCloud as that tester, so the primary account's iCloud + was left alone. Apple's own sandbox device list does not mention the Mac; a second user + account works, and the machine-level "security settings were modified" failure did not occur + here. + +**Nothing in the library changed to make it work.** The wallet code that shipped for Google Pay +carried Apple Pay unmodified: the same `walletConfiguration`, the gate on the click, the gift +cards on `onAuthorized`, the self-abort guard. Every blocker was configuration, in three +different systems, each reported by a message that named something else. + +**Still unverified, and only a human can:** whether Safari renders _our_ message from +`new ApplePayError("unknown", …)` when a gift card cannot be charged, or its own wording for that +code. The path is exercised and correct; what the shopper reads is not known. diff --git a/docs/adr/2026-09-07-google-pay-through-adyen.md b/docs/adr/2026-09-07-google-pay-through-adyen.md new file mode 100644 index 00000000..e6830bf5 --- /dev/null +++ b/docs/adr/2026-09-07-google-pay-through-adyen.md @@ -0,0 +1,153 @@ +# Google Pay through Adyen: the gift cards move off the click + +**Date:** 2026-09-07 +**Status:** accepted +**Scope:** Google Pay offered inside the Adyen Drop-in, on the `payment_sessions` model + +## Context + +`2026-09-07-paypal-through-adyen.md` established the **Gateway-Owned Button**: a method whose +own control performs the payment, where `` cannot collect and the +privacy-and-terms gate moves inside the method's own click. Google Pay is the second instance, +and it was chosen over Apple Pay for one reason — it is the one we can actually run. Apple Pay's +`isAvailable()` rejects unless the document is `https:`, and it needs Safari with a card in +Wallet, which Playwright's WebKit is not and does not have. + +So the question this ADR answers is not "how do we add a wallet". It is whether the pattern +generalises, or whether it was shaped around PayPal. + +Verified against the installed `@adyen/adyen-web@6.42.0` and the real `/sessions/{id}/setup` +response, not the docs: + +- **Nothing to configure.** Adyen's setup response already carries + `googlepay.configuration = { merchantId, gatewayMerchantId }`. Unlike `applepay` it carries no + `merchantName`, which Google shows in its sheet; left unset it is empty. +- **Google renders the button.** `componentToRender` gates on `showPayButton`, and passes + `onClick: this.submit` to `paymentsClient.createButton()`. The Core sets `showPayButton: false` + so cards route through our button, so Google Pay needs it re-enabled per method, exactly as + PayPal does. +- **`submit` works** — `new Promise((resolve, reject) => props.onClick(resolve, reject)).then(showGooglePayPaymentSheet)`. + Its `onClick` takes its callbacks **positionally**, not as PayPal's actions object. +- **`onAuthorized` exists**, and fires between the shopper choosing a card and the money moving: + `handleAuthorization().then(this.makePaymentsCall)`. `formatProps` appends + `PAYMENT_AUTHORIZATION` to `callbackIntents` unconditionally, so the hook is always live. +- **`isAvailable()` is `isReadyToPay()`**, with `existingPaymentMethodRequired: false` by default + and no protocol guard — so the row renders on the dev server, in Chromium. + +## Decision + +### Google Pay owns its button, even though `submit` works + +`submit` resolving our `onClick` and then calling `loadPaymentData()` looks like host-drivability. +It is not, because Google requires `loadPaymentData()` to happen inside the click's user gesture, +and the place sequence charges the gift cards before it asks anyone to collect. The gesture window +is finite and a network round trip can exceed it, at which point the sheet does not open. + +That ordering is not negotiable — a refused payment must never leave gift cards charged after it — +so the method goes where PayPal went. Which corrects the earlier claim that the wallets were "an +easier problem": for a wallet the deciding question is the gesture, not the API. + +### The gate on the click, the gift cards on `onAuthorized` + +The two halves of PayPal's `onClick` split here, and each lands where it can work. + +The **gate** stays on the click and does no I/O: it reads `collectionPermitted` from a ref, +reports and rejects, or resolves. It has to be on the click because its job is to stop the sheet +from ever opening, and it can be because it needs nothing from the network. + +The **gift cards** move to `onAuthorized`, which is strictly better than the click ever was: + +- no gesture to preserve, so no race to lose intermittently; +- still before the money — `/payments` is called only once it resolves; +- and a rejection reaches the shopper where they are standing. Whatever we reject with becomes + Google's `PaymentDataError`: a string is shown verbatim **inside Google's own sheet**, which + stays open for another attempt. A gift card that cannot be charged no longer costs the shopper + the wallet flow. + +**It is not an argument for moving PayPal there, and that was checked** — the first draft of +this ADR called it the obvious next simplification, wrongly. `Paypal.js#handleOnApprove` reads: + +```js +if (!onAuthorized) { this.handleAdditionalDetails(payload); return } // today's path +if (!actions.order) { this.handleError("PayPal order actions are not available"); return } +return actions.order.get() + .then(order => new Promise((resolve, reject) => onAuthorized({ ... }, { resolve, reject }))) + .then(() => this.handleAdditionalDetails(payload)) + .catch(e => this.handleError("Something went wrong while parsing PayPal Order", { cause: e })) +``` + +Providing the hook makes the payment conditional on `actions.order`: without it +`handleAdditionalDetails` is never reached and the payment does not complete — a failure mode +the current path does not have. Worse, a deliberate rejection is indistinguishable from a parse +failure, because both land in that one `catch` and arrive as `onError`. Every `onError` is an +**unknown** outcome by this library's own rules: nothing may be rolled back, and the shopper is +shown Adyen's message about parsing rather than ours about their gift card. + +Google Pay's rejection value, by contrast, becomes Google's `PaymentDataError` and is displayed +verbatim in a sheet that stays open. + +And there is nothing to buy. The gesture is why Google Pay's gift cards had to move; PayPal has +no gesture problem, because zoid pre-opens the popup on the click. The click is also the better +moment for the shopper: a gift card that cannot be charged aborts before the popup opens, rather +than after they have signed in to PayPal and approved a payment. + +So the placement is per-method for reasons per method, and the asymmetry is the design. + +### A rejected `onAuthorized` is not a refusal + +Adyen routes it through `handleFailedResult`, which calls `onPaymentFailed` — the same callback a +real refusal arrives on. Read as a refusal it would burn the Payment Session, and that is exactly +wrong: no money moved, and the shopper is still in Google's sheet able to pick another card. The +session _is_ the payment, so discarding it would break the retry Google is offering. + +So a flag marks our own abort, is consumed by the handler, and is reset on every click — a flag +that outlived its attempt would swallow a real refusal. Both directions are pinned by specs, and +both fail without the guard. + +**`onPaymentFailed` therefore no longer means "the gateway refused".** It means "this attempt +ended without money", and who ended it decides what may be rolled back. + +### The gate is reported, because there is nothing else + +PayPal's `onInit` hands over `enable()`/`disable()`, so its button can render disabled. Google's +`createButton()` offers no equivalent. There is no disabled state to render, which means the +message produced on the click is the _only_ thing between a shopper and a control that appears to +do nothing. The decision to report the closed gate rather than merely enforce it — added for +PayPal after a manual run — is what makes Google Pay viable at all. + +### A row in the list, not an instant payment + +Adyen can hoist wallets above the method list via `instantPaymentTypes`. Kept as an ordinary row: +`collection.by` is switched from the Drop-in's `onSelect`, and a hoisted button is unlikely to +emit one. A second presentation is not worth a second mechanism for deciding who collects. + +### `"google_pay"`, not `"googlepay"` + +The public `paymentMethods` union is our vocabulary, not Adyen's tx-variants — `"card"` is +already not `"scheme"`. It is in the defaults, like every method as it lands, and the override +narrows only. + +## Consequences + +**The pattern generalised, and gained a seam.** `OWNS_ITS_BUTTON` and the out-of-band collection +carried over untouched. What did not carry over is where gift cards are charged, which is now a +per-method question with a name — see **Gift Card Moment** in `CONTEXT.md`. + +**Three shapes of gateway callback now coexist.** PayPal's `(data, actions)`, Google Pay's +positional `(resolve, reject)`, and Adyen's own Core callbacks. Adyen under-declares the first and +declares the second; neither is guessable, and an `adyen-web` upgrade should not land without the +end-to-end tests for both. + +**The sheet has no merchant name.** The session carries none for `googlepay`. If it matters, a +prop is the place for it — the library cannot know it. + +**The payment itself is untested, and the interesting part is not.** Completing a Google Pay +payment needs a signed-in Google account with an allowlisted test card, which Playwright has no +profile for. The e2e stops where the sheet opens — which is precisely the assertion that the +gesture survived, the one thing about this design that could have been wrong. What remains +unverified is the leg after Google returns a token, and that leg is the card path. + +**One caveat on that test.** It proves the sheet opens under the current design; it does not +prove the asynchronous version would fail. On an order without gift cards the round trip is fast +enough that both would pass. The reason to keep the gate synchronous rests on Google's +requirement, not on this test — which is why the requirement is written down here. diff --git a/docs/adr/2026-09-07-paypal-through-adyen.md b/docs/adr/2026-09-07-paypal-through-adyen.md new file mode 100644 index 00000000..51908655 --- /dev/null +++ b/docs/adr/2026-09-07-paypal-through-adyen.md @@ -0,0 +1,578 @@ +# PayPal through Adyen: the gate moves to the method's own click + +**Date:** 2026-09-07 +**Status:** accepted +**Scope:** PayPal offered inside the Adyen Drop-in, on the `payment_sessions` model + +## Context + +`2026-09-02-adyen-payment-setting.md` shipped cards through Adyen's client-side Drop-in and +restricted it to them with `allowPaymentMethods: ["scheme"]`. PayPal is the first method to be +added, and it breaks the property the whole design was built on: **`` is the +pay button.** + +That property is what keeps a legally required privacy-and-terms gate in front of every +payment. It works for cards because a card form is inert until something submits it. PayPal +is not inert: its own branded button performs the payment, and it must, because the browser +requires a real user gesture to open a popup and PayPal's presentation rules require their +button to be the thing clicked. + +So this is not "one more method". It is a second answer to the question _who owns the click_, +and the gate has to move to wherever that is. + +### The reason the earlier ADR gives for the restriction is wrong + +It says Apple Pay, Google Pay and PayPal _"render their own pay buttons and submit +themselves, which would bypass `` and the terms gate"_. Half of that is +right and the operative half is not. + +`showPayButton: false` — which the card integration sets on the `Core` — does not hide +PayPal's button. `Paypal.componentToRender()` returns `null` when it is false +(`src/components/PayPal/Paypal.tsx:243`), and that is the only thing `UIElement.render()` +renders. The PayPal SDK script is never even downloaded. In the Drop-in, PayPal would still +appear as a selectable row with **an empty accordion panel** underneath. + +So the restriction was correct and its stated reason was not: it was preventing a broken +render, not a bypassed gate. That correction is applied to the earlier ADR. + +The same is true of Apple Pay, Google Pay and Amazon Pay — all four return `null` rather than +degrading like Card, which always renders its form and gates only the button inside it +(`Card.tsx:214` vs `CardInput.tsx:613`). + +### Out of scope, and the reason has changed + +- **Apple Pay and Google Pay.** Deferred, but no longer for the reason the earlier ADR gives. + Their buttons' `onClick` **is** `this.submit` (`ApplePay.tsx:356`, `GooglePay.tsx:320`), so a + host button _can_ drive them — they are a different and easier problem than PayPal, and + designing for PayPal first would over-build for them. PayPal is the only method in the set + that categorically cannot be host-driven. +- **Klarna.** Still blocked in `core-api`: the Adyen session payload sends nine keys and none + of them is `lineItems`, which Klarna requires, and the only extension point — `options` — is + `prohibited: [read, write]` for a sales-channel token. Nothing has changed there since the + card work. +- **PayPal as an express button** (product page, before an order exists). Unchanged from the + earlier ADR: blocked on reading `public_key` without an order. + +### What `adyen-web` actually does + +Verified against `6.42.0`, the installed version. + +**`dropin.submit()` can never drive PayPal.** Not a configuration — an override: + +```ts +// src/components/PayPal/Paypal.tsx:78 +public submit = () => { + this.handleError(new AdyenCheckoutError('IMPLEMENTATION_ERROR', ERRORS.SUBMIT_NOT_SUPPORTED)); +}; +``` + +A class-property arrow, so it shadows `UIElement.prototype.submit` unconditionally and cannot +be reached through `super`. It is in the public typings (`index.d.ts:3630`), so it is +intentional. `handleSubmit` — the real entry point — is `private` and is only ever called by +PayPal's SDK as `createOrder`. `updateWithAction` is public but guards with +`WRONG_INSTANCE` unless the button already started the flow. **There is no programmatic +trigger.** + +**Two hooks reach PayPal's own click, and both are forwarded verbatim.** +`Paypal.componentToRender` spreads its props into `PaypalComponent`, which spreads them into +`PaypalButtons`, which passes `onInit` and `onClick` straight into `paypal.Buttons({...})` +(`PaypalButtons.tsx:32-45`). So they are PayPal's callbacks, not Adyen's: + +```ts +// @paypal/paypal-js@10.0.2 types/components/buttons.d.ts:232 +export type PayPalButtonOnClick = ( + data: Record, + actions: OnClickActions, +) => Promise | void; + +export type OnClickActions = { + reject: () => Promise; + resolve: () => Promise; +}; +export type OnInitActions = { + enable: () => Promise; + disable: () => Promise; +}; +``` + +`actions.reject()` aborts before the popup opens and before any Adyen call. And `onClick` may +return a promise, so it may do work first — which is what makes it usable for more than a +boolean check. + +**Adyen under-declares `onClick`.** `PayPalConfiguration.onClick?: () => void` +(`index.d.ts:3556`) — no parameters. The real signature comes from PayPal's SDK, above. A cast +is required, and the version has to be pinned. + +**`beforeSubmit` is not the gate, and using it is worse than not gating.** It runs inside +`makePaymentsCall`, i.e. _after_ PayPal's popup is already open. And rejecting it lands in +`Paypal.handleSubmit`'s catch: + +```ts +// src/components/PayPal/Paypal.tsx:197 +.catch((e) => { + if (e instanceof CancelError) { + this.setElementStatus('ready'); + return; // ← neither resolve nor reject + } +``` + +That early return settles neither half of the promise handed to `createOrder`, so **the PayPal +popup hangs on a spinner indefinitely.** + +**`beforeRedirect` never fires for PayPal.** It lives only in `RedirectShopper`, reached only +from `action.type === 'redirect'`. PayPal's action is `'sdk'`, and `Dropin.handleAction` +short-circuits every non-`redirect` action to `updateWithAction` first (`Dropin.tsx:199`). +`onActionHandled` does fire — `actionDescription: 'sdk-loaded'` — but after the popup is +already open, and it returns `void`. **Neither is a "we are about to leave the page" hook, and +Adyen has none:** PayPal decides overlay-versus-redirect inside its own SDK. + +**The flow is two Adyen calls, both from inside PayPal's lifecycle.** +`createOrder` → `POST /sessions/{id}/payments` → Adyen answers `action.type: 'sdk'` → +`updateWithAction` resolves the token → PayPal takes the shopper through approval → `onApprove` +→ `POST /sessions/{id}/paymentDetails` → `onPaymentCompleted`. + +**`Pending` and `Received` arrive as success.** `verifyPaymentDidNotFail` is a _deny_-list: + +```ts +// src/components/internal/UIElement/UIElement/utils.ts:54 +if (["Cancelled", "Error", "Refused"].includes(response.resultCode)) + return Promise.reject(response); +``` + +So `onPaymentCompleted` fires for a delayed-settlement PayPal payment, and firing is no longer +the same thing as money taken. + +**Cancel and refusal arrive on different callbacks, and the discriminator is fragile.** +Closing the overlay produces `onError` with `new AdyenCheckoutError('CANCEL')` and **no +message** (`Paypal.tsx:253`); a refusal produces `onPaymentFailed` with a `resultCode`. But a +script-load cancellation also uses `name === 'CANCEL'`, with the message `'Script loading +cancelled.'` — so the two separate only on an undocumented empty-message convention. And a +refusal can be followed by a spurious `onError('ERROR')`, because `handleSubmit` rejects +`createOrder` after `handleFailedResult` has already run. + +**Two smaller facts worth recording.** PayPal is never an `instantPaymentTypes` member +(`SUPPORTED_INSTANT_PAYMENTS = ['paywithgoogle', 'googlepay', 'applepay']`, `Dropin.tsx:16`), +so it is an ordinary accordion row and its buttons appear one click deeper than the wallets'. +And `locale` is **not** in `GENERIC_OPTIONS`, so it never reaches PayPal from the Core config: +PayPal auto-detects the shopper's language instead of following the checkout, unless it is set +per method from a 24-entry allow-list. + +### What the API actually does + +Verified in `core-api` at `65c08cc73`. + +**The fall-through the card work rested on is gone, and the replacement is better.** +`2c1145339` rewrote it. `#authorize!` now has both the status check it lacked _and_ a guard: + +```ruby +# app/models/payment/session/adyen.rb:82 +def skip_authorize? + session.payment_wallet.blank? && client_data['payment_method'].blank? +end +``` + +Our Drop-in charges client-side, so there is no wallet and no `client_data['payment_method']`: +`authorize!` returns immediately, **no `/payments` call is made at all**, the authorization stays +`pending`, and the `AUTHORISATION` webhook succeeds it. Same outcome as before, reached without +ever talking to Adyen — and now pinned by an upstream spec, _"does not call Adyen and leaves the +authorization pending, waiting for the webhook"_. Assumption 2 of the earlier ADR is retired. + +**In exchange there is a tripwire, and it is one PayPal invites.** +`client_data.payment_method` is `creatable`/`updatable` for a sales-channel token with no +`prohibited` key. Writing it — to record which method the shopper picked, for a recap or for +analytics, which is exactly what one wants with PayPal — flips `skip_authorize?` to `false`. +Commerce Layer then calls `/payments` with it, Adyen answers 422, and the new `apply_response` +puts the authorization in **`failed`**. From there `succeed` is illegal, `509bbb9a1` added +`succeeded → failed` with `invalidate_session!`, and the session lands in the new `invalidated` +state, which is in `CLOSED_STATES` and blocks new transactions. **Unrecoverable, and silent.** + +**Four gaps a storefront-only PayPal integration cannot close.** None blocks the happy path; +each leaves a hole. + +1. **`options` is `prohibited: [read, write]` for a sales-channel token**, and + `payload.merge!(options.deep_symbolize_keys)` is the _only_ extension point on the + `/sessions` payload. So `lineItems`, `shopperEmail` and a bare `shopperReference` cannot be + sent from a storefront at all. This one sits underneath the other three. +2. **`PENDING` and `OFFER_CLOSED` webhooks are discarded.** Neither is in + `Response::AdyenEvent::Standard::CODES`, so the handler's constructor raises + `UnsupportedEventType` — inside a Sidekiq job with `retry: 0`, after the controller has + already answered `200 "[accepted]"`. A delayed PayPal payment therefore has **no resolution + path**: the authorization stays `pending` forever. +3. **`payment_session.payment_instrument` is always `{}` for Adyen.** `Payment::Session::Adyen` + overrides neither `payment_data` nor `refresh`, so after a reload the order cannot say + whether the shopper paid by card or by PayPal. The recap cannot name it. +4. **`auto_capture` is inert for Adyen** — no `auto_capture!` call and nothing in the payload — + so a PayPal sale cannot be auto-captured from a storefront. + +Two more, recorded because they will be met eventually and are not on our path today: +`CANCEL_OR_REFUND` always books a `PaymentVoid`, never a `PaymentRefund`; and `require_action` +transitions only `from: :pending`, so PayPal's `RedirectShopper → Pending` sequence raises. That +second one is reachable only through `#payment_details`, which is the advanced flow's relay and +which this integration does not use. Whether the raise is swallowed or surfaces as a 500 depends +on `AASM::InvalidTransition`'s superclass, which we could not verify. + +## Decision + +### The gate lives inside PayPal's own click + +`onClick` rejects when the terms are not accepted; `onInit` renders the buttons disabled until +they are. + +``` +paymentMethodsConfiguration: { + paypal: { + showPayButton: true, // required: overrides the Core's false, which would delete the component + onInit: (_data, actions) => { hold(actions); if (!permitted()) void actions.disable() }, + onClick: (async (_data, actions) => permitted() ? actions.resolve() : actions.reject()) as never, + } +} +``` + +Both, not either. `onInit` makes the state visible instead of letting the shopper discover the +refusal by clicking; `onClick` is the enforcement, at the last moment before anything happens. + +`showPayButton: true` per method is what re-enables PayPal at all, and it works because +`componentProps` is spread last in `UIElement.buildElementProps` — the Core keeps `false`, so +cards still route through our own button. + +Three notes that are part of the decision, not commentary. The cast on `onClick` is unavoidable +and the adyen-web version must be pinned, because Adyen's type says the callback takes no +arguments. `onClick` is a closure created when the Drop-in mounts, so it must read acceptance +through a ref rather than closing over it. And `onInit` hands over its `actions` exactly once, +so they have to be held and `enable()` called later — without that, a shopper who accepts the +terms _after_ the buttons render finds them dead. + +### The card path is unchanged, and the two mechanisms are the point + +Cards keep `showPayButton: false` on the Core and `` calling +`dropin.submit()`. So the library now enforces the same gate in two places, by two mechanisms. + +That is not duplication to be tidied away later. Which mechanism applies depends on **who owns +the click**, and that is a property of the payment method, not of our architecture. Moving cards +to the PayPal mechanism for symmetry would reopen a decision that has passed three end-to-end +tests, and buy nothing. + +### The privacy-and-terms rule is extracted into one hook + +Today the rule is an expression inside ``: +`privacyUrl && termsUrl ? privacyTermsChecked : true`. Copying that into the gateway component +is the shape of a bug this repository has already shipped: two places asking the same question, +drifting apart. `hasLiveAuthorization` without `hasReturnedMoney` was exactly that, one week +ago. + +So the gate becomes a hook both consult. Not the button publishing into the handoff store for +the component to read, and not the reverse: a single owner that neither of them is. + +### Our place button disables when the method owns its button + +Disabled with a reason the application can render — not hidden. + +Hiding removes the one control the shopper has learnt to look for. Leaving it live offers two +routes to one action, and ours leads nowhere: `dropin.submit()` on PayPal produces +`IMPLEMENTATION_ERROR` and no payment. "The selected method has its own button" is a domain +fact the library knows and the application renders, which is the line +`2026-09-01-presentation-belongs-to-the-application.md` already draws. + +### The handoff becomes two axes + +Four flat fields cannot express what the button now needs to know. Two axes can: + +```ts +collection: + | { by: "host"; submit: () => Promise; isReady: boolean } + | { by: "gateway" } + | null + +collectedOutOfBand: "no" | "in-progress" | "done" | "failed" +``` + +The first answers _who collects_, and `{ by: "gateway" }` **is** the reason the button renders +when it disables itself. The second generalises `resumePhase`: a 3DS redirect return and +PayPal's own button produce the same event — money taken, no click, order still to place — and +having two names for one mechanism is how they end up implemented twice. + +### An order placed on `Pending` is correct + +`onPaymentCompleted` fires for `Pending` and `Received`, and we place the order anyway. + +Not for convenience: `Pending` means Adyen accepted and is waiting for the money, and +`placeOrderWithPaymentSessions` **already** handles that without knowing anything about PayPal — +the authorization stays `pending`, the loop waits, and a payment that never arrives is failed by +the webhook. It is the card mechanism with a longer window. + +Which means `onPaymentCompleted` must branch on `data.resultCode` rather than assume captured +funds, and the placeability budget — 20 attempts at 1s, sized for an Adyen webhook — is not +sized for a PayPal eCheck. The honest position is that a genuinely delayed PayPal payment has +no resolution path today, because gap 2 discards the `PENDING` webhook. + +### Only `onPaymentFailed` is a verdict + +`onPaymentFailed` → `{ status: "failed" }`. `onError` → `{ status: "unknown" }`, whatever its +`name` says. + +The empty-message convention that separates a shopper cancel from a script-load failure is +undocumented, and what hangs off it is whether someone's money is given back. Routing every +`onError` to `unknown` means the fragile part cannot decide that: in doubt, nothing is touched. +That is the rule the gift card incident produced, applied one layer up. + +### A verdict burns the session; a cancel touches nothing + +Same rule as cards for `failed`: delete the Payment Session, create nothing, the shopper +re-picks. Nothing at all for `unknown`. + +The asymmetry is deliberate and is the right way round: **cancelling PayPal costs nothing** — +the session is still there and the shopper can click again immediately — while being refused +costs a re-pick. Note that deleting the session remounts the Drop-in and so **destroys the +rendered PayPal button**, which is why the re-pick is visible rather than silent. + +### The gift cards are authorized inside `onClick` + +Before `actions.resolve()`, and rejecting if any of them fails. + +It is the only moment available: with cards we authorize them just before `dropin.submit()` +because we own that call, and here we do not. `onClick` may return a promise +(`PayPalButtonOnClick = (data, actions) => Promise | void`), so the round trip is within +contract. + +The state it produces on an abandoned popup — gift cards charged, no payment started — is +exactly the state already accepted for a refused card: still applied, spendable on the retry, +removable through their own control. The two cases are the same one. + +### `paymentMethods`, narrowing only + +```ts +/** Which of the designed methods to offer. Defaults to all of them. */ +paymentMethods?: Array<"card" | "paypal"> +``` + +Our vocabulary, not Adyen's tx-variants. So an application can turn PayPal off without waiting +for a release, and cannot turn on a method nobody has designed for — where the failure is worse +than a dead control: an undesigned wallet renders an empty accordion, and re-enabling its button +walks past the gate. The `card → scheme` mapping stays inside the library, and adding a method +widens a union, which is additive and type-checked. A value outside it is dropped with a +development warning, as `` already does for unimplemented setting types. + +### An end-to-end test is mandatory here + +The gate rests on a signature Adyen does not document. A unit test with a mocked SDK proves +that we pass `onClick` into `paymentMethodsConfiguration.paypal`; it cannot prove that PayPal +calls it with `actions`. Only an end-to-end run distinguishes those, and that distinction is the +risk. + +So: one end-to-end test through the popup, plus the unit test that the callback is forwarded. +The `payment_source`-model fixture is reusable whole — `waitForEvent("popup")`, the sandbox +login, `.adyen-checkout__paypal__button >> nth=0`. A closed popup is worth attempting with +`newPage.close()`, since it is a rollback branch nothing has ever tested; if it does not +reliably produce `onError('CANCEL')` it is dropped with a note rather than chased into PayPal's +SDK. A blocked popup and the dispute branches stay out. + +## Considered options + +- **`beforeSubmit` as the gate.** Rejected: runs after the popup is open, and rejecting it + leaves `createOrder`'s promise unsettled, hanging the popup forever. +- **`beforeRedirect` or `onActionHandled` as the gate.** Rejected: the first is structurally + unreachable for PayPal, the second fires too late and returns `void`. +- **`dropin.submit()` for PayPal.** Not an option — it throws by design. +- **Keeping `allowPaymentMethods` as a passthrough prop.** Rejected: hands every consumer the + ability to produce an empty accordion or a bypassed gate. +- **Hardcoding the list with no override.** Rejected: a merchant who does not want PayPal + should not have to wait for a release. +- **Keeping `resumePhase` and adding a second signal for PayPal.** Rejected: one mechanism, two + names, implemented twice. +- **Copying the terms expression into the gateway component.** Rejected for the reason the + `holdsMoney` bug gave us. +- **Moving the card path to the PayPal mechanism**, for one gate mechanism instead of two. + Rejected: reopens a tested decision to buy symmetry. +- **Matching `'PayPal overlay closed'`.** That constant exists in `constants.ts` and is never + thrown anywhere in the bundle. + +## Consequences + +**Two pay buttons are on screen, and that is the design.** Ours, disabled with a reason +whenever the selected method owns its own; PayPal's, inside the expanded row. + +**`onPaymentCompleted` no longer means money taken.** Every consumer reading it has to branch on +`resultCode`. This is a behaviour change for the card path too, where the same callback +previously only ever meant `Authorised`. + +**A delayed PayPal payment has no resolution path.** `PENDING` is discarded upstream, so the +authorization stays `pending`, the placeability loop exhausts, and the shopper is told we are +still checking — which is true and unhelpful. This is the first real case of the open question +the place-order ADR left: what a timeout should actually show. + +**The recap cannot name PayPal.** `payment_instrument` is empty for Adyen, and +`client_data.payment_method` — the obvious place to record it — is the tripwire above. So the +order, after a reload, knows a payment happened through the Adyen setting and not which method. + +**The gate is enforced in two places.** Accepted, and the hook is what keeps them from drifting. +A third mechanism would be the point to stop and redesign. + +**The integration depends on an undocumented signature.** Pin `@adyen/adyen-web`, keep the +forwarding test, and treat an adyen-web upgrade as something that needs the PayPal end-to-end +test run before it lands. + +**PayPal's language will not follow the checkout** unless `locale` is set per method, because it +is not a `GENERIC_OPTIONS` key. Left unset deliberately for now: PayPal auto-detects, which is +usually right, and the alternative is mapping the checkout's locale onto a 24-entry allow-list +whose misses are silent. + +### Assumptions this design rests on + +1. **PayPal's SDK opens its popup on the user gesture, before `onClick` settles.** The contract + allows an async `onClick`, so the SDK must wait for it — but whether the window is opened + eagerly or after resolution is PayPal's runtime behaviour, loaded from their CDN and not + inspectable here. If it is opened after, a slow gift card authorization could get the popup + blocked. **The end-to-end test settles this, and it is the first thing it proves.** +2. **Adyen keeps forwarding `onClick` and `onInit` verbatim into `paypal.Buttons`.** Undocumented + in Adyen's own types, which declare `onClick` as taking no arguments. +3. **The empty-message convention separates a shopper cancel from a script-load cancel.** We do + not rely on it for anything that moves money — that is the point of routing every `onError` to + `unknown` — but it is the only signal available if the two ever need telling apart. +4. **`AASM::InvalidTransition` descends from `StandardError`.** Unverified: there is no Ruby + toolchain here. It decides whether an upstream defect fails silently or as a 500, and that + defect is not on this integration's path. + +### Correction: the wallets are not "host-drivable, so easier" (2026-09-07) + +An earlier draft of this table called Apple Pay and Google Pay an easier problem than PayPal +because, unlike PayPal, both override `submit`: Apple Pay's calls `startSession()` and Google +Pay's calls `loadPaymentData()`. That is true and it is not the point. + +Both of those must run **inside a user gesture** — Safari throws +`Must create a new ApplePaySession from a user gesture handler`, and Google requires the same of +`loadPaymentData()`. Our place-order sequence authorizes the gift cards _before_ it asks the +gateway to collect, so by the time `submit()` runs the gesture is spent. The ordering is not +negotiable either: a refused payment must never leave gift cards charged after it. + +So both wallets want their own button and the PayPal mechanism — gate and gift cards inside the +method's own click — and `submit` being available changes nothing. Apple Pay adds two obstacles +of its own: `isAvailable()` rejects outright unless `location.protocol === "https:"`, so it +never renders on the dev server, and it needs Safari with a card in Wallet, which Playwright's +WebKit is not and does not have. **Apple Pay cannot be covered by an e2e at all** — not the +payment, not even the button. + +Google Pay has neither problem: no protocol guard in the bundle, availability decided by +`isReadyToPay()`, and it runs in the Chromium these tests already use. It is therefore the +second instance of the Gateway-Owned Button pattern to build, and the one that will show whether +the abstraction generalises. Note its `onClick` is `(resolve, reject) => void` positionally, not +PayPal's `(data, actions)`. + +### Asks for the API + +In dependency order. The first is the one the others sit on. + +1. **`Session::Base` should carry what `Payments::Base` already carries** — `lineItems` and + `shopperEmail`, built from the order server-side. + + Re-verified at core-api `65c08cc73` (2026-09-05). Klarna requires `lineItems` — mandatory, + totalling `amount.value`, each with a `description` — plus `shopperEmail`. + `Payment::Payload::Adyen::Session::Base#to_h` sends neither. The only extension point is + `payment_session.options`, which `Payload::Session` delegates straight to the session + attribute, and `ProhibitedAttributesCheck` raises `CanCan::AccessDenied` on it for a + sales-channel token (`prohibited: [read, write]`, enforced `if: :sales_channel?`). So a + storefront cannot supply them by any route. + + The earlier framing of this ask — open `options` to sales channels — was the expensive one: a + permissions change, with a storefront then responsible for a payload it should not be + composing. The cheap one is three lines in the same repository, because + `Payments::Base#line_items_data` already exists one class over and builds exactly this from + the order. No new permission, no storefront API change, and nothing to build in this library + until it lands. + +2. **Handle `PENDING` and `OFFER_CLOSED`.** Both are discarded by `CODES`, in a `retry: 0` + worker, after a 200 has gone back to Adyen. The first leaves a delayed payment unresolvable; + the second leaves an abandoned one with no signal. +3. **Populate `payment_instrument` for Adyen sessions.** `Payment::Instrument::Adyen::Account` + already knows how to describe a `paypal` account; nothing invokes it, because + `Payment::Session::Adyen` overrides neither `payment_data` nor `refresh`. +4. **Make `auto_capture` real for Adyen, or stop advertising it** — it is writable, readable, + documented, and does nothing. + +Two more worth filing, not on this path: `CANCEL_OR_REFUND` books a `PaymentVoid` where a +refund happened, and `require_action` transitions only `from: :pending`, which PayPal's +`RedirectShopper → Pending` sequence violates. + +### Payment method status, inside `payment_setting_adyens` + +| Method | adyen-web type | Status | +| ---------- | -------------- | ------------------------------------------------------------------------------------------------------ | +| Card | `scheme` | ✅ implemented — `2026-09-02-adyen-payment-setting.md` | +| PayPal | `paypal` | 🟡 built; the handoff verified end to end, the payment refused by Adyen — see below | +| Apple Pay | `applepay` | ✅ implemented, opt-in — `2026-09-07-apple-pay-through-adyen.md`; verified by hand, no e2e is possible | +| Google Pay | `googlepay` | ✅ implemented — `2026-09-07-google-pay-through-adyen.md` | +| Klarna | `klarna*` | ⬜ blocked upstream — see ask 1, reshaped | + +## What the first end-to-end run established (2026-09-07) + +Built, unit-tested, and run against the real Adyen sandbox on the `payment_sessions` +organization. Three things separated cleanly, and it is worth recording which is which. + +**The handoff works.** Selecting PayPal in the Drop-in publishes `{ by: "gateway" }`, our place +button disables itself, the application renders its reason, and switching back to the card row +hands collection back. That is `payment-sessions-paypal.spec.ts`, the one test here that needs +no popup and takes no money, and it is green. + +**Adyen offers PayPal on this account.** The `/sessions/{id}/setup` response lists +`scheme, applepay, paypal, googlepay` for a US/USD order. So neither the account configuration +nor the session payload is what was hiding it — `countryCode` is sent by +`Payment::Payload::Adyen::Session::Base` and was correct. + +**The payment is refused after PayPal approves it.** The popup completes, PayPal returns its +token, and `/sessions/{id}/paymentDetails` answers `resultCode: "Refused"` — with no +`refusalReason`, which Adyen never sends to a client-side integration. The reason is only in +Adyen's Customer Area, and the most likely one is that the sandbox buyer these tests sign in as +is not valid for this merchant's PayPal integration. So the two paying tests are written and +currently fail there. + +The library's own behaviour on that refusal is what it should be, and this run is the evidence: +the burnt Payment Session was discarded, the order came back `pending` / `unpaid` with **zero** +payment sessions, the setting was deselected, and the shopper was told. Nothing was left behind +for a second attempt to trip over. + +### The closed gate is reported from PayPal's click + +`onClick` refuses when the terms are not accepted, and it now says so — an error carrying +`meta: { error: "TermsNotAccepted" }` on the component's own `errors`, which every consumer +already renders. + +The earlier position was that reporting was unnecessary because `onInit` has the buttons +disabled anyway. A manual run refuted it: a disabled PayPal button absorbs the click and says +nothing, and "nothing happened" is indistinguishable from a broken button. Nobody but the +library can produce that reason, because the click is PayPal's and never reaches the +application — so this is not a case where presentation could belong to the consumer alone. + +**No new callback.** The component's `errors` render prop is the existing channel, and the +message arrived in mfe-checkout with no change to the application at all. What the application +_does_ own is the copy: it keys off `meta.error` and translates, falling through to the +library's English default for a code it does not recognise. + +And it settles something the types do not answer: **`onClick` fires on a button +`actions.disable()` has disabled.** Verified end to end — the test that clicks PayPal before +accepting the terms is green, which is the only reason this design works at all. Were it +otherwise, the button would have to stay live and reject instead. + +### Adyen renders four PayPal buttons, not one + +`PaypalButtons.js` calls `paypal.Buttons()` **once per funding source** — PayPal, Credit, Pay +Later, Venmo — each with its own `onInit`. So `actions.enable()` reaches exactly the instance +its actions came from, and holding a single `PayPalOnInitActions` keeps only the last one +rendered, which is Venmo. + +That shipped, and a manual run found it immediately: a shopper who accepted the terms _after_ +the buttons had rendered got a working Venmo button while PayPal and Pay Later silently +swallowed the click. Fixed by holding a `Set` and driving every member. + +No e2e saw it, and the reason is worth keeping: every test accepted the terms _before_ selecting +PayPal, so nothing was ever disabled and `enable()` was never needed. The tests now accept them +in the shopper's order instead, and the unit spec drives four funding sources rather than one. + +Anything per-funding-source is therefore plural by default. Apple Pay and Google Pay render one +button each, but the same question — _does this callback fire once or once per button?_ — is the +first one to ask of them. + +**A trap worth knowing.** The first run of this suite failed with the Drop-in offering cards +only, and three plausible explanations were chased — the Adyen account, the session payload, a +CSP blocking PayPal's SDK — before the real one: mfe-checkout's e2e stack consumes +`packages/react-components/dist`, and the bundle predated the PayPal work. The unit suite reads +`src`, so it was entirely green against code the browser had never seen. Build the library +before running any e2e. diff --git a/docs/adr/2026-09-09-gift-cards-authorized-last.md b/docs/adr/2026-09-09-gift-cards-authorized-last.md new file mode 100644 index 00000000..21fea31e --- /dev/null +++ b/docs/adr/2026-09-09-gift-cards-authorized-last.md @@ -0,0 +1,108 @@ +# Proposal: authorize the gift cards last + +**Date:** 2026-09-09 +**Status:** proposed — not implemented, pending a UI/UX decision +**Scope:** the order in which a partly-gift-carded order's sessions are authorized + +## Why this is written down + +Today the gift cards are charged **before** the gateway is asked for anything, on every method. +That ordering is not free: because each gateway takes its money at a different moment, "before +the money" had to be found separately for each one, and there are now four bespoke places where +gift cards get authorized. + +| Method | Where the gift cards are charged | +| ---------- | ---------------------------------------------------------- | +| Card | in our own click handler, before `dropin.submit()` | +| PayPal | inside `onClick`, before the popup opens | +| Google Pay | inside `onAuthorized`, before `/payments` | +| Apple Pay | the same, with the rejection dressed as an `ApplePayError` | + +The proposal is to charge them **last** — after the gateway has said yes — and to treat a failed +gift card as a residual the shopper is invited to settle, rather than as a reason to abort. + +## What it would simplify + +This is the strongest argument for it, and it has nothing to do with risk appetite. + +`placeOrderWithPaymentSessions` **already** authorizes every gift card as its first step, for +every method, with no hook at all. Charging them after the gateway means that is the only place +it happens. What could then be deleted: + +- the `authorizeGiftCardSessions` call in the place handler's click path; +- the gift-card branch of PayPal's `onClick` — the terms gate stays, it is about the gate; +- **all** of the wallets' `onAuthorized`, and with it `plainError`, `appleError`, + `applePayErrorCtor`, `WalletOnAuthorizedActions`, and the `selfAbortedRef` guard, which exists + only because a rejected `onAuthorized` arrives on the same callback as a real refusal; +- the refetch between authorizing and submitting, which the Adyen ADR calls load-bearing. + +And the question every new gateway currently has to answer — _where is the moment before the +money that we control?_ — stops existing. Stripe would not need an answer. + +## What it would require + +**One rule reopened.** `canAddGiftCard` is `remainingAmountCents > 0 && !sessions.some(holdsMoney)`, +with the comment "settling a partially-paid order is a flow this iteration does not implement". +With the gateway authorized, `holdsMoney` is true and the gift-card input stays shut — which is +half of the proposed recovery. Reopening it is the change, and its original reason _is_ this +proposal. + +**The residual is already computed.** `isLiveGiftCard` drops a session whose authorization +failed, so such a card already disappears from `giftCardSessions`, stops counting toward +`giftCardAmountCents`, and `remainingAmountCents` reopens by exactly its share. Nothing to build. + +**A new session for the top-up**, because `amount_cents` is immutable and the gateway's was sized +for the total minus the gift cards. Creatable: the only create-time validations on +`payment_sessions` are "the order is not free" and "the setting is available", so an order +already carrying a live authorization does not block one. + +**The selection stops being single.** If the shopper settles the residual with a second method, +two gateway sessions are live at once, and `findCurrentPaymentSession` returns one — the newest. +The payment step stops being "choose one method" and becomes "here is what is covered, choose how +to cover the rest". Gift cards are already additive, so the model has the shape; this makes +method sessions additive too. + +## The risk, and who is accepting it + +The failure moves to the side a storefront cannot undo. From +`base_abilities/sales_channel_ability.rb`: a sales-channel token may create a `PaymentRefund` +**only** for `payment_type: 'GIFT_CARD'` on a `pending` order, and there is **no `PaymentVoid` +grant at all**. So a shopper who abandons the top-up leaves a real authorization on their card, +on an unplaced order, and this library cannot reverse it. + +Accepted deliberately, on two grounds: + +1. **Frequency.** A gateway refusal is common; a gift card failing is rare. A gift card's balance + is validated when it is applied, so the only realistic failure is someone draining it in the + minutes before the place. Today's ordering makes the _common_ failure the messy one; this makes + the _rare_ one messy. +2. **There is a way out that is not a rollback.** The shopper settles the residual — another gift + card, or a second method — or asks support to cancel the order and reverse the authorization. + +Which reframes what today's ordering is really for, and the ADRs it appears in have been amended +to say so: charging the gift cards first is what keeps the library **out of a partially-paid +state it does not implement**. The recoverability argument is true and secondary; the primary one +is that aborting is simple and settling is not. + +## Open questions — UI/UX first + +The state this creates is not the thank-you page and not the payment step as it stands: money has +been taken, the order is not placed, and something is still owed. It has no design. + +- How is what has **already been paid** presented, given it is not a placed order? The recap + component exists for the thank-you page and assumes the order is done. +- How is the **residual** presented so that it reads as _completing an order_ rather than as + _paying again_? The shopper has already authenticated a payment. +- What does a shopper who abandons see when they come back — and does the storefront's account + area list a `pending` order at all? The API allows reading one, but listing it is the consuming + application's choice, and this recovery path depends on it. +- Does the shopper get told the gift card failed, or only that an amount is outstanding? The + failed card has already vanished from the list by then. + +## Unverified + +- **Re-applying the same gift card may succeed rather than fail.** If the balance was only partly + drained, `applyGiftCard` may create a session for the smaller balance and leave a smaller + residual. Worth checking before designing around an error message. +- A residual can be very small. `amount_cents` must be `> 0`, and gateways enforce minimum + amounts, so a sub-euro residual may be unpayable by any method. diff --git a/packages/core-components/src/payment_sessions/adyenSession.spec.ts b/packages/core-components/src/payment_sessions/adyenSession.spec.ts new file mode 100644 index 00000000..2b182ba7 --- /dev/null +++ b/packages/core-components/src/payment_sessions/adyenSession.spec.ts @@ -0,0 +1,63 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { ADYEN_SETTING_TYPE, isAdyenSession, readAdyenSession } from "./types" + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: { id: "ps-adyen", type: ADYEN_SETTING_TYPE }, + ...overrides, + } as PaymentSession +} + +describe("isAdyenSession", () => { + it("keys off the setting type, not the session type", () => { + expect(isAdyenSession(session())).toBe(true) + expect( + isAdyenSession( + session({ payment_setting: { id: "m", type: "payment_setting_manuals" } as never }) + ) + ).toBe(false) + }) + + it("is false for a missing session", () => { + expect(isAdyenSession(undefined)).toBe(false) + expect(isAdyenSession(null)).toBe(false) + }) +}) + +describe("readAdyenSession", () => { + it("reads Adyen's own field names out of response_data", () => { + const result = readAdyenSession( + session({ response_data: { id: "CS123", sessionData: "Ab02b4c0!BQ" } }) + ) + expect(result).toEqual({ id: "CS123", sessionData: "Ab02b4c0!BQ" }) + }) + + it("ignores the rest of the gateway response", () => { + const result = readAdyenSession( + session({ + response_data: { id: "CS123", sessionData: "blob", expiresAt: "2026-09-03T00:00:00Z" }, + }) + ) + expect(result).toEqual({ id: "CS123", sessionData: "blob" }) + }) + + it("returns undefined when either half is missing", () => { + // A partial Adyen Session is not something to boot a Drop-in from, and it + // is what a `fields` allowlist that omits `response_data` produces. + expect(readAdyenSession(session({ response_data: { id: "CS123" } }))).toBeUndefined() + expect(readAdyenSession(session({ response_data: { sessionData: "blob" } }))).toBeUndefined() + expect( + readAdyenSession(session({ response_data: { id: "", sessionData: "blob" } })) + ).toBeUndefined() + }) + + it("returns undefined when there is no response_data at all", () => { + expect(readAdyenSession(session())).toBeUndefined() + expect(readAdyenSession(session({ response_data: null }))).toBeUndefined() + expect(readAdyenSession(undefined)).toBeUndefined() + }) +}) diff --git a/packages/core-components/src/payment_sessions/authorizeGiftCardSessions.ts b/packages/core-components/src/payment_sessions/authorizeGiftCardSessions.ts new file mode 100644 index 00000000..d389da98 --- /dev/null +++ b/packages/core-components/src/payment_sessions/authorizeGiftCardSessions.ts @@ -0,0 +1,99 @@ +import type { Order } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" +import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +import { hasLiveAuthorization, type PlaceabilityError } from "./types" + +interface AuthorizeGiftCardSessionsParams + extends Pick { + /** The order as last fetched, with `payment_sessions.payment_authorization`. */ + order: Order +} + +export interface AuthorizeGiftCardSessionsResult { + /** + * Ids of the sessions **this call** authorized — not every charged gift card + * on the order. + * + * The distinction is what makes a rollback safe: a card charged by an earlier + * timed-out attempt is not ours to refund, and refunding it would take back + * money for a payment that may yet complete. + */ + authorizedSessionIds: string[] + /** Why it stopped, if it did. */ + errors: PlaceabilityError[] +} + +/** + * Authorize an order's applied gift cards, ahead of the gateway. + * + * `placeOrderWithPaymentSessions` already does this as its first step, and for + * settings with no gateway UI that is the right place. A card is different: the + * money leaves when the shopper submits the Drop-in, which happens *before* the + * place sequence runs — so leaving the gift cards to that sequence would charge + * them after the card, inverting the order — and a refused card must never + * leave gift cards charged after it. + * + * Calling this first restores it, and costs nothing downstream: + * `placeOrderWithPaymentSessions` skips any session that already carries a live + * authorization. **The caller must refetch the order in between** — that skip + * reads the order it was handed, so a stale copy would authorize the same cards + * twice and take the money twice. + * + * Sequential and stopping at the first failure, for the same reason the place + * sequence is: each authorization shrinks what the next session may take, and + * carrying on would charge more cards for an order that is not going to be + * placed. + * + * **Smallest first**, which decides how much is stranded when one of several + * cards fails. Stopping at the first failure leaves everything charged before it + * charged, and a card fails because its balance is gone elsewhere — uncorrelated + * with its size. So charging the small ones first makes the amount left behind + * the smallest it can be: with a $5 and a $50 card, a failure on the second + * strands $5 rather than $50. It changes nothing else, because a session's + * `amount_cents` is fixed when it is created, not when it is charged. + * + * Nothing is rolled back here. Whether the cards already charged should be + * refunded depends on what the *gateway* then does, which this function cannot + * see — see `refundGiftCardSessions`. + */ +export async function authorizeGiftCardSessions({ + accessToken, + interceptors, + order, +}: AuthorizeGiftCardSessionsParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + const { giftCardSessions } = derivePaymentSessionsState(order) + const authorizedSessionIds: string[] = [] + + // Copied before sorting: `giftCardSessions` is derived from the order the + // caller handed us, and reordering it in place would reorder what they see. + // An unknown amount sorts last, where it can strand nothing that a known one + // would not have. + const smallestFirst = [...giftCardSessions].sort( + (a, b) => + (a.amount_cents ?? Number.POSITIVE_INFINITY) - (b.amount_cents ?? Number.POSITIVE_INFINITY) + ) + + for (const session of smallestFirst) { + // Already taken, or in flight. Creating a second authorization over the + // first is how the money gets taken twice. + if (hasLiveAuthorization(session)) continue + + try { + await sdk.payment_authorizations.create({ + payment_session: sdk.payment_sessions.relationship(session.id), + }) + authorizedSessionIds.push(session.id) + } catch (error) { + const errors = mapPlaceabilityErrors(error) + // Not a refusal we can read, so not something the caller can report as + // one. Let it out rather than flatten it into an empty error list. + if (errors.length === 0) throw error + return { authorizedSessionIds, errors } + } + } + + return { authorizedSessionIds, errors: [] } +} diff --git a/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.spec.ts b/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.spec.ts new file mode 100644 index 00000000..88392a62 --- /dev/null +++ b/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest" +import { ADYEN_RETURN_URL_MAX_LENGTH, buildAdyenReturnUrl } from "./buildAdyenReturnUrl" + +describe("buildAdyenReturnUrl", () => { + it("keeps the page the shopper is on", () => { + expect(buildAdyenReturnUrl("https://shop.example/checkout/order-1")).toBe( + "https://shop.example/checkout/order-1" + ) + }) + + it("preserves the application's own query", () => { + expect(buildAdyenReturnUrl("https://shop.example/checkout?orderId=1&lang=it")).toBe( + "https://shop.example/checkout?orderId=1&lang=it" + ) + }) + + it("strips a spent redirectResult so it is not baked into the next session", () => { + // Adyen refuses the same `redirectResult` twice, so a second redirect built + // from the raw location would return a value that is already burnt. + expect( + buildAdyenReturnUrl("https://shop.example/checkout?redirectResult=abc&sessionId=CS1&keep=1") + ).toBe("https://shop.example/checkout?keep=1") + }) + + it("strips resultCode too", () => { + expect(buildAdyenReturnUrl("https://shop.example/c?resultCode=Authorised")).toBe( + "https://shop.example/c" + ) + }) + + it("drops the fragment", () => { + // Adyen appends its parameters as a query string. A returnUrl ending in a + // fragment would come back as `#payment?redirectResult=…`, which nothing + // can read. + expect(buildAdyenReturnUrl("https://shop.example/checkout#payment")).toBe( + "https://shop.example/checkout" + ) + }) + + it("returns an unparseable href untouched rather than inventing one", () => { + expect(buildAdyenReturnUrl("not a url")).toBe("not a url") + }) +}) + +describe("buildAdyenReturnUrl length guard", () => { + it("warns when what it derives cannot be used", () => { + // The failure it replaces named nothing an application had set: Adyen's + // "may not exceed 1024 characters", plus a collateral "token - can't be + // blank" from the session's own validation. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const long = `https://shop.example/o-1?accessToken=${"e".repeat(1200)}` + expect(long.length).toBeGreaterThan(ADYEN_RETURN_URL_MAX_LENGTH) + + buildAdyenReturnUrl(long) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("over Adyen's limit")) + warn.mockRestore() + }) + + it("says nothing about a URL that fits", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + buildAdyenReturnUrl("https://shop.example/o-1?paymentReturn=true") + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.ts b/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.ts new file mode 100644 index 00000000..556e9187 --- /dev/null +++ b/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.ts @@ -0,0 +1,73 @@ +/** + * Query parameters Adyen adds when it sends the shopper back from a 3DS page. + * + * They have to come off before the URL is used as the *next* `returnUrl`: a + * second redirect would otherwise carry the first attempt's `redirectResult`, + * and that value is single-use. + */ +const ADYEN_RETURN_PARAMS = ["redirectResult", "sessionId", "resultCode"] as const + +/** + * Adyen's own cap on `returnUrl`, from gateway version 72 onwards. + * + * Worth a constant and a warning rather than a silent overrun. A URL over the + * cap is refused by Adyen with `Field 'returnUrl' may not exceed 1024 + * characters`, which Commerce Layer relays alongside `token - can't be blank` — + * the token is assigned before the gateway call and not persisted when it + * fails, so the second error is collateral and the pair points at nothing an + * application recognises. A storefront whose credentials live in the query + * string blows past it on the JWT alone. + * + * Older versions do not validate it, and `payment_setting_adyens` defaults to + * the newest version it supports — so an existing setting can work for months + * and a newly created one fail immediately. + */ +export const ADYEN_RETURN_URL_MAX_LENGTH = 1024 + +/** + * Build the `returnUrl` an Adyen Session is created with. + * + * Derived from where the shopper is rather than configured, because the Payment + * Session — and with it the Adyen Session — is created when the radio is + * clicked, by ``, which knows nothing about gateways. A prop + * would have to sit on that generic component to be read in time. + * + * Two things are deliberately stripped, and both are bugs if they survive: + * + * - **Adyen's own return parameters.** Reusing a URL that still carries + * `redirectResult` bakes a spent, single-use value into the new session. + * - **The fragment.** Adyen appends its parameters as a query string, so a + * `returnUrl` ending in `#payment` would come back as + * `…#payment?redirectResult=…` — a fragment, not a query, and nothing can + * read it. Checkouts that keep the step in the hash are common enough that + * this is not a hypothetical. + * + * Any other query the application had is preserved: it is how a storefront + * identifies the page it wants back. + * + * @param href the current location, as `window.location.href` + */ +export function buildAdyenReturnUrl(href: string): string { + let url: URL + try { + url = new URL(href) + } catch { + // Not parseable, so nothing can be cleaned off it. Better to hand Adyen + // what we were given than to invent a URL the shopper never came from. + return href + } + + for (const param of ADYEN_RETURN_PARAMS) url.searchParams.delete(param) + url.hash = "" + + const returnUrl = url.toString() + if (returnUrl.length > ADYEN_RETURN_URL_MAX_LENGTH && process.env.NODE_ENV !== "production") { + console.warn( + `[commercelayer] the Adyen returnUrl derived from this page is ${returnUrl.length} characters, ` + + `over Adyen's limit of ${ADYEN_RETURN_URL_MAX_LENGTH}. Creating the Payment Session will fail. ` + + "Pass `returnUrl` to with a URL this application can reload — commonly the " + + "same one without its access token, re-authenticating the return from storage." + ) + } + return returnUrl +} diff --git a/packages/core-components/src/payment_sessions/createPaymentSession.ts b/packages/core-components/src/payment_sessions/createPaymentSession.ts index 00cae710..3dfa0e81 100644 --- a/packages/core-components/src/payment_sessions/createPaymentSession.ts +++ b/packages/core-components/src/payment_sessions/createPaymentSession.ts @@ -12,6 +12,27 @@ interface CreatePaymentSessionParams extends Pick + /** + * Gateway payload variant, e.g. `"Tokenization"` to have the API inject + * `shopperReference`, `storePaymentMethodMode` and `recurringProcessingModel` + * into the Adyen session. + * + * A trigger attribute, creatable by a sales-channel token and validated by + * name against the setting's available variants — an unknown one is a 422. + */ + internalVersion?: string } /** @@ -43,11 +64,20 @@ export async function createPaymentSession({ orderId, paymentSettingId, amountCents, + clientData, + internalVersion, }: CreatePaymentSessionParams): Promise { const sdk = getSdk({ accessToken, interceptors }) return await sdk.payment_sessions.create({ payment_setting: sdk.payment_settings.relationship(paymentSettingId), order: sdk.orders.relationship(orderId), + ...(clientData != null ? { client_data: clientData } : {}), + // Not in `PaymentSessionCreate` yet, though the API accepts it and there is + // a spec in `core-api` for a sales-channel token sending it. Spread rather + // than written inline because a spread is exempt from excess-property + // checking, which is what lets an attribute the SDK types do not know about + // through without a `@ts-expect-error` that would go stale on the next bump. + ...(internalVersion != null ? { _internal_version: internalVersion } : {}), // A zero or negative amount is rejected by the API (`greater_than: 0`), and // there is nothing left to pay anyway — fall back to the server's own // sizing rather than sending a value that cannot be valid. diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts index 3b2ced70..41559129 100644 --- a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts @@ -108,11 +108,20 @@ describe("derivePaymentSessionsState", () => { } ) - it("drops a refunded gift card", () => { + it("does not drop a card on the strength of a refunds array alone", () => { + // This test used to assert the opposite, and it was green while the + // behaviour was broken: the old check read `payment_refunds`, which needs + // `payment_sessions.payment_refunds` in the order's `include` and nothing + // registers it — so against a real order the check never fired and a + // refunded card went on covering the order. The fixture described a state + // the API never serves. + // + // Kept as a regression guard: the signal is `status`, and a refund that has + // not settled yet has not moved the money either. const state = derivePaymentSessionsState( order([giftCard("gift-a", 2000, { payment_refunds: [{ id: "refund-1" }] as never })]) ) - expect(state.giftCardSessions).toEqual([]) + expect(state.giftCardSessions).toHaveLength(1) }) it("keeps a gift card whose authorization is still in flight", () => { @@ -181,3 +190,99 @@ describe("derivePaymentSessionsState", () => { expect(state.giftCardSettingId).toBeUndefined() }) }) + +describe("a gift card whose money has come back", () => { + const AUTHORIZED = { + payment_authorization: { status: "succeeded" }, + } as Partial + + it("stops counting once the session reads refunded", () => { + // The signal has to be `status`. `payment_refunds` needs an include nobody + // registers, so a check on that array never fires — and a refunded card + // would go on covering an order it no longer pays for. + const state = derivePaymentSessionsState( + order([giftCard("gc-1", 2000, { ...AUTHORIZED, status: "refunded" })]) + ) + expect(state.giftCardSessions).toEqual([]) + expect(state.giftCardAmountCents).toBe(0) + expect(state.remainingAmountCents).toBe(TOTAL) + expect(state.isCovered).toBe(false) + }) + + it("stops counting a partially refunded one too", () => { + const state = derivePaymentSessionsState( + order([giftCard("gc-1", 2000, { ...AUTHORIZED, status: "partially_refunded" })]) + ) + expect(state.giftCardSessions).toEqual([]) + }) + + it("stops counting a voided one", () => { + const state = derivePaymentSessionsState( + order([giftCard("gc-1", 2000, { ...AUTHORIZED, status: "voided" })]) + ) + expect(state.giftCardSessions).toEqual([]) + }) + + it("keeps listing a charged card while its refund is still pending", () => { + // The money has not moved yet — the session only reaches `refunded` when it + // has. Hiding it earlier would raise the remainder while the balance is + // still spent, and the shopper would be asked to pay that part twice. + const state = derivePaymentSessionsState( + order([giftCard("gc-1", 2000, { ...AUTHORIZED, status: "paid" })]) + ) + expect(state.giftCardSessions).toHaveLength(1) + expect(state.remainingAmountCents).toBe(TOTAL - 2000) + }) + + it("leaves the other cards on the order counting", () => { + const state = derivePaymentSessionsState( + order([giftCard("gc-1", 2000, { ...AUTHORIZED, status: "refunded" }), giftCard("gc-2", 1500)]) + ) + expect(state.giftCardSessions.map((s) => s.id)).toEqual(["gc-2"]) + expect(state.remainingAmountCents).toBe(TOTAL - 1500) + }) +}) + +describe("after a refund, the order stops being covered by that money", () => { + const SETTLED = { + payment_authorization: { status: "succeeded" }, + } as Partial + + it("lets another gift card be applied again", () => { + // The bug this pins: a refunded session keeps its `succeeded` + // authorization, so a test on the authorization alone left this false for + // good — and the gift card input renders on it, so the shopper whose card + // had just been given back could never apply another one. + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { ...SETTLED, status: "refunded" })]) + ) + expect(state.remainingAmountCents).toBe(TOTAL) + expect(state.canAddGiftCard).toBe(true) + }) + + it("still refuses another one while money is genuinely held", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { ...SETTLED, status: "paid" })]) + ) + expect(state.canAddGiftCard).toBe(false) + }) + + it("stops counting a refunded method session toward the remainder", () => { + const state = derivePaymentSessionsState( + order([ + session({ id: "adyen-1", amount_cents: 5100, ...SETTLED, status: "refunded" } as never), + ]) + ) + expect(state.remainingAmountCents).toBe(TOTAL) + expect(state.isCovered).toBe(false) + }) + + it("keeps counting one that is still holding the money", () => { + const state = derivePaymentSessionsState( + order([ + session({ id: "adyen-1", amount_cents: 5100, ...SETTLED, status: "authorized" } as never), + ]) + ) + expect(state.remainingAmountCents).toBe(TOTAL - 5100) + }) +}) diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts index 1b5f6b76..c52d5a0a 100644 --- a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts @@ -1,6 +1,12 @@ import type { Order, PaymentSession } from "@commercelayer/sdk" import { findCurrentPaymentSession } from "./findCurrentPaymentSession" -import { GIFT_CARD_SETTING_TYPE, hasLiveAuthorization, isGiftCardSession } from "./types" +import { + GIFT_CARD_SETTING_TYPE, + hasLiveAuthorization, + hasReturnedMoney, + holdsMoney, + isGiftCardSession, +} from "./types" export interface PaymentSessionsState { /** @@ -35,7 +41,7 @@ export interface PaymentSessionsState { * * False once anything has been authorized: money is taken or in flight, and * settling a partially-paid order is a flow this iteration does not - * implement. See the place-order ADR. + * implement. */ canAddGiftCard: boolean /** The non-gift-card session paying the difference, if the shopper picked one. */ @@ -64,10 +70,11 @@ export function derivePaymentSessionsState(order?: Order | null): PaymentSession const giftCardAmountCents = sumAmounts(giftCardSessions) // A method session reduces what is left only once it has taken money — an - // unauthorized one is just an intent. Gift cards count as soon as applied, - // which is the whole reason this derivation exists. + // unauthorized one is just an intent, and one that has given the money back + // is history. Gift cards count as soon as applied, which is the whole reason + // this derivation exists. const takenMethodAmountCents = sumAmounts( - sessions.filter((session) => !isGiftCardSession(session) && hasLiveAuthorization(session)) + sessions.filter((session) => !isGiftCardSession(session) && holdsMoney(session)) ) const remainingAmountCents = Math.max(0, total - giftCardAmountCents - takenMethodAmountCents) @@ -84,7 +91,14 @@ export function derivePaymentSessionsState(order?: Order | null): PaymentSession giftCardAmountCents, remainingAmountCents, isCovered, - canAddGiftCard: remainingAmountCents > 0 && !sessions.some(hasLiveAuthorization), + // Nothing more may be applied once money has been taken: settling a + // partially-paid order is a flow this iteration does not implement. + // + // `holdsMoney` and not `hasLiveAuthorization`: a refunded session keeps its + // `succeeded` authorization, so the narrower test left this false for good + // once any card had been charged and given back — and the gift card input, + // which renders on this, never came back for the shopper who needed it most. + canAddGiftCard: remainingAmountCents > 0 && !sessions.some(holdsMoney), currentPaymentSession: findCurrentPaymentSession({ paymentSessions: sessions }), giftCardSettingId: (order?.available_payment_settings ?? []).find( (setting) => setting.type === GIFT_CARD_SETTING_TYPE @@ -95,14 +109,30 @@ export function derivePaymentSessionsState(order?: Order | null): PaymentSession /** * A gift card session still worth showing: nothing failed and nothing was given * back. A refunded one is history, not an applied card. + * + * The "given back" half is read from the session's `status`, **not** from + * `payment_refunds`. That relationship needs + * `payment_sessions.payment_refunds` in the order's `include` and nothing + * registers it — so a check on the array never fires, and a card whose money + * had been returned would go on being listed and on being deducted from the + * remainder. The order would then show a coverage it does not have, size a new + * session against the wrong amount, and keep the place-order button live on the + * strength of it. + * + * A refund that is still `pending` deliberately leaves the card listed: the + * money has not moved yet, and the session only reaches `refunded` when it has. + * Hiding it earlier would raise the remainder while the balance is still spent. */ function isLiveGiftCard(session: PaymentSession): boolean { - if ((session.payment_refunds ?? []).length > 0) return false + if (hasReturnedMoney(session)) return false const status = session.payment_authorization?.status if (status == null) return true return hasLiveAuthorization(session) } +// `holdsMoney` is the pair of these two asked together; see its note in +// `types.ts` for why asking only the first is a bug and not a shortcut. + function sumAmounts(sessions: PaymentSession[]): number { return sessions.reduce((total, session) => total + (session.amount_cents ?? 0), 0) } diff --git a/packages/core-components/src/payment_sessions/discardPaymentSession.ts b/packages/core-components/src/payment_sessions/discardPaymentSession.ts new file mode 100644 index 00000000..69167b3d --- /dev/null +++ b/packages/core-components/src/payment_sessions/discardPaymentSession.ts @@ -0,0 +1,46 @@ +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface DiscardPaymentSessionParams extends Pick { + paymentSessionId: string +} + +/** + * Delete a Payment Session that must not be used again, best effort. + * + * Used when a gateway refuses a payment. The Adyen Session survives a refusal + * client-side — `adyen-web` rolls its `sessionData` forward and would happily + * re-POST — but retrying on it is broken *server-side*: the refusal arrives as + * an `AUTHORISATION` webhook that lands a `failed` Payment Authorization on the + * session, and a later success then calls `succeed!` on a `failed` record. That + * is not a legal transition, `whiny_transitions` is at its default, and the job + * has `retry: 0` — so the retry's success would be dropped in silence. + * + * Deleting is chosen over waiting because the timing is not observable from + * here: immediately after the refusal the failed authorization has not arrived + * yet, so the session still reads as the current selection *and* as reusable. + * + * **Failures are swallowed, and the design still holds.** The API refuses to + * delete a session with transactions attached (`dependent: + * :restrict_with_exception`, surfaced as an unhandled 500), which is precisely + * the case where the failed authorization has already landed — and a session in + * that state is excluded by both `findCurrentPaymentSession` and + * `findReusablePaymentSession` anyway. The two mechanisms cover the same hole + * from opposite sides, so there is no outcome where a burnt session is adopted. + * + * @returns whether the session is known to be gone + */ +export async function discardPaymentSession({ + accessToken, + interceptors, + paymentSessionId, +}: DiscardPaymentSessionParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + try { + await sdk.payment_sessions.delete(paymentSessionId) + return true + } catch { + // See above: the authorization state excludes it regardless. + return false + } +} diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts index 5b01b4a9..261ab9ad 100644 --- a/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts @@ -5,13 +5,16 @@ import { findReusablePaymentSession } from "./findReusablePaymentSession" const NOW = new Date("2026-08-18T12:00:00Z") const SETTING_ID = "setting-manual" +// Real timestamps, because the selection is defined by recency: a fixture with +// an empty `created_at` makes `Date.parse` return NaN, every comparison false, +// and the ordering an accident of array position. function session(overrides: Partial = {}): PaymentSession { return { id: "session-1", type: "payment_sessions", status: "unpaid", - created_at: "", - updated_at: "", + created_at: "2026-08-18T11:00:00Z", + updated_at: "2026-08-18T11:00:00Z", payment_setting: { id: SETTING_ID, type: "payment_setting_manuals" }, ...overrides, } as PaymentSession @@ -118,10 +121,21 @@ describe("findReusablePaymentSession", () => { } ) - it("searches the array rather than reading the first entry", () => { - const giftCard = session({ id: "gift", payment_setting: { id: "setting-gift-card" } as never }) - const burnt = session({ id: "burnt", payment_authorization: { status: "failed" } as never }) - const fresh = session({ id: "fresh" }) + it("is not shadowed by a gift card or a burnt session", () => { + // Neither is the selection — one is additive, the other is failed — so the + // newest live session for this setting is still adoptable. + const giftCard = session({ + id: "gift", + created_at: "2026-08-18T11:30:00Z", + gift_card_code: "ABC123", + payment_setting: { id: "setting-gift-card", type: "payment_setting_gift_cards" } as never, + }) + const burnt = session({ + id: "burnt", + created_at: "2026-08-18T11:40:00Z", + payment_authorization: { status: "failed" } as never, + }) + const fresh = session({ id: "fresh", created_at: "2026-08-18T11:20:00Z" }) expect( findReusablePaymentSession({ paymentSessions: [giftCard, burnt, fresh], @@ -131,6 +145,43 @@ describe("findReusablePaymentSession", () => { ).toBe(fresh) }) + /** + * The regression this rule exists for. + * + * A shopper picks Adyen, changes to bank transfer, then changes back. The + * first Adyen session is still unpaid, unexpired and the right size, so it + * used to be adopted — and adopting changes no timestamp, so the newest + * session stayed the bank transfer one, the radio never moved, and clicking + * again did nothing again. Found on a real order carrying two unpaid sessions + * nine seconds apart. + */ + it("does not adopt a session that a later selection has superseded", () => { + const adyen = session({ + id: "adyen", + created_at: "2026-08-18T11:57:54Z", + payment_setting: { id: "setting-adyen", type: "payment_setting_adyens" } as never, + }) + const manual = session({ id: "manual", created_at: "2026-08-18T11:58:03Z" }) + + expect( + findReusablePaymentSession({ + paymentSessions: [adyen, manual], + paymentSettingId: "setting-adyen", + now: NOW, + }) + ).toBeUndefined() + + // And the one that *is* the selection stays adoptable, so a remount does + // not pile up a third session. + expect( + findReusablePaymentSession({ + paymentSessions: [adyen, manual], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(manual) + }) + it("does not decide on statuses it has never heard of", () => { const unknown = session({ status: "some_future_state" }) expect( diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts index 535e6ad8..4dd694a3 100644 --- a/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts @@ -1,4 +1,5 @@ import type { PaymentSession } from "@commercelayer/sdk" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" import { TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" interface FindReusablePaymentSessionParams { @@ -44,7 +45,24 @@ export function findReusablePaymentSession({ amountCents, now = new Date(), }: FindReusablePaymentSessionParams): PaymentSession | undefined { - return (paymentSessions ?? []).find((session) => { + // Only the session that **is** the current selection may be adopted. + // + // Not a narrowing for tidiness: adopting any other one is unobservable, and + // therefore a bug. The selection is defined as the newest non-gift-card + // session (see `findCurrentPaymentSession`), and adopting changes no + // timestamp — so reusing an older session leaves the radio group pointing + // where it already pointed, the click does nothing, and clicking again does + // nothing again. A shopper who picks Adyen, switches to bank transfer, then + // changes their mind can never get back: their first Adyen session is still + // adoptable, so no new one is created, so Adyen never becomes the newest. + // + // Both reasons reuse exists for are cases where the candidate *is* the + // selection — a remount and a page reload — so nothing is lost by requiring + // it, and switching back now creates a session that can actually be selected. + const candidate = findCurrentPaymentSession({ paymentSessions }) + if (candidate == null) return undefined + + return [candidate].find((session) => { if (session.payment_setting?.id !== paymentSettingId) return false // Only when both numbers are known: an order fetched without diff --git a/packages/core-components/src/payment_sessions/giftCardRemoval.spec.ts b/packages/core-components/src/payment_sessions/giftCardRemoval.spec.ts new file mode 100644 index 00000000..a3847f8e --- /dev/null +++ b/packages/core-components/src/payment_sessions/giftCardRemoval.spec.ts @@ -0,0 +1,92 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { giftCardRemoval } from "./giftCardRemoval" + +function giftCard(overrides: Partial = {}): PaymentSession { + return { + id: "gc-1", + type: "payment_sessions", + status: "unpaid", + amount_cents: 2000, + payment_setting: { id: "ps-gift", type: "payment_setting_gift_cards" }, + ...overrides, + } as PaymentSession +} + +function order(status = "pending"): Order { + return { id: "order-1", type: "orders", status } as Order +} + +const SETTLED = { payment_authorization: { status: "succeeded" } } as Partial + +describe("giftCardRemoval", () => { + it("lets an unauthorized card be discarded", () => { + // Nothing was taken, so deleting the session touches no balance. + expect(giftCardRemoval({ paymentSession: giftCard(), order: order() })).toBe("discard") + }) + + it("lets a captured card be refunded while the order is still pending", () => { + expect( + giftCardRemoval({ + paymentSession: giftCard({ ...SETTLED, status: "paid" }), + order: order(), + }) + ).toBe("refund") + }) + + it("offers nothing once the order has been placed", () => { + // A storefront token's refund grant names `pending` exactly. This is the + // painful case — a timed-out place with the cards already charged — and it + // has to read as "cannot" rather than as a control that fails. + expect( + giftCardRemoval({ + paymentSession: giftCard({ ...SETTLED, status: "paid" }), + order: order("placed"), + }) + ).toBeUndefined() + }) + + it.each(["pending", "processing"])("offers nothing while the charge is %s", (status) => { + // Neither route is open: the API refuses to delete a session with + // transactions attached, and a refund has no capture to point at yet. + expect( + giftCardRemoval({ + paymentSession: giftCard({ payment_authorization: { status } as never }), + order: order(), + }) + ).toBeUndefined() + }) + + it("offers nothing for an authorized card that has not been captured", () => { + // Transient for gift cards, since the setting forces auto-capture — but + // requiring the captured state is what makes the refund reliable. + expect( + giftCardRemoval({ + paymentSession: giftCard({ ...SETTLED, status: "authorized" }), + order: order(), + }) + ).toBeUndefined() + }) + + it("discards a card whose authorization failed", () => { + // It took no money, so the session is inert and deletable. + expect( + giftCardRemoval({ + paymentSession: giftCard({ payment_authorization: { status: "declined" } as never }), + order: order(), + }) + ).toBe("discard") + }) + + it("offers nothing in a readonly subtree", () => { + expect( + giftCardRemoval({ paymentSession: giftCard(), order: order(), readonly: true }) + ).toBeUndefined() + }) + + it("offers nothing without an order", () => { + expect( + giftCardRemoval({ paymentSession: giftCard({ ...SETTLED, status: "paid" }) }) + ).toBeUndefined() + }) +}) diff --git a/packages/core-components/src/payment_sessions/giftCardRemoval.ts b/packages/core-components/src/payment_sessions/giftCardRemoval.ts new file mode 100644 index 00000000..977972a9 --- /dev/null +++ b/packages/core-components/src/payment_sessions/giftCardRemoval.ts @@ -0,0 +1,76 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { hasLiveAuthorization } from "./types" + +/** + * How an applied gift card can be taken back off an order. + * + * Two different domain operations behind one gesture, and the difference is + * worth exposing: an application wants to word them differently, may want to + * confirm the second, and has to explain that the second is slow. + */ +export type GiftCardRemoval = + /** Nothing was charged: the Payment Session is deleted and no balance moves. */ + | "discard" + /** + * The card was charged, so the only way back is a `PaymentRefund`. Slower — a + * background job, then a poll — and a real accounting record: it restores the + * balance and leaves an entry in the card's usage log. + */ + | "refund" + +interface GiftCardRemovalParams { + /** The gift card Payment Session in question. */ + paymentSession: PaymentSession + /** The order it belongs to. Its status decides whether a refund is permitted. */ + order?: Order | null + /** True when the subtree is a recap rather than a form. */ + readonly?: boolean +} + +/** + * Decide how — or whether — a gift card can come off the order. + * + * `undefined` means it cannot, and the control must not be rendered rather than + * rendered and failing. There are three ways to get there, and only one of them + * is permanent: + * + * - **The subtree is readonly.** A recap, not a form. + * - **The order has left `pending`.** A storefront token's refund grant names + * that status exactly — `payment_type: 'GIFT_CARD'` *and* `order: { status: + * 'pending' }` — so on a placed order there is no refund to offer at all. + * This is the painful case: a place that timed out with the cards already + * charged is exactly when a shopper most wants the money back, and a + * storefront cannot give it to them. + * - **The charge is still settling.** Between the authorization being created + * and the background job capturing it, neither route is open: the API refuses + * to delete a session with transactions attached (an unhandled 500), and a + * refund has no capture to point at yet. It lasts seconds and resolves on its + * own, which is why it reads the same as "cannot" rather than getting a state + * of its own — a control that appears, fails, and then works would be worse + * than one that appears a moment late. + * + * Requiring a captured state rather than merely a live authorization is what + * makes the refund path reliable: for gift cards the setting forces + * auto-capture, so `paid` is reached *through* the capture — by the time this + * returns `"refund"`, the record a refund needs already exists. + */ +export function giftCardRemoval({ + paymentSession, + order, + readonly, +}: GiftCardRemovalParams): GiftCardRemoval | undefined { + if (readonly === true) return undefined + + // Nothing taken, so the session is inert and deleting it touches no balance. + if (!hasLiveAuthorization(paymentSession)) return "discard" + + if (order?.status !== "pending") return undefined + + // Captured, so there is something to refund against. `authorized` is skipped + // deliberately: it is the transient step before auto-capture lands. + if (paymentSession.status !== "paid" && paymentSession.status !== "partially_paid") { + return undefined + } + + return "refund" +} diff --git a/packages/core-components/src/payment_sessions/giftCardRollback.spec.ts b/packages/core-components/src/payment_sessions/giftCardRollback.spec.ts new file mode 100644 index 00000000..6f9f67a8 --- /dev/null +++ b/packages/core-components/src/payment_sessions/giftCardRollback.spec.ts @@ -0,0 +1,375 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { authorizeGiftCardSessions } from "./authorizeGiftCardSessions" +import { refundGiftCardSessions } from "./refundGiftCardSessions" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ACCESS_TOKEN = "token" +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } +const ADYEN = { id: "ps-adyen", type: "payment_setting_adyens" } + +function giftCard(id: string, overrides: Partial = {}): PaymentSession { + return { + id, + type: "payment_sessions", + status: "unpaid", + amount_cents: 2000, + payment_setting: GIFT_CARD, + ...overrides, + } as PaymentSession +} + +function order(sessions: PaymentSession[]): Order { + return { + id: "order-1", + type: "orders", + total_amount_with_taxes_cents: 7100, + payment_sessions: sessions, + available_payment_settings: [GIFT_CARD, ADYEN], + } as Order +} + +/** A 422 shaped the way the SDK surfaces one. */ +function apiError(detail: string) { + return { + errors: [ + { code: "VALIDATION_ERROR", detail, source: { pointer: "/data/attributes/payment_action" } }, + ], + } +} + +function stubSdk(overrides: Record = {}) { + const create = vi.fn().mockResolvedValue({ id: "auth-1" }) + const refundCreate = vi.fn().mockResolvedValue({ id: "refund-1" }) + const retrieve = vi.fn() + getSdkMock.mockReturnValue({ + payment_authorizations: { create }, + payment_refunds: { create: refundCreate }, + payment_sessions: { relationship: (id: string) => ({ id, type: "payment_sessions" }) }, + payment_captures: { relationship: (id: string) => ({ id, type: "payment_captures" }) }, + orders: { retrieve }, + ...overrides, + }) + return { create, refundCreate, retrieve } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("authorizeGiftCardSessions", () => { + it("authorizes every applied card and reports which ones it charged", async () => { + const { create } = stubSdk() + + const result = await authorizeGiftCardSessions({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gc-1"), giftCard("gc-2")]), + }) + + expect(create).toHaveBeenCalledTimes(2) + expect(result.authorizedSessionIds).toEqual(["gc-1", "gc-2"]) + expect(result.errors).toEqual([]) + }) + + it("leaves the session paying the difference alone", async () => { + // The gateway takes that one, and it takes it before this runs. + const { create } = stubSdk() + const method = { + id: "adyen-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: ADYEN, + } as PaymentSession + + const result = await authorizeGiftCardSessions({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gc-1"), method]), + }) + + expect(create).toHaveBeenCalledTimes(1) + expect(result.authorizedSessionIds).toEqual(["gc-1"]) + }) + + it("skips a card that already carries a live authorization", async () => { + // Creating a second authorization over the first is how the money gets + // taken twice — the case a stale order would produce. + const { create } = stubSdk() + + const result = await authorizeGiftCardSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gc-1", { payment_authorization: { status: "pending" } as never }), + giftCard("gc-2"), + ]), + }) + + expect(create).toHaveBeenCalledTimes(1) + expect(result.authorizedSessionIds).toEqual(["gc-2"]) + }) + + /** + * Which card is charged first decides how much is stranded when a later one + * fails, because nothing is rolled back. A card fails when its balance has + * gone elsewhere, which has nothing to do with its size — so charging the + * small ones first makes the amount left behind the smallest it can be. + */ + it("charges the smallest card first, so a later failure strands less", async () => { + const { create } = stubSdk() + await authorizeGiftCardSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("ps-big", { amount_cents: 5000 }), + giftCard("ps-small", { amount_cents: 500 }), + giftCard("ps-mid", { amount_cents: 1500 }), + ]), + }) + + expect(create.mock.calls.map(([body]) => body.payment_session.id)).toEqual([ + "ps-small", + "ps-mid", + "ps-big", + ]) + }) + + it("charges a card of unknown size last", async () => { + // An order fetched without `amount_cents` in its `fields` must not push an + // unknown to the front, where it could strand more than any known one. + const { create } = stubSdk() + await authorizeGiftCardSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("ps-unknown", { amount_cents: null as never }), + giftCard("ps-small", { amount_cents: 500 }), + ]), + }) + + expect(create.mock.calls.map(([body]) => body.payment_session.id)).toEqual([ + "ps-small", + "ps-unknown", + ]) + }) + + it("stops at the first refusal rather than charging more cards", async () => { + const { create } = stubSdk() + create.mockResolvedValueOnce({ id: "auth-1" }) + create.mockRejectedValueOnce(apiError("Gift card balance is insufficient.")) + + const result = await authorizeGiftCardSessions({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gc-1"), giftCard("gc-2"), giftCard("gc-3")]), + }) + + expect(create).toHaveBeenCalledTimes(2) + expect(result.authorizedSessionIds).toEqual(["gc-1"]) + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.message).toBe("Gift card balance is insufficient.") + }) + + it("rethrows an error it cannot read as a refusal", async () => { + const { create } = stubSdk() + create.mockRejectedValueOnce(new Error("socket hang up")) + + await expect( + authorizeGiftCardSessions({ accessToken: ACCESS_TOKEN, order: order([giftCard("gc-1")]) }) + ).rejects.toThrow("socket hang up") + }) +}) + +describe("refundGiftCardSessions", () => { + const capture = (id: string) => ({ id, status: "succeeded", refund_balance_cents: 2000 }) as never + + /** A card still holding the money, with a capture to refund against. */ + const charged = (captureId = "cap-1") => + order([giftCard("gc-1", { status: "paid", payment_captures: [capture(captureId)] })]) + + /** The same card once the refund job has settled — the server's own signal. */ + const returned = () => + order([ + giftCard("gc-1", { + status: "refunded", + payment_captures: [capture("cap-1")], + payment_refunds: [{ id: "refund-1", status: "succeeded" } as never], + }), + ]) + + it("does nothing when asked for nothing", async () => { + const { retrieve } = stubSdk() + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: [], + }) + expect(retrieve).not.toHaveBeenCalled() + expect(result).toEqual({ refundedSessionIds: [], errors: [], timedOut: false }) + }) + + it("refunds against the capture the authorization produced", async () => { + const { retrieve, refundCreate } = stubSdk() + retrieve.mockResolvedValueOnce(charged()).mockResolvedValue(returned()) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + intervalMs: 0, + }) + + expect(refundCreate).toHaveBeenCalledWith({ + payment_session: { id: "gc-1", type: "payment_sessions" }, + payment_capture: { id: "cap-1", type: "payment_captures" }, + }) + expect(result).toEqual({ refundedSessionIds: ["gc-1"], errors: [], timedOut: false }) + }) + + it("waits for the refund to settle, not just to be created", async () => { + // The regression this budget exists for. A refund starts `pending` and + // another job closes it, so reporting success on the create told the shopper + // their card was off the order while it was still charged — and the row + // stayed on screen, because the row goes on the session's own status. + const { retrieve, refundCreate } = stubSdk() + retrieve + .mockResolvedValueOnce(charged()) + .mockResolvedValueOnce(charged()) + .mockResolvedValue(returned()) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + intervalMs: 0, + }) + + // Created once, then waited on rather than created again. + expect(refundCreate).toHaveBeenCalledTimes(1) + expect(retrieve.mock.calls.length).toBeGreaterThanOrEqual(3) + expect(result.refundedSessionIds).toEqual(["gc-1"]) + expect(result.timedOut).toBe(false) + }) + + it("does not create a second refund while the first is still pending", async () => { + const { retrieve, refundCreate } = stubSdk() + // Charged, with an unsettled refund already attached. + retrieve.mockResolvedValue( + order([ + giftCard("gc-1", { + status: "paid", + payment_captures: [capture("cap-1")], + payment_refunds: [{ id: "refund-1", status: "pending" } as never], + }), + ]) + ) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + attempts: 3, + intervalMs: 0, + }) + + expect(refundCreate).not.toHaveBeenCalled() + expect(result.timedOut).toBe(true) + }) + + it("waits for the capture the background job has not created yet", async () => { + const { retrieve, refundCreate } = stubSdk() + retrieve + .mockResolvedValueOnce(order([giftCard("gc-1", { payment_captures: [] })])) + .mockResolvedValueOnce(charged()) + .mockResolvedValue(returned()) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + intervalMs: 0, + }) + + expect(refundCreate).toHaveBeenCalledTimes(1) + expect(result.timedOut).toBe(false) + }) + + it("ignores a capture that has not succeeded yet", async () => { + const { retrieve, refundCreate } = stubSdk() + retrieve.mockResolvedValue( + order([giftCard("gc-1", { payment_captures: [{ id: "cap-1", status: "pending" } as never] })]) + ) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + attempts: 2, + intervalMs: 0, + }) + + expect(refundCreate).not.toHaveBeenCalled() + expect(result.timedOut).toBe(true) + }) + + it("treats a session whose money is already back as done", async () => { + const { retrieve, refundCreate } = stubSdk() + retrieve.mockResolvedValue(returned()) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + intervalMs: 0, + }) + + expect(refundCreate).not.toHaveBeenCalled() + expect(result).toEqual({ refundedSessionIds: ["gc-1"], errors: [], timedOut: false }) + }) + + it("carries on to the next card when one refund is refused", async () => { + // Unlike authorizing, refunds do not change what the next one may take, so + // giving up on the second would leave the third charged for no reason. + const { retrieve, refundCreate } = stubSdk() + const bothCharged = order([ + giftCard("gc-1", { status: "paid", payment_captures: [capture("cap-1")] }), + giftCard("gc-2", { status: "paid", payment_captures: [capture("cap-2")] }), + ]) + const secondReturned = order([ + giftCard("gc-1", { status: "paid", payment_captures: [capture("cap-1")] }), + giftCard("gc-2", { + status: "refunded", + payment_captures: [capture("cap-2")], + payment_refunds: [{ id: "refund-2", status: "succeeded" } as never], + }), + ]) + retrieve.mockResolvedValueOnce(bothCharged).mockResolvedValue(secondReturned) + refundCreate.mockRejectedValueOnce(apiError("Refund amount exceeds the capture.")) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1", "gc-2"], + intervalMs: 0, + }) + + expect(refundCreate).toHaveBeenCalledTimes(2) + expect(result.refundedSessionIds).toEqual(["gc-2"]) + expect(result.errors).toHaveLength(1) + expect(result.timedOut).toBe(false) + }) + + it("reports a timeout instead of claiming a completed rollback", async () => { + const { retrieve } = stubSdk() + retrieve.mockResolvedValue(order([giftCard("gc-1", { payment_captures: [] })])) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + attempts: 3, + intervalMs: 0, + }) + + expect(retrieve).toHaveBeenCalledTimes(3) + expect(result.timedOut).toBe(true) + expect(result.refundedSessionIds).toEqual([]) + }) +}) diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts index 3f2240ef..fda4d1e7 100644 --- a/packages/core-components/src/payment_sessions/index.ts +++ b/packages/core-components/src/payment_sessions/index.ts @@ -1,22 +1,37 @@ export { applyGiftCard } from "./applyGiftCard" +export type { AuthorizeGiftCardSessionsResult } from "./authorizeGiftCardSessions" +export { authorizeGiftCardSessions } from "./authorizeGiftCardSessions" +export { ADYEN_RETURN_URL_MAX_LENGTH, buildAdyenReturnUrl } from "./buildAdyenReturnUrl" export { createPaymentSession } from "./createPaymentSession" export type { PaymentSessionsState } from "./derivePaymentSessionsState" export { derivePaymentSessionsState } from "./derivePaymentSessionsState" +export { discardPaymentSession } from "./discardPaymentSession" export { findCurrentPaymentSession } from "./findCurrentPaymentSession" export { findReusablePaymentSession } from "./findReusablePaymentSession" export type { PaymentsModel } from "./getPaymentsModel" export { getPaymentsModel } from "./getPaymentsModel" +export type { GiftCardRemoval } from "./giftCardRemoval" +export { giftCardRemoval } from "./giftCardRemoval" export { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" export { mapGiftCardErrors } from "./mapGiftCardErrors" export { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" export type { PlaceOrderWithPaymentSessionsResult } from "./placeOrderWithPaymentSessions" export { + DEFAULT_GATEWAY_PLACEABLE_ATTEMPTS, + DEFAULT_GATEWAY_PLACEABLE_INTERVAL_MS, DEFAULT_PLACEABLE_ATTEMPTS, DEFAULT_PLACEABLE_INTERVAL_MS, placeOrderWithPaymentSessions, } from "./placeOrderWithPaymentSessions" +export type { RefundGiftCardSessionsResult } from "./refundGiftCardSessions" +export { + DEFAULT_REFUND_ATTEMPTS, + DEFAULT_REFUND_INTERVAL_MS, + refundGiftCardSessions, +} from "./refundGiftCardSessions" export { removeGiftCard } from "./removeGiftCard" export type { + AdyenSession, KnownPaymentSessionStatus, KnownPaymentTransactionStatus, PaymentSessionStatus, @@ -24,9 +39,17 @@ export type { PlaceabilityError, } from "./types" export { + ADYEN_SETTING_TYPE, GIFT_CARD_SETTING_TYPE, + hasAuthorizationInFlight, + hasFailedAuthorization, hasLiveAuthorization, + hasReturnedMoney, + holdsMoney, + isAdyenSession, isGiftCardSession, + MONEY_RETURNED_SESSION_STATUSES, PAYMENT_TAKEN_SESSION_STATUSES, + readAdyenSession, TERMINAL_FAILURE_TRANSACTION_STATUSES, } from "./types" diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts index 25e92910..42a44a39 100644 --- a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts @@ -22,6 +22,24 @@ export const DEFAULT_PLACEABLE_ATTEMPTS = 8 /** Delay between placeability attempts, in milliseconds. */ export const DEFAULT_PLACEABLE_INTERVAL_MS = 500 +/** + * Attempts to use when a **gateway** collected the payment client-side. + * + * The defaults above are sized for a setting whose authorization is a local + * background job — manual, gift card — where the whole wait is one Sidekiq hop. + * A card taken through Adyen's Drop-in is different in kind: Commerce Layer's + * own gateway call fails by construction, and the authorization only reaches + * `succeeded` when Adyen's `AUTHORISATION` webhook arrives. That is a round trip + * through a third party, and four seconds is not a realistic budget for it. + * + * Exhausting these is still **not** a payment failure — the webhook may land a + * moment later — which is why the result reports `timedOut` separately from + * `errors`. + */ +export const DEFAULT_GATEWAY_PLACEABLE_ATTEMPTS = 20 +/** Delay between gateway placeability attempts, in milliseconds. */ +export const DEFAULT_GATEWAY_PLACEABLE_INTERVAL_MS = 1000 + /** Order includes the placeability loop needs to read authorization states. */ const AUTHORIZATION_INCLUDES = ["payment_sessions.payment_authorization"] diff --git a/packages/core-components/src/payment_sessions/refundGiftCardSessions.ts b/packages/core-components/src/payment_sessions/refundGiftCardSessions.ts new file mode 100644 index 00000000..9669bb47 --- /dev/null +++ b/packages/core-components/src/payment_sessions/refundGiftCardSessions.ts @@ -0,0 +1,187 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +import { hasReturnedMoney, type PlaceabilityError } from "./types" + +/** + * Attempts spent waiting for the capture a refund points at, and then for the + * refund itself to settle. + * + * Both waits are background jobs, so the budget covers two of them. It was half + * this while the function returned as soon as it had *created* a refund — which + * read as success on a session whose money had not come back yet, so the row + * stayed on screen and an end-to-end test failed on a timing accident. + */ +export const DEFAULT_REFUND_ATTEMPTS = 12 +/** Delay between those attempts, in milliseconds. */ +export const DEFAULT_REFUND_INTERVAL_MS = 500 + +/** + * Includes needed to find the capture to refund, and to tell an already + * refunded session from one still to do. + */ +const REFUND_INCLUDES = ["payment_sessions.payment_captures", "payment_sessions.payment_refunds"] + +interface RefundGiftCardSessionsParams extends Pick { + orderId: string + /** + * The sessions to give back — normally the `authorizedSessionIds` from the + * `authorizeGiftCardSessions` call in the same attempt, never every charged + * card on the order. + */ + paymentSessionIds: string[] + attempts?: number + intervalMs?: number +} + +export interface RefundGiftCardSessionsResult { + /** Sessions whose money is back — not merely those a refund was created for. */ + refundedSessionIds: string[] + /** Sessions still charged when this gave up, and why. */ + errors: PlaceabilityError[] + /** + * True when the budget ran out with money still to come back — the capture + * never appeared, or a refund was created and never settled. The gift cards + * are still charged and the shopper's balance still spent, so this must be + * surfaced rather than treated as a completed rollback. + */ + timedOut: boolean +} + +/** + * Give back gift cards charged for a payment that then failed. + * + * The case this exists for: the gift cards are authorized just before the + * Drop-in is submitted, so a refused card leaves them charged on an order that + * is not going to be placed. Authorizing a gift card debits the balance + * immediately — the setting forces auto-capture, so the session lands on `paid` + * — and a void always fails by construction, which leaves a refund as the only + * way back. + * + * **The API grants exactly this and nothing more.** A sales-channel token may + * create a `PaymentRefund` only for a session whose `payment_type` is + * `GIFT_CARD`, and only while the order is in `pending` — `draft` is excluded + * (`app/abilities/base_abilities/sales_channel_ability.rb`). A failed checkout + * is precisely that situation, which is presumably why the grant is shaped this + * way. Nothing else is refundable from a storefront. + * + * **Why it polls, twice over.** `payment_capture` is a required relationship on + * a refund, and the capture is produced by the same background job that succeeds + * the authorization — so immediately after `authorizeGiftCardSessions` returns + * there is usually nothing to point at yet. And creating the refund is itself + * only an ask: it starts `pending` and another job settles it. The loop + * therefore waits for the session to read `refunded`, which is the server + * saying the balance is back. Each attempt is one `GET`. + * + * A session that already carries an unsettled refund is waited on rather than + * refunded twice. + * + * Failures are collected per session instead of stopping the loop: unlike + * authorizing, where each step changes what the next may take, refunds are + * independent, and giving up on the second card would leave the third charged + * for no reason. + */ +export async function refundGiftCardSessions({ + accessToken, + interceptors, + orderId, + paymentSessionIds, + attempts = DEFAULT_REFUND_ATTEMPTS, + intervalMs = DEFAULT_REFUND_INTERVAL_MS, +}: RefundGiftCardSessionsParams): Promise { + if (paymentSessionIds.length === 0) { + return { refundedSessionIds: [], errors: [], timedOut: false } + } + + const sdk = getSdk({ accessToken, interceptors }) + const pending = new Set(paymentSessionIds) + /** + * Sessions a refund has already been asked for in this call. + * + * The read-back guard below is not enough on its own: it depends on the next + * `GET` already reflecting a refund created moments earlier, and an order + * fetched with a `fields` allowlist that omits `payment_refunds` never + * reflects it at all. Either would have us ask twice and over-credit the card. + * The server's own `refund_balance_cents` would probably catch it — but "the + * server would probably catch it" is not where a double refund belongs. + */ + const requested = new Set() + const refundedSessionIds: string[] = [] + const errors: PlaceabilityError[] = [] + + for (let attempt = 1; attempt <= attempts && pending.size > 0; attempt++) { + const order = await sdk.orders.retrieve(orderId, { include: REFUND_INCLUDES }) + const sessions = (order.payment_sessions ?? []).filter((session) => pending.has(session.id)) + + for (const session of sessions) { + // The money is back, and the server is the one saying so: the session's + // own status moves to `refunded` when the refund succeeds. This is the + // only condition that counts as done — creating a refund is asking, not + // getting, and treating the ask as the answer told the shopper their card + // was off the order while it was still charged. + if (hasReturnedMoney(session)) { + refundedSessionIds.push(session.id) + pending.delete(session.id) + continue + } + + // A refund is on its way and has not settled yet. Wait for it rather than + // creating a second one for the same capture. + if (requested.has(session.id)) continue + if ((session.payment_refunds ?? []).length > 0) continue + + const capture = refundableCapture(session) + // The capture's job has not run yet. Leave it pending and look again. + if (capture == null) continue + + try { + await sdk.payment_refunds.create({ + payment_session: sdk.payment_sessions.relationship(session.id), + payment_capture: sdk.payment_captures.relationship(capture), + // Amount omitted on purpose: the server defaults it to the capture's + // own refund balance, which is the number we would otherwise be + // recomputing from values it gave us. + }) + requested.add(session.id) + // Deliberately still pending: the next attempt reads the session back + // and only then is the money actually returned. + } catch (error) { + const mapped = mapPlaceabilityErrors(error) + if (mapped.length === 0) throw error + errors.push(...mapped) + // Independent of the others — stop only on this one. + pending.delete(session.id) + } + } + + if (pending.size > 0 && attempt < attempts) await sleep(intervalMs) + } + + return { refundedSessionIds, errors, timedOut: pending.size > 0 } +} + +/** + * The capture a refund can be created against, if the job has produced one. + * + * A capture that is not yet `succeeded` is skipped rather than rejected — the + * same background job that succeeds the authorization creates and succeeds it, + * so "not there yet" and "not succeeded yet" are the same wait, and the next + * attempt will find it. + * + * `refund_balance_cents` is only trusted when present: an order fetched with a + * `fields` allowlist that omits it must not lose the refund altogether, and the + * server rejects an over-refund on its own. + */ +function refundableCapture(session: PaymentSession): string | undefined { + return (session.payment_captures ?? []).find((capture) => { + if (capture.status !== "succeeded") return false + const balance = capture.refund_balance_cents + return balance == null || balance > 0 + })?.id +} + +async function sleep(ms: number): Promise { + if (ms <= 0) return + await new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/core-components/src/payment_sessions/types.ts b/packages/core-components/src/payment_sessions/types.ts index a8b0d861..cbb2fed0 100644 --- a/packages/core-components/src/payment_sessions/types.ts +++ b/packages/core-components/src/payment_sessions/types.ts @@ -41,6 +41,36 @@ export const PAYMENT_TAKEN_SESSION_STATUSES = [ "partially_paid", ] as const satisfies readonly KnownPaymentSessionStatus[] +/** + * Session states in which the money is no longer with the merchant. + * + * `app/models/payment_session.rb:55-63` — the `refund` event goes to + * `partially_refunded` while a balance is left and to `refunded` once it is + * zero. `voided` is here for completeness; for gift cards it is unreachable, + * because that client hard-codes auto-capture and a void then fails by + * construction. + */ +export const MONEY_RETURNED_SESSION_STATUSES = [ + "voided", + "refunded", + "partially_refunded", +] as const satisfies readonly KnownPaymentSessionStatus[] + +/** + * True when this session's money has been given back. + * + * Read from `status` rather than from `payment_refunds`, and that is the whole + * point: the refunds relationship needs `payment_sessions.payment_refunds` in + * the order's `include`, which nothing registers — so a check on that array is + * dead code in any consumer, and a refunded card would go on counting toward + * the order's coverage. `status` is a plain attribute, always served. + */ +export function hasReturnedMoney(session: PaymentSession): boolean { + return MONEY_RETURNED_SESSION_STATUSES.includes( + session.status as (typeof MONEY_RETURNED_SESSION_STATUSES)[number] + ) +} + /** * `app/models/payment_transaction.rb:22-31` — initial state is `pending`. * Shared by all four STI subclasses: payment authorizations, captures, voids @@ -140,6 +170,25 @@ export function hasLiveAuthorization(session: PaymentSession): boolean { ) } +/** + * True when this session is holding money that belongs to the merchant. + * + * The conjunction that matters, and the one whose absence caused a real bug: + * `hasLiveAuthorization` alone says only that an authorization exists and did + * not fail, and it stays true after a refund — the authorization keeps its + * `succeeded` status forever, because what changes is the *session*. So every + * question of the form "is this still paying for the order?" has to ask both, + * and asking one of them was enough to keep a refunded gift card counting + * toward coverage and to leave the gift card input hidden for good. + * + * Distinct from the questions that only need one half: whether a session may be + * deleted, whether it still needs authorizing, whether it is the shopper's + * current selection. Those are about the authorization, not about the money. + */ +export function holdsMoney(session: PaymentSession): boolean { + return hasLiveAuthorization(session) && !hasReturnedMoney(session) +} + /** * True when the session's authorization is still being worked on server-side. * @@ -171,3 +220,52 @@ export function hasFailedAuthorization(session: PaymentSession): boolean { status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] ) } + +/** + * The Payment Setting type Adyen cards are taken through. + * + * Unlike gift cards, this is one of the alternatives the shopper picks between, + * so it stays inside the radio group. What separates it from `manual` is that a + * gateway has to collect something before the order can be placed. + */ +export const ADYEN_SETTING_TYPE = "payment_setting_adyens" + +/** True when this session pays through Adyen. */ +export function isAdyenSession(session?: PaymentSession | null): boolean { + return session?.payment_setting?.type === ADYEN_SETTING_TYPE +} + +/** + * The gateway-side session `adyen-web` needs, as Adyen names its own fields. + * + * Distinct from the Payment Session that owns it: this is what + * `AdyenCheckout({ session })` is constructed with. + */ +export interface AdyenSession { + id: string + sessionData: string +} + +/** + * Read the Adyen Session out of a Payment Session. + * + * It lives in `response_data`, which is the response Commerce Layer got from + * Adyen `/sessions` passed through verbatim — hence Adyen's camelCase + * `sessionData` beside a bare `id`. That attribute is deliberately readable by + * sales-channel tokens (`config/attributes/payment_session.yml`, *"used by + * client"*), unlike `payment_authorization.response_data`, which is withheld. + * + * Returns `undefined` unless **both** fields are present and non-empty. A + * partial Adyen Session is not something to boot a Drop-in from, and the two + * ways of getting one — a consumer whose `fields` allowlist omits + * `response_data`, or a session whose gateway call failed — are both better + * reported as "no Adyen session" than as a Drop-in that fails inside the SDK. + */ +export function readAdyenSession(session?: PaymentSession | null): AdyenSession | undefined { + const data = session?.response_data + if (data == null || typeof data !== "object") return undefined + const { id, sessionData } = data as { id?: unknown; sessionData?: unknown } + if (typeof id !== "string" || id === "") return undefined + if (typeof sessionData !== "string" || sessionData === "") return undefined + return { id, sessionData } +} diff --git a/packages/react-components/specs/hooks/useAdyenRedirectResume.spec.tsx b/packages/react-components/specs/hooks/useAdyenRedirectResume.spec.tsx new file mode 100644 index 00000000..3f431f53 --- /dev/null +++ b/packages/react-components/specs/hooks/useAdyenRedirectResume.spec.tsx @@ -0,0 +1,238 @@ +import type { Order, PaymentSession, PaymentSetting } from "@commercelayer/sdk" +import { renderHook, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import { useAdyenRedirectResume } from "#hooks/useAdyenRedirectResume" +import { getHandoffSnapshot, resetPaymentGatewayStore } from "#utils/paymentGatewayStore" + +const adyen = vi.hoisted(() => ({ + submitDetails: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + captured: { options: null as any }, + shouldReject: false, +})) + +vi.mock("@adyen/adyen-web/auto", () => ({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + AdyenCheckout: vi.fn(async (options: any) => { + adyen.captured.options = options + if (adyen.shouldReject) throw new Error("session expired") + return { submitDetails: adyen.submitDetails } + }), + Dropin: class {}, +})) + +// Cast where the fixture is defined, as the core specs do, rather than at +// every call site: `available_payment_settings` is the six-member per-provider +// union, and a literal without `created_at`/`updated_at` matches none of them. +const ADYEN_SETTING = { + id: "ps-adyen", + type: "payment_setting_adyens", + public_key: "test_ABC123", +} as unknown as PaymentSetting + +function adyenSession(overrides: Record = {}): PaymentSession { + return { + id: "session-adyen", + type: "payment_sessions", + status: "unpaid", + payment_setting: { id: "ps-adyen", type: "payment_setting_adyens" }, + response_data: { id: "CS-ORDER", sessionData: "blob-from-order" }, + ...overrides, + } as unknown as PaymentSession +} + +function order(overrides: Record = {}): Partial { + return { + id: "order-1", + available_payment_settings: [ADYEN_SETTING], + payment_sessions: [adyenSession()], + ...overrides, + } as Partial +} + +const getOrder = vi.fn() + +function wrapper(currentOrder: Partial | null) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +function visit(search: string) { + window.history.replaceState({}, "", `/checkout${search}`) +} + +beforeEach(() => { + vi.clearAllMocks() + resetPaymentGatewayStore() + adyen.captured.options = null + adyen.shouldReject = false + getOrder.mockResolvedValue(order()) +}) + +describe("useAdyenRedirectResume", () => { + it("does nothing on an ordinary page load", async () => { + visit("") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + expect(adyen.captured.options).toBeNull() + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("no") + }) + + it("waits for the order rather than burning the single-use value", async () => { + // `redirectResult` cannot be submitted twice, so it stays in the URL until + // there is an order with sessions to match it against. + visit("?redirectResult=wait-for-order") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(null) }) + + expect(adyen.captured.options).toBeNull() + expect(window.location.search).toContain("redirectResult") + }) + + it("resumes from the order, not from the sessionId in the query", async () => { + // The order is the version that survives a different browser, cleared + // storage or private mode, where adyen-web's localStorage cache is absent. + visit("?redirectResult=resume-ok&sessionId=CS-FROM-QUERY") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + await waitFor(() => { + expect(adyen.submitDetails).toHaveBeenCalledWith({ + details: { redirectResult: "resume-ok" }, + }) + }) + expect(adyen.captured.options.session).toEqual({ + id: "CS-ORDER", + sessionData: "blob-from-order", + }) + expect(adyen.captured.options.clientKey).toBe("test_ABC123") + }) + + it("cleans Adyen's parameters out of the address bar", async () => { + visit("?redirectResult=clean-me&sessionId=CS-9&orderId=1") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + await waitFor(() => { + expect(window.location.search).not.toContain("redirectResult") + }) + expect(window.location.search).not.toContain("sessionId") + // The application's own query is not ours to remove. + expect(window.location.search).toContain("orderId=1") + }) + + it("reports the phase so the place-order button can finish without a click", async () => { + visit("?redirectResult=phase-ok") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + adyen.captured.options.onPaymentCompleted({ resultCode: "Authorised" }) + + await waitFor(() => { + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("done") + }) + }) + + it("carries Adyen's resultCode when the redirect comes back refused", async () => { + visit("?redirectResult=phase-refused") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + + await waitFor(() => { + const snapshot = getHandoffSnapshot("order-1") + expect(snapshot.collectedOutOfBand).toBe("failed") + expect(snapshot.errors[0]?.meta).toEqual({ error: "Refused" }) + }) + }) + + it("reports a refused setup instead of hanging on a spinner", async () => { + // What an expired Adyen Session or an unauthorized origin looks like. + adyen.shouldReject = true + visit("?redirectResult=setup-fails") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + await waitFor(() => { + const snapshot = getHandoffSnapshot("order-1") + expect(snapshot.collectedOutOfBand).toBe("failed") + expect(snapshot.errors[0]?.meta).toEqual({ error: "SetupFailed" }) + }) + }) + + it("pulls the order back in, since the shopper was away while it changed", async () => { + visit("?redirectResult=refetches") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + await waitFor(() => { + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + }) + + it("skips a session whose payment has already been picked up", async () => { + visit("?redirectResult=already-authorized") + renderHook(() => useAdyenRedirectResume(), { + wrapper: wrapper( + order({ + payment_sessions: [adyenSession({ payment_authorization: { status: "succeeded" } })], + }) + ), + }) + + expect(adyen.captured.options).toBeNull() + // Still claimed and cleaned, so the check does not repeat every render. + await waitFor(() => { + expect(window.location.search).not.toContain("redirectResult") + }) + }) + + it("skips a session with no Adyen Session to resume", async () => { + visit("?redirectResult=no-response-data") + renderHook(() => useAdyenRedirectResume(), { + wrapper: wrapper(order({ payment_sessions: [adyenSession({ response_data: null })] })), + }) + + expect(adyen.captured.options).toBeNull() + }) + + it("does not resume a setting that is not Adyen", async () => { + visit("?redirectResult=manual-setting") + renderHook(() => useAdyenRedirectResume(), { + wrapper: wrapper( + order({ + payment_sessions: [ + adyenSession({ payment_setting: { id: "m", type: "payment_setting_manuals" } }), + ], + }) + ), + }) + + expect(adyen.captured.options).toBeNull() + }) + + it("submits a given redirectResult only once", async () => { + // Adyen refuses the same value twice, so two mounted trees or a remount + // must not both relay it. + visit("?redirectResult=only-once") + const { unmount } = renderHook(() => useAdyenRedirectResume(), { + wrapper: wrapper(order()), + }) + await waitFor(() => { + expect(adyen.submitDetails).toHaveBeenCalledTimes(1) + }) + unmount() + + visit("?redirectResult=only-once") + renderHook(() => useAdyenRedirectResume(), { wrapper: wrapper(order()) }) + + expect(adyen.submitDetails).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-components/specs/orders/place-order-gateway-handoff.spec.tsx b/packages/react-components/specs/orders/place-order-gateway-handoff.spec.tsx new file mode 100644 index 00000000..730f667d --- /dev/null +++ b/packages/react-components/specs/orders/place-order-gateway-handoff.spec.tsx @@ -0,0 +1,381 @@ +import type { Order } from "@commercelayer/sdk" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PlaceOrderButtonPaymentSessions } from "#components/orders/PlaceOrderButtonPaymentSessions" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import { + type PaymentGatewaySubmitResult, + registerGatewayCollection, + registerHostCollection, + resetPaymentGatewayStore, + setOutOfBandCollection, +} from "#utils/paymentGatewayStore" +import { resetTermsAcceptanceStore } from "#utils/termsAcceptanceStore" + +const { authorizeGiftCardsMock, discardPaymentSessionMock, placeOrderMock, refundGiftCardsMock } = + vi.hoisted(() => ({ + authorizeGiftCardsMock: vi.fn(), + discardPaymentSessionMock: vi.fn(), + placeOrderMock: vi.fn(), + refundGiftCardsMock: vi.fn(), + })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + authorizeGiftCardSessions: authorizeGiftCardsMock, + discardPaymentSession: discardPaymentSessionMock, + placeOrderWithPaymentSessions: placeOrderMock, + refundGiftCardSessions: refundGiftCardsMock, + } +}) + +vi.mock("#utils/organization", () => ({ useOrganizationConfig: () => ({ urls: {} }) })) + +const ADYEN = { id: "ps-adyen", type: "payment_setting_adyens" } +function orderWithCard(overrides: Partial = {}): Partial { + return { + id: "order-1", + status: "pending", + total_amount_with_taxes_cents: 7100, + available_payment_settings: [ADYEN], + payment_sessions: [{ id: "session-adyen", status: "unpaid", payment_setting: ADYEN }], + ...overrides, + } as Partial +} + +const setOrderErrors = vi.fn() +const getOrder = vi.fn() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +/** Stands in for a card gateway: registers a host collection, answers on demand. */ +function useFakeGateway(result: PaymentGatewaySubmitResult) { + const submit = vi.fn(async () => result) + registerHostCollection("order-1", submit) + return submit +} + +async function clickPlace() { + await act(async () => { + fireEvent.click(screen.getByTestId("place")) + }) +} + +function renderButton(currentOrder: Partial | null = orderWithCard()) { + return render( + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + resetPaymentGatewayStore() + resetTermsAcceptanceStore() + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: [], errors: [] }) + refundGiftCardsMock.mockResolvedValue({ refundedSessionIds: [], errors: [], timedOut: false }) + discardPaymentSessionMock.mockResolvedValue(true) + placeOrderMock.mockResolvedValue({ placed: true, order: orderWithCard(), errors: [] }) + getOrder.mockResolvedValue(orderWithCard()) +}) + +describe("the button as the pay button", () => { + it("asks the gateway to collect before placing the order", async () => { + const submit = useFakeGateway({ status: "completed" }) + renderButton() + + await clickPlace() + + expect(submit).toHaveBeenCalledTimes(1) + expect(placeOrderMock).toHaveBeenCalledTimes(1) + // Collect first, place second. + expect(submit.mock.invocationCallOrder[0]).toBeLessThan( + placeOrderMock.mock.invocationCallOrder[0] as number + ) + }) + + it("places without asking anyone when no gateway has registered", async () => { + // A manual or gift-card-only order. The handoff is empty and the sequence + // is exactly what it was before. + renderButton() + + await clickPlace() + + expect(authorizeGiftCardsMock).not.toHaveBeenCalled() + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + + it("charges the gift cards before the card, and refetches in between", async () => { + // The charge order the gift card ADR established. Refetching matters: the + // place sequence skips already-authorized sessions by reading the order it + // is handed, so a stale copy would take the money twice. + const submit = useFakeGateway({ status: "completed" }) + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + renderButton() + + await clickPlace() + + const authorizeCall = authorizeGiftCardsMock.mock.invocationCallOrder[0] as number + const refetchCall = getOrder.mock.invocationCallOrder[0] as number + const submitCall = submit.mock.invocationCallOrder[0] as number + expect(authorizeCall).toBeLessThan(refetchCall) + expect(refetchCall).toBeLessThan(submitCall) + }) + + it("stops before the card when a gift card is refused", async () => { + const submit = useFakeGateway({ status: "completed" }) + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + renderButton() + + await clickPlace() + + expect(submit).not.toHaveBeenCalled() + expect(placeOrderMock).not.toHaveBeenCalled() + expect(setOrderErrors).toHaveBeenCalledWith([ + expect.objectContaining({ message: "Gift card balance is insufficient." }), + ]) + }) + + it("gives the gateway a longer placeability budget than a local job", async () => { + // The wait is a webhook round trip through a third party, not a Sidekiq hop. + useFakeGateway({ status: "completed" }) + renderButton() + + await clickPlace() + + const args = placeOrderMock.mock.calls[0]?.[0] + expect(args.attempts).toBe(20) + expect(args.intervalMs).toBe(1000) + }) + + it("honours an explicit budget over the gateway default", async () => { + useFakeGateway({ status: "completed" }) + render( + + + + ) + + await clickPlace() + + const args = placeOrderMock.mock.calls[0]?.[0] + expect(args.attempts).toBe(3) + expect(args.intervalMs).toBe(50) + }) +}) + +describe("when the gateway does not complete", () => { + it("says nothing when the form is incomplete", async () => { + // The gateway is showing its own validation. Reporting an error on top of + // it would tell the shopper something failed when nothing was attempted. + useFakeGateway({ status: "incomplete" }) + renderButton() + + await clickPlace() + + expect(placeOrderMock).not.toHaveBeenCalled() + expect(setOrderErrors).toHaveBeenCalledWith([]) + expect(setOrderErrors).toHaveBeenCalledTimes(1) + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + }) + + it("burns the session on a refusal and leaves the gift cards charged", async () => { + // Deliberately no rollback. A refused card is the ordinary failure of a + // checkout and the shopper tries another one — giving their credit back + // here destroys what the next attempt needs, and they cannot re-apply it + // while anything is authorized. Removing a card is theirs to ask for. + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + useFakeGateway({ status: "failed", code: "Refused" }) + renderButton() + + await clickPlace() + + expect(refundGiftCardsMock).not.toHaveBeenCalled() + expect(discardPaymentSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionId: "session-adyen" }) + ) + expect(placeOrderMock).not.toHaveBeenCalled() + // `resource: "orders"` is load-bearing, not descriptive: `` matches + // on it, so tagging this `payment_methods` — which reads better — hid the + // one message telling the shopper their card was refused. + expect(setOrderErrors).toHaveBeenCalledWith([ + expect.objectContaining({ + resource: "orders", + message: "Refused", + meta: { error: "Refused" }, + }), + ]) + }) + + it("leaves even the burnt session alone when the outcome is unknown", async () => { + // The payment may have gone through, and the session is the record the + // gateway's webhook settles against — deleting it would orphan the charge. + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + useFakeGateway({ status: "unknown", code: "NETWORK_ERROR" }) + renderButton() + + await clickPlace() + + expect(refundGiftCardsMock).not.toHaveBeenCalled() + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + expect(setOrderErrors).toHaveBeenCalledWith([ + expect.objectContaining({ resource: "orders", meta: { error: "NETWORK_ERROR" } }), + ]) + }) +}) + +describe("when the method owns its own button", () => { + it("renders disabled, because our own route leads nowhere", async () => { + // `dropin.submit()` on PayPal is IMPLEMENTATION_ERROR and no payment, so a + // live button would offer a second route to one action and ours would fail. + // Disabled rather than hidden: the reason is on the handoff, for the + // application to word. + const place = () => screen.getByTestId("place") as HTMLButtonElement + + // Nothing registered, so this order is one our button can place: the + // assertion below is about the gateway and not about the order. + const { unmount } = renderButton() + await waitFor(() => { + expect(place().disabled).toBe(false) + }) + unmount() + + registerGatewayCollection("order-1") + renderButton() + await waitFor(() => { + expect(place().disabled).toBe(true) + }) + }) + + it("collects nothing and places nothing if it is clicked anyway", async () => { + // A guard, not a branch: a consumer can pass `disabled={false}`, and an + // earlier bug had a gateway registering from a card it was not selected in. + registerGatewayCollection("order-1") + render( + + + + ) + + await clickPlace() + + expect(authorizeGiftCardsMock).not.toHaveBeenCalled() + expect(placeOrderMock).not.toHaveBeenCalled() + }) + + it("places the order once the gateway's own button has collected", async () => { + // The same mechanism as a redirect return: money taken, no click of ours, + // order still to place. The gift cards were charged inside PayPal's own + // click, so nothing is authorized here. + registerGatewayCollection("order-1") + renderButton() + + await act(async () => { + setOutOfBandCollection("order-1", "done") + }) + + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe("returning from a 3DS redirect", () => { + it("places the order without a click, and without asking for the terms again", async () => { + // Acceptance did not survive the navigation, and the money is already + // taken — asking again would leave anyone who declines with a paid, + // unplaced order. Acceptance happened before the redirect, or the button + // was never clickable. + renderButton() + expect(placeOrderMock).not.toHaveBeenCalled() + + await act(async () => { + setOutOfBandCollection("order-1", "done") + }) + + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + }) + + it("places once, however many times the phase is republished", async () => { + renderButton() + + await act(async () => { + setOutOfBandCollection("order-1", "done") + }) + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + await act(async () => { + setOutOfBandCollection("order-1", "no") + setOutOfBandCollection("order-1", "done") + }) + + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + + it("reports a refused redirect and burns the session, but refunds nothing", async () => { + // Which gift cards this attempt charged was lost with the page, so giving + // them back could take money for a payment that is still settling. + renderButton() + + await act(async () => { + setOutOfBandCollection("order-1", "failed", [ + { code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", message: "Refused" }, + ]) + }) + + await waitFor(() => { + expect(discardPaymentSessionMock).toHaveBeenCalled() + }) + expect(setOrderErrors).toHaveBeenCalledWith([expect.objectContaining({ message: "Refused" })]) + expect(refundGiftCardsMock).not.toHaveBeenCalled() + expect(placeOrderMock).not.toHaveBeenCalled() + }) + + it("shows the button as busy while the redirect is being completed", async () => { + renderButton() + + await act(async () => { + setOutOfBandCollection("order-1", "in-progress") + }) + + expect((screen.getByTestId("place") as HTMLButtonElement).disabled).toBe(true) + }) +}) diff --git a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx index 6ed3861f..a127c7fe 100644 --- a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx +++ b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx @@ -10,11 +10,18 @@ import { PaymentSettingRadioButton } from "#components/payment_settings/PaymentS import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext, { defaultOrderContext } from "#context/OrderContext" -const { createPaymentSessionMock } = vi.hoisted(() => ({ createPaymentSessionMock: vi.fn() })) +const { createPaymentSessionMock, discardPaymentSessionMock } = vi.hoisted(() => ({ + createPaymentSessionMock: vi.fn(), + discardPaymentSessionMock: vi.fn(), +})) vi.mock("@commercelayer/core-components", async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createPaymentSession: createPaymentSessionMock } + return { + ...actual, + createPaymentSession: createPaymentSessionMock, + discardPaymentSession: discardPaymentSessionMock, + } }) const MANUAL = { id: "ps-manual", type: "payment_setting_manuals", name: "Bank transfer" } @@ -77,6 +84,7 @@ function renderSettings(currentOrder?: Partial | null) { beforeEach(() => { vi.clearAllMocks() createPaymentSessionMock.mockResolvedValue({ id: "session-new" }) + discardPaymentSessionMock.mockResolvedValue(true) }) afterEach(() => { @@ -340,3 +348,180 @@ describe("PaymentSetting children as a function", () => { }) }) }) + +/** + * Switching setting clears what the shopper switched away from. + * + * The rule is `2026-08-20-gift-cards-as-payment-sessions.md`'s reformulation — + * sessions that took no money are deleted, everything else is abandoned — which + * the selection path had never implemented. Left undone, an order accumulated a + * session per setting the shopper had ever tried. + */ +describe(" clearing the superseded session", () => { + const ADYEN = { id: "ps-adyen", type: "payment_setting_adyens", name: "Adyen" } + + function withBoth(sessions: unknown[]) { + return order({ + available_payment_settings: [MANUAL, ADYEN], + payment_sessions: sessions, + } as never) + } + + async function clickManual() { + await act(async () => { + fireEvent.click(screen.getAllByTestId("radio")[0] as HTMLElement) + }) + } + + it("deletes the session belonging to the setting just left", async () => { + renderSettings( + withBoth([ + { + id: "session-adyen", + status: "unpaid", + created_at: "2026-09-08T10:57:54Z", + payment_setting: ADYEN, + }, + ]) + ) + + await clickManual() + + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalled() + }) + expect(discardPaymentSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionId: "session-adyen" }) + ) + }) + + it("does nothing at all when the setting is already selected", async () => { + // The radio ignores a click on the current selection, which is what keeps + // the adopt branch — where the superseded session *is* the selection — + // unreachable from a click. So the property worth pinning is that a stray + // click destroys nothing. + renderSettings( + withBoth([ + { + id: "session-manual", + status: "unpaid", + created_at: "2026-09-08T10:57:54Z", + payment_setting: MANUAL, + }, + ]) + ) + + await clickManual() + + expect(createPaymentSessionMock).not.toHaveBeenCalled() + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + }) + + it("leaves a session that is holding money", async () => { + // Not ours to undo from a radio button: the API refuses to delete a session + // with transactions attached, and the money is a real record. + renderSettings( + withBoth([ + { + id: "session-adyen", + status: "authorized", + created_at: "2026-09-08T10:57:54Z", + payment_setting: ADYEN, + payment_authorization: { status: "succeeded" }, + }, + ]) + ) + + await clickManual() + + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalled() + }) + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + }) + + it("still completes the selection when the delete fails", async () => { + // Tidying, not correctness: the newest session is the selection either way, + // so a refused delete must not turn into a failed selection. + discardPaymentSessionMock.mockRejectedValue(new Error("nope")) + renderSettings( + withBoth([ + { + id: "session-adyen", + status: "unpaid", + created_at: "2026-09-08T10:57:54Z", + payment_setting: ADYEN, + }, + ]) + ) + + await clickManual() + + await waitFor(() => { + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + expect(screen.queryByTestId("error")).toBeNull() + }) +}) + +/** + * `returnUrl`, and why it is on this component. + * + * From gateway version 72 Adyen refuses a `returnUrl` over 1024 characters, and + * a checkout carrying its access token in the query string exceeds that on the + * token alone — a JWT here is around 1.5 kB. The refusal arrives as + * `Field 'returnUrl' may not exceed 1024 characters` plus a collateral + * `token - can't be blank`, neither of which names anything the application set. + */ +describe(" returnUrl", () => { + const ADYEN = { + id: "ps-adyen", + type: "payment_setting_adyens", + name: "Adyen", + public_key: "test_ABC", + } + + function renderAdyenOnly(returnUrl?: string) { + return render( + + + + + + ) + } + + async function clickIt() { + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + } + + it("sends the one the application gave, verbatim", async () => { + renderAdyenOnly("https://shop.example/checkout/o-1?paymentReturn=true") + await clickIt() + + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + clientData: { return_url: "https://shop.example/checkout/o-1?paymentReturn=true" }, + }) + ) + }) + }) + + it("falls back to the current location when the application says nothing", async () => { + renderAdyenOnly() + await clickIt() + + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalled() + }) + const [{ clientData }] = createPaymentSessionMock.mock.calls[0] as [ + { clientData?: { return_url?: string } }, + ] + // jsdom serves the page from localhost, which is all this needs to assert: + // the fallback is the page, not a configured value. + expect(clientData?.return_url).toContain(window.location.origin) + }) +}) diff --git a/packages/react-components/specs/payment_settings/PaymentSettingAdyenPayment.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSettingAdyenPayment.spec.tsx new file mode 100644 index 00000000..6285cd36 --- /dev/null +++ b/packages/react-components/specs/payment_settings/PaymentSettingAdyenPayment.spec.tsx @@ -0,0 +1,1179 @@ +import type { + Order, + PaymentSession, + PaymentSetting as PaymentSettingResource, +} from "@commercelayer/sdk" +import { act, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import { PaymentSettingAdyenPayment } from "#components/payment_settings/PaymentSettingAdyenPayment" +import { PaymentSettingRadioButton } from "#components/payment_settings/PaymentSettingRadioButton" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import type { BaseError } from "#typings/errors" +import { getHandoffSnapshot, resetPaymentGatewayStore } from "#utils/paymentGatewayStore" + +const adyen = vi.hoisted(() => ({ + dropinMount: vi.fn(), + dropinRemove: vi.fn(), + dropinSubmit: vi.fn(), + isValid: true, + // The Core and Drop-in configuration the component builds, so tests can + // invoke the very callbacks it installed. + // biome-ignore lint/suspicious/noExplicitAny: test cast + captured: { options: null as any, dropinOptions: null as any }, +})) + +vi.mock("@adyen/adyen-web/auto", () => ({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + AdyenCheckout: vi.fn(async (options: any) => { + adyen.captured.options = options + return { submitDetails: vi.fn(), remove: vi.fn() } + }), + Dropin: class FakeDropin { + // biome-ignore lint/suspicious/noExplicitAny: test cast + constructor(_core: any, options: any) { + adyen.captured.dropinOptions = options + } + get isValid(): boolean { + return adyen.isValid + } + mount(node: unknown): this { + adyen.dropinMount(node) + return this + } + submit(): void { + adyen.dropinSubmit() + } + remove(): void { + adyen.dropinRemove() + } + }, +})) + +const { authorizeGiftCardsMock, createPaymentSessionMock, discardPaymentSessionMock } = vi.hoisted( + () => ({ + authorizeGiftCardsMock: vi.fn(), + createPaymentSessionMock: vi.fn(), + discardPaymentSessionMock: vi.fn(), + }) +) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + authorizeGiftCardSessions: authorizeGiftCardsMock, + createPaymentSession: createPaymentSessionMock, + discardPaymentSession: discardPaymentSessionMock, + } +}) + +/** The privacy-and-terms gate, which PayPal's own click has to honour. */ +const { permitted } = vi.hoisted(() => ({ permitted: { value: true } })) +vi.mock("#hooks/useCollectionPermitted", () => ({ + useCollectionPermitted: () => permitted.value, + default: () => permitted.value, +})) + +// `paymentSettingCreateAttributes` decides the tokenization variant from the +// token, and the test token is not a real JWT. +vi.mock("#utils/isGuestToken", () => ({ isGuestToken: () => true })) + +// Cast where the fixture is defined, as the core specs do, rather than at +// every call site: `available_payment_settings` is the six-member per-provider +// union, and a literal without `created_at`/`updated_at` matches none of them. +const ADYEN_SETTING = { + id: "ps-adyen", + type: "payment_setting_adyens", + name: "Adyen", + public_key: "test_ABC123", +} as unknown as PaymentSettingResource + +const ADYEN_SESSION = { + id: "session-adyen", + type: "payment_sessions", + status: "unpaid", + amount_cents: 7100, + payment_setting: { id: "ps-adyen", type: "payment_setting_adyens" }, + response_data: { id: "CS-1", sessionData: "blob-1" }, +} as unknown as PaymentSession + +function order(overrides: Record = {}): Partial { + return { + id: "order-1", + total_amount_with_taxes_cents: 7100, + available_payment_settings: [ADYEN_SETTING], + payment_sessions: [ADYEN_SESSION], + ...overrides, + } as Partial +} + +const getOrder = vi.fn() + +/** The host collection this component is expected to have registered. */ +function hostCollection() { + const { collection } = getHandoffSnapshot("order-1") + if (collection?.by !== "host") { + throw new Error(`expected a host collection, got ${JSON.stringify(collection)}`) + } + return collection +} + +function hostSubmit() { + return hostCollection().submit +} + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +/** The tree under test, as an element, so `rerender` can re-render it. */ +function tree(currentOrder: Partial | null = order()) { + return ( + + + + + + + ) +} + +function renderAdyen(currentOrder: Partial | null = order()) { + return render( + + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + resetPaymentGatewayStore() + adyen.isValid = true + adyen.captured.options = null + adyen.captured.dropinOptions = null + permitted.value = true + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: [], errors: [] }) + createPaymentSessionMock.mockResolvedValue({ id: "session-new" }) + discardPaymentSessionMock.mockResolvedValue(true) + getOrder.mockResolvedValue(order()) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe(" mounting", () => { + it("builds the Drop-in from the Adyen Session on the order", async () => { + renderAdyen() + + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) + expect(adyen.captured.options.session).toEqual({ id: "CS-1", sessionData: "blob-1" }) + expect(adyen.captured.options.clientKey).toBe("test_ABC123") + }) + + it("suppresses Adyen's own Pay button, on the Core and not on the Drop-in", async () => { + // The Drop-in forwards only `{ elementRef, isDropin }` to its children, so + // setting it on the Drop-in would visibly do nothing. + renderAdyen() + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.showPayButton).toBe(false) + expect(adyen.captured.dropinOptions.showPayButton).toBeUndefined() + }) + + it("offers every designed method, and only those", async () => { + // Restricting matters because `showPayButton: false` deletes a wallet's + // component rather than hiding its button, so an undesigned method would + // render an accordion that opens on nothing. + renderAdyen() + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.allowPaymentMethods).toEqual(["scheme", "paypal", "googlepay"]) + }) + + it("leaves Apple Pay out until it is asked for", async () => { + // The one method whose being offered is not evidence it can work: its + // button renders wherever the device can pay, and a domain that is not + // registered for Apple Pay fails later, at merchant validation, after the + // shopper has tapped. Nothing here can detect that, so it is opt-in. + renderAdyen() + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.allowPaymentMethods).not.toContain("applepay") + expect(adyen.captured.dropinOptions.paymentMethodsConfiguration.applepay).toBeUndefined() + }) + + it("lets an application narrow the list", async () => { + render( + + + + + + + ) + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.allowPaymentMethods).toEqual(["scheme"]) + // And nothing is configured for a method that is not on offer: the cards + // are the one method the host's own button collects, so a configuration + // here would mean a wallet had been wired up invisibly. + expect(adyen.captured.dropinOptions.paymentMethodsConfiguration).toBeUndefined() + }) + + it("drops a method it has not been designed for, with a warning", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + render( + + + + + + + ) + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.allowPaymentMethods).toEqual(["scheme"]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining("applepay")) + }) + + it("disables the final animation, since a refusal replaces the session", async () => { + renderAdyen() + + await waitFor(() => { + expect(adyen.captured.dropinOptions).not.toBeNull() + }) + expect(adyen.captured.dropinOptions.disableFinalAnimation).toBe(true) + }) + + it("derives the environment from the Client Key prefix", async () => { + renderAdyen() + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.environment).toBe("test") + }) + + it("still renders its container when a function child is given", async () => { + // The Drop-in mounts into that element. If a render prop replaced it — as + // it does elsewhere in the library — an application that forgot to render + // the container would get a payment form that silently never appears. + render( + + + + {({ isSubmitting }) => ( + {isSubmitting ? "paying" : "idle"} + )} + + + + ) + + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) + expect(screen.getByTestId("chrome").textContent).toBe("idle") + }) + + it("does not mount without an Adyen Session on the order", async () => { + // What a `fields` allowlist that omits `response_data` produces. + renderAdyen(order({ payment_sessions: [{ ...ADYEN_SESSION, response_data: null }] })) + + await waitFor(() => { + expect(screen.getByTestId("radio")).toBeTruthy() + }) + expect(adyen.dropinMount).not.toHaveBeenCalled() + }) +}) + +describe(" skipping unusable Adyen settings", () => { + it("skips a setting with no public_key", async () => { + // Optional and unvalidated server-side, so a setting that charges fine + // server-side can carry none — and then the Drop-in cannot boot. A radio + // button that does nothing is worse than no radio button. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + renderAdyen(order({ available_payment_settings: [{ ...ADYEN_SETTING, public_key: null }] })) + + await waitFor(() => { + expect(warn).toHaveBeenCalledWith(expect.stringContaining("has no public_key")) + }) + expect(screen.queryByTestId("radio")).toBeNull() + }) + + it("skips a disabled setting", async () => { + // `available_payment_settings` has no `.enabled` filter, unlike the older + // model's `available_payment_methods`. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + renderAdyen( + order({ + available_payment_settings: [{ ...ADYEN_SETTING, disabled_at: "2026-09-01T00:00:00Z" }], + }) + ) + + await waitFor(() => { + expect(warn).toHaveBeenCalledWith(expect.stringContaining("is disabled")) + }) + expect(screen.queryByTestId("radio")).toBeNull() + }) +}) + +describe("the Payment Gateway Handoff", () => { + it("registers a submit the place-order button can call", async () => { + renderAdyen() + + await waitFor(() => { + expect(getHandoffSnapshot("order-1").collection?.by).toBe("host") + }) + }) + + it("resolves as completed when Adyen reports the payment taken", async () => { + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + + const submit = hostSubmit() + let result: unknown + await act(async () => { + const pending = submit?.().then((r) => { + result = r + }) + // The Drop-in charges the card and answers through the callback the + // component installed, not through the return value of `submit()`. + adyen.captured.options.onPaymentCompleted({ resultCode: "Authorised" }) + await pending + }) + + expect(adyen.dropinSubmit).toHaveBeenCalledTimes(1) + expect(result).toEqual({ status: "completed" }) + }) + + it("reports an invalid form as incomplete without submitting a payment", async () => { + // `dropin.submit()` shows its own validation and no-ops, settling nothing, + // so the guard is what stops the caller waiting forever. + adyen.isValid = false + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + + const submit = hostSubmit() + const result = await act(async () => await submit?.()) + + expect(result).toEqual({ status: "incomplete" }) + expect(adyen.dropinSubmit).toHaveBeenCalledTimes(1) + }) + + it("carries Adyen's resultCode as the failure code, with no copy of its own", async () => { + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + + const submit = hostSubmit() + let result: unknown + await act(async () => { + const pending = submit?.().then((r) => { + result = r + }) + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + await pending + }) + + expect(result).toEqual({ status: "failed", code: "Refused" }) + }) + + it("reports a network or SDK error as unknown, so nothing is rolled back", async () => { + // The payment may have gone through: refunding gift cards here could take + // back money for a card that did charge. + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + + const submit = hostSubmit() + let result: unknown + await act(async () => { + const pending = submit?.().then((r) => { + result = r + }) + adyen.captured.options.onError({ name: "NETWORK_ERROR", message: "boom" }) + await pending + }) + + expect(result).toEqual({ status: "unknown", code: "NETWORK_ERROR" }) + }) + + it("publishes readiness from the Drop-in's own validity", async () => { + renderAdyen() + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + + await act(async () => { + adyen.captured.options.onChange({ isValid: true }) + }) + expect(hostCollection().isReady).toBe(true) + + await act(async () => { + adyen.captured.options.onChange({ isValid: false }) + }) + expect(hostCollection().isReady).toBe(false) + }) +}) + +describe("who may claim to collect a payment", () => { + const MANUAL = { + id: "ps-manual", + type: "payment_setting_manuals", + name: "Wire Transfer", + } as unknown as PaymentSettingResource + + const MANUAL_SESSION = { + id: "session-manual", + type: "payment_sessions", + status: "unpaid", + amount_cents: 7100, + payment_setting: { id: "ps-manual", type: "payment_setting_manuals" }, + } as unknown as PaymentSession + + /** + * `` renders its children once per available setting, so this + * component is mounted inside every setting's card and returns `null` from + * all but one — while its effects still run. + * + * Registering a handoff from those instances told `` that a + * gateway would collect the payment on an order paying by bank transfer. It + * called `submit()`, got `incomplete` back from an instance that has no + * Drop-in, and returned without placing anything, in silence. An end-to-end + * test that had passed for weeks caught it; nothing here did. + */ + it("does not register while another setting is the one selected", async () => { + renderAdyen( + order({ + available_payment_settings: [MANUAL, ADYEN_SETTING], + payment_sessions: [MANUAL_SESSION], + }) + ) + + await waitFor(() => { + expect(screen.getAllByTestId("radio").length).toBeGreaterThan(0) + }) + expect(getHandoffSnapshot("order-1").collection).toBeNull() + expect(adyen.dropinMount).not.toHaveBeenCalled() + }) + + it("gives the handoff up when the shopper switches away", async () => { + const { rerender } = renderAdyen(order({ available_payment_settings: [MANUAL, ADYEN_SETTING] })) + await waitFor(() => { + expect(getHandoffSnapshot("order-1").collection?.by).toBe("host") + }) + + rerender( + + + + + + + ) + + await waitFor(() => { + expect(getHandoffSnapshot("order-1").collection).toBeNull() + }) + }) +}) + +describe("what this component does NOT do on a refusal", () => { + it("leaves replacing the burnt Payment Session to the place-order button", async () => { + // Not an oversight. The button also decides whether the gift cards are + // given back, and that changes what is left to pay — so a replacement + // created here would be sized for the wrong amount. + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + + await act(async () => { + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + }) + + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + expect(createPaymentSessionMock).not.toHaveBeenCalled() + }) + + it("remounts a fresh Drop-in once the session is replaced", async () => { + // The error screen tears down the PCI secured-field iframes, so a new + // Adyen Session is the only route back to a usable form. + const { rerender } = renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) + + const replaced = order({ + payment_sessions: [ + { + ...ADYEN_SESSION, + id: "session-adyen-2", + response_data: { id: "CS-2", sessionData: "blob-2" }, + }, + ], + }) + rerender( + + + + + + + ) + + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalledTimes(2) + }) + expect(adyen.dropinRemove).toHaveBeenCalled() + expect(adyen.captured.options.session).toEqual({ id: "CS-2", sessionData: "blob-2" }) + }) +}) + +describe("PayPal, which owns its own click", () => { + /** Tell the component which method the shopper has open, as the Drop-in does. */ + async function select(type: string): Promise { + await act(async () => { + adyen.captured.dropinOptions.onSelect({ type }) + }) + } + + function payPalConfig() { + const config = adyen.captured.dropinOptions.paymentMethodsConfiguration?.paypal + if (config == null) throw new Error("PayPal was not configured on the Drop-in") + return config + } + + async function mounted(): Promise { + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + } + + it("re-enables its own button, which the Core had switched off", async () => { + // Left false, PayPal's component returns `null` outright — the shopper gets + // an accordion that opens on nothing rather than a hidden button. + await mounted() + expect(payPalConfig().showPayButton).toBe(true) + expect(adyen.captured.options.showPayButton).toBe(false) + }) + + it("hands the place-order button a collection it cannot call", async () => { + // `submit` on PayPal throws by design, so the button is told who collects + // rather than being given a route that ends in IMPLEMENTATION_ERROR. + await mounted() + expect(getHandoffSnapshot("order-1").collection?.by).toBe("host") + + await select("paypal") + expect(getHandoffSnapshot("order-1").collection).toEqual({ by: "gateway" }) + }) + + it("hands it back when the shopper returns to the card", async () => { + await mounted() + await select("paypal") + await select("scheme") + expect(getHandoffSnapshot("order-1").collection?.by).toBe("host") + }) + + it("refuses the click when the terms have not been accepted", async () => { + // The gate, at the last moment before anything happens: `actions.reject()` + // aborts before the popup opens and before any Adyen call. + permitted.value = false + await mounted() + + const actions = { resolve: vi.fn(async () => {}), reject: vi.fn(async () => {}) } + await act(async () => { + await payPalConfig().onClick({}, actions) + }) + + expect(actions.reject).toHaveBeenCalled() + expect(actions.resolve).not.toHaveBeenCalled() + expect(authorizeGiftCardsMock).not.toHaveBeenCalled() + }) + + it("says why it refused, because a dead button reads as a broken one", async () => { + // The click is PayPal's, so this is the only place the reason can be + // produced. `meta.error` is what an application keys its copy off; the + // message is a default for one that renders `errors` as they come. + permitted.value = false + let reported: BaseError[] = [] + render( + + + + + {({ errors: adyenErrors }) => { + reported = adyenErrors + return <> + }} + + + + ) + await waitFor(() => { + expect(adyen.captured.dropinOptions).not.toBeNull() + }) + + const actions = { resolve: vi.fn(async () => {}), reject: vi.fn(async () => {}) } + await act(async () => { + await adyen.captured.dropinOptions.paymentMethodsConfiguration.paypal.onClick({}, actions) + }) + + expect(actions.reject).toHaveBeenCalled() + expect(reported[0]?.meta).toEqual({ error: "TermsNotAccepted" }) + }) + + it("renders its buttons disabled until the terms are accepted", async () => { + permitted.value = false + await mounted() + + const initActions = { enable: vi.fn(async () => {}), disable: vi.fn(async () => {}) } + await act(async () => { + payPalConfig().onInit({}, initActions) + }) + expect(initActions.disable).toHaveBeenCalled() + }) + + it("wakes every funding source's button when the terms are accepted after they render", async () => { + // Adyen renders four separate `paypal.Buttons()` instances — PayPal, + // Credit, Pay Later, Venmo — each with its own `onInit`, and + // `actions.enable()` reaches only the instance it came from. Keeping the + // last one handed over left a real shopper with a working Venmo button + // while PayPal and Pay Later swallowed the click, and no e2e saw it because + // the tests accept the terms *before* the buttons render. + permitted.value = false + const { rerender } = render(tree()) + await waitFor(() => { + expect(adyen.captured.dropinOptions).not.toBeNull() + }) + + const fundingSources = ["paypal", "credit", "paylater", "venmo"].map(() => ({ + enable: vi.fn(async () => {}), + disable: vi.fn(async () => {}), + })) + await act(async () => { + for (const actions of fundingSources) payPalConfig().onInit({}, actions) + }) + for (const actions of fundingSources) { + expect(actions.disable).toHaveBeenCalled() + } + + permitted.value = true + await act(async () => { + rerender(tree()) + }) + + for (const [index, actions] of fundingSources.entries()) { + expect(actions.enable, `funding source ${index} was left disabled`).toHaveBeenCalled() + } + }) + + it("charges the gift cards on the click, before the popup opens", async () => { + // The only moment we are given: PayPal's button performs the payment, and + // `beforeSubmit` runs after the popup is already open — and hangs it. + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + await mounted() + + const actions = { resolve: vi.fn(async () => {}), reject: vi.fn(async () => {}) } + await act(async () => { + await payPalConfig().onClick({}, actions) + }) + + expect(authorizeGiftCardsMock).toHaveBeenCalledTimes(1) + // Refetched, so the place sequence skips the cards it would otherwise + // authorize a second time. + expect(getOrder).toHaveBeenCalledWith("order-1") + expect(actions.resolve).toHaveBeenCalled() + }) + + it("does not open the popup when a gift card cannot be charged", async () => { + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + await mounted() + + const actions = { resolve: vi.fn(async () => {}), reject: vi.fn(async () => {}) } + await act(async () => { + await payPalConfig().onClick({}, actions) + }) + + expect(actions.reject).toHaveBeenCalled() + expect(actions.resolve).not.toHaveBeenCalled() + }) + + it("reports its completion as an out-of-band collection", async () => { + // Nobody pressed our button, so there is no promise to settle: the place + // button watches this and takes the order the rest of the way. + await mounted() + await select("paypal") + + await act(async () => { + adyen.captured.options.onPaymentCompleted({ resultCode: "Authorised" }) + }) + + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("done") + }) + + it("reports a completion that is not captured funds, and still places", async () => { + // `Pending` and `Received` arrive as success — PayPal produces them far + // more than cards do — so the code is surfaced rather than assumed. + let seen: string | undefined + render( + + + + + {({ lastResultCode }) => { + seen = lastResultCode + return <> + }} + + + + ) + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + await select("paypal") + + await act(async () => { + adyen.captured.options.onPaymentCompleted({ resultCode: "Pending" }) + }) + + expect(seen).toBe("Pending") + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("done") + }) + + it("reports a refusal as an out-of-band failure", async () => { + await mounted() + await select("paypal") + + await act(async () => { + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + }) + + const snapshot = getHandoffSnapshot("order-1") + expect(snapshot.collectedOutOfBand).toBe("failed") + expect(snapshot.errors[0]?.meta).toEqual({ error: "Refused" }) + }) + + it("touches nothing when the shopper closes the overlay", async () => { + // A closed overlay arrives on `onError`, and every `onError` is an unknown + // outcome: the Adyen Session stays, and the shopper can click again. + await mounted() + await select("paypal") + + await act(async () => { + adyen.captured.options.onError({ name: "CANCEL", message: "" }) + }) + + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("no") + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + }) +}) + +describe("Google Pay, whose click cannot wait", () => { + async function mounted(): Promise { + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + } + + function googlePayConfig() { + const config = adyen.captured.dropinOptions.paymentMethodsConfiguration?.googlepay + if (config == null) throw new Error("Google Pay was not configured on the Drop-in") + return config + } + + /** Tell the component the shopper has Google Pay open, as the Drop-in does. */ + async function selectGooglePay(): Promise { + await act(async () => { + adyen.captured.dropinOptions.onSelect({ type: "googlepay" }) + }) + } + + it("re-enables its own button, which the Core had switched off", async () => { + await mounted() + expect(googlePayConfig().showPayButton).toBe(true) + }) + + it("takes collection, because the gesture cannot survive our round trip", async () => { + // `submit` works on Google Pay, unlike PayPal — and it is still no use: + // `loadPaymentData()` runs after our gift cards and Google requires it + // inside the click's gesture. + await mounted() + await selectGooglePay() + expect(getHandoffSnapshot("order-1").collection).toEqual({ by: "gateway" }) + }) + + it("refuses the click synchronously when the terms are not accepted", async () => { + // Synchronously is the whole point: anything awaited here happens between + // the shopper's gesture and the sheet. Google's button has no disabled + // state either, so the message is all the shopper gets. + permitted.value = false + let reported: BaseError[] = [] + render( + + + + + {({ errors: adyenErrors }) => { + reported = adyenErrors + return <> + }} + + + + ) + await waitFor(() => { + expect(adyen.captured.dropinOptions).not.toBeNull() + }) + + const resolve = vi.fn() + const reject = vi.fn() + await act(async () => { + googlePayConfig().onClick(resolve, reject) + }) + + expect(reject).toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + expect(reported[0]?.meta).toEqual({ error: "TermsNotAccepted" }) + // Not even asked for: the gift cards are not this hook's business. + expect(authorizeGiftCardsMock).not.toHaveBeenCalled() + }) + + it("resolves the click without touching the network", async () => { + await mounted() + + const resolve = vi.fn() + const reject = vi.fn() + await act(async () => { + googlePayConfig().onClick(resolve, reject) + }) + + expect(resolve).toHaveBeenCalled() + expect(authorizeGiftCardsMock).not.toHaveBeenCalled() + expect(getOrder).not.toHaveBeenCalled() + }) + + it("charges the gift cards on the authorization, before the payment call", async () => { + // The moment after the sheet and before the money: Adyen calls `/payments` + // only once this resolves. + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + await mounted() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + googlePayConfig().onAuthorized({}, actions) + }) + + await waitFor(() => { + expect(actions.resolve).toHaveBeenCalled() + }) + expect(authorizeGiftCardsMock).toHaveBeenCalledTimes(1) + // Refetched, so the place sequence skips what has already been charged. + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("hands Google the reason a gift card could not be charged", async () => { + // A string reaches Google's own sheet verbatim, and the sheet stays open — + // so the shopper can try another card instead of losing the wallet flow. + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + await mounted() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + googlePayConfig().onAuthorized({}, actions) + }) + + await waitFor(() => { + expect(actions.reject).toHaveBeenCalledWith("Gift card balance is insufficient.") + }) + expect(actions.resolve).not.toHaveBeenCalled() + }) + + it("does not burn the Adyen Session when the abort was its own", async () => { + // Adyen routes a rejected `onAuthorized` through the same `onPaymentFailed` + // a refusal arrives on. Read as a refusal it would discard the Payment + // Session — and the session *is* the payment, so the retry Google is + // offering inside its still-open sheet would have nothing to pay with. + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + await mounted() + await selectGooglePay() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + googlePayConfig().onAuthorized({}, actions) + }) + await waitFor(() => { + expect(actions.reject).toHaveBeenCalled() + }) + + await act(async () => { + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + }) + + const snapshot = getHandoffSnapshot("order-1") + expect(snapshot.collectedOutOfBand).toBe("no") + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + }) + + it("still reports a real refusal on the next attempt", async () => { + // The flag is consumed, so it cannot swallow the refusal that follows it. + await mounted() + await selectGooglePay() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + googlePayConfig().onAuthorized({}, actions) + }) + await waitFor(() => { + expect(actions.resolve).toHaveBeenCalled() + }) + + await act(async () => { + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + }) + + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("failed") + }) +}) + +describe("Apple Pay, which is Google Pay's shape", () => { + /** + * Apple's error type, which only Safari defines. + * + * Stood up here because it is the one thing the two wallets do not share: + * Adyen types Apple Pay's `reject` for an `ApplePayError` and nothing else, + * so a bare string — which is exactly what Google Pay wants — is silently + * replaced by Apple's own generic wording. + */ + class FakeApplePayError { + constructor( + readonly code: string, + readonly contactField: string | undefined, + readonly message: string | undefined + ) {} + } + + beforeEach(() => { + ;(globalThis as { ApplePayError?: unknown }).ApplePayError = FakeApplePayError + }) + + afterEach(() => { + delete (globalThis as { ApplePayError?: unknown }).ApplePayError + }) + + /** Apple Pay is opt-in, so every test here asks for it. */ + async function mounted(): Promise { + render( + + + + + + + ) + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + } + + function applePayConfig() { + const config = adyen.captured.dropinOptions.paymentMethodsConfiguration?.applepay + if (config == null) throw new Error("Apple Pay was not configured on the Drop-in") + return config + } + + it("is configured exactly as Google Pay is", async () => { + // The assertion is the sameness. Both wallets re-enable their own button, + // take a positional `onClick` whose resolution opens their sheet inside the + // gesture, and run `onAuthorized` before `/payments` — so they are built by + // one function called twice, and this says so. + await mounted() + const googlePay = adyen.captured.dropinOptions.paymentMethodsConfiguration.googlepay + + expect(applePayConfig().showPayButton).toBe(true) + expect(Object.keys(applePayConfig()).sort()).toEqual(Object.keys(googlePay).sort()) + }) + + it("takes collection when the shopper opens it", async () => { + await mounted() + await act(async () => { + adyen.captured.dropinOptions.onSelect({ type: "applepay" }) + }) + expect(getHandoffSnapshot("order-1").collection).toEqual({ by: "gateway" }) + }) + + it("refuses the click before the sheet, when the terms are not accepted", async () => { + permitted.value = false + await mounted() + + const resolve = vi.fn() + const reject = vi.fn() + await act(async () => { + applePayConfig().onClick(resolve, reject) + }) + + expect(reject).toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + // `session.begin()` waits on this, and Safari wants it inside the gesture. + expect(authorizeGiftCardsMock).not.toHaveBeenCalled() + }) + + it("charges the gift cards on the authorization", async () => { + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + await mounted() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + applePayConfig().onAuthorized({}, actions) + }) + + await waitFor(() => { + expect(actions.resolve).toHaveBeenCalled() + }) + expect(authorizeGiftCardsMock).toHaveBeenCalledTimes(1) + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("dresses a refusal as an ApplePayError, which is the only thing Apple takes", async () => { + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + await mounted() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + applePayConfig().onAuthorized({}, actions) + }) + + await waitFor(() => { + expect(actions.reject).toHaveBeenCalled() + }) + const [error] = actions.reject.mock.calls[0] as [FakeApplePayError] + expect(error).toBeInstanceOf(FakeApplePayError) + expect(error.message).toBe("Gift card balance is insufficient.") + // `unknown` is the only code that is not about a contact field. + expect(error.code).toBe("unknown") + }) + + it("rejects with nothing at all where Apple's error type does not exist", async () => { + // Unreachable in practice — no `ApplePayError` global means no Apple Pay + // button was ever rendered — but `reject` is typed for that class alone, so + // the alternative would be handing Apple a string it discards. + delete (globalThis as { ApplePayError?: unknown }).ApplePayError + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + await mounted() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + applePayConfig().onAuthorized({}, actions) + }) + + await waitFor(() => { + expect(actions.reject).toHaveBeenCalledWith(undefined) + }) + }) + + it("does not burn the Adyen Session when the abort was its own", async () => { + authorizeGiftCardsMock.mockResolvedValue({ + authorizedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Gift card balance is insufficient." }], + }) + await mounted() + await act(async () => { + adyen.captured.dropinOptions.onSelect({ type: "applepay" }) + }) + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + applePayConfig().onAuthorized({}, actions) + }) + await waitFor(() => { + expect(actions.reject).toHaveBeenCalled() + }) + + await act(async () => { + adyen.captured.options.onPaymentFailed({ resultCode: "Refused" }) + }) + + expect(getHandoffSnapshot("order-1").collectedOutOfBand).toBe("no") + expect(discardPaymentSessionMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-components/specs/payment_settings/gift-card-removal.spec.tsx b/packages/react-components/specs/payment_settings/gift-card-removal.spec.tsx new file mode 100644 index 00000000..d1b3c2e4 --- /dev/null +++ b/packages/react-components/specs/payment_settings/gift-card-removal.spec.tsx @@ -0,0 +1,314 @@ +import type { Order } from "@commercelayer/sdk" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentSettingGiftCard } from "#components/payment_settings/PaymentSettingGiftCard" +import { PaymentSettingGiftCardList } from "#components/payment_settings/PaymentSettingGiftCardList" +import { PaymentSettingGiftCardListItem } from "#components/payment_settings/PaymentSettingGiftCardListItem" +import { PaymentSettingGiftCardRemoveButton } from "#components/payment_settings/PaymentSettingGiftCardRemoveButton" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const { refundGiftCardSessionsMock, removeGiftCardMock } = vi.hoisted(() => ({ + refundGiftCardSessionsMock: vi.fn(), + removeGiftCardMock: vi.fn(), +})) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + refundGiftCardSessions: refundGiftCardSessionsMock, + removeGiftCard: removeGiftCardMock, + } +}) + +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards", name: "Gift card" } +const SETTLED = { payment_authorization: { status: "succeeded" } } + +function giftCardSession(overrides: Record = {}) { + return { + id: "gc-1", + status: "unpaid", + amount_cents: 2000, + formatted_amount: "$20.00", + gift_card_code: "CODE-1", + payment_setting: GIFT_CARD, + ...overrides, + } +} + +function order(overrides: Record = {}): Partial { + return { + id: "order-1", + status: "pending", + total_amount_with_taxes_cents: 7100, + available_payment_settings: [GIFT_CARD], + payment_sessions: [giftCardSession()], + ...overrides, + } as Partial +} + +const getOrder = vi.fn() +const seen: { removal?: string; isRemoving?: boolean } = {} + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder: Partial +}) { + return ( + + + {children} + + + ) +} + +function renderList(currentOrder: Partial = order()) { + return render( + + + + + {({ removal, isRemoving }) => { + seen.removal = removal + seen.isRemoving = isRemoving + return ( +
+ +
+ ) + }} +
+
+
+
+ ) +} + +beforeEach(() => { + vi.clearAllMocks() + seen.removal = undefined + seen.isRemoving = undefined + removeGiftCardMock.mockResolvedValue(undefined) + refundGiftCardSessionsMock.mockResolvedValue({ + refundedSessionIds: ["gc-1"], + errors: [], + timedOut: false, + }) + getOrder.mockResolvedValue(order()) +}) + +describe("removing a gift card that took no money", () => { + it("discards it, without a refund", async () => { + renderList() + + expect(seen.removal).toBe("discard") + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + + expect(removeGiftCardMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionId: "gc-1" }) + ) + expect(refundGiftCardSessionsMock).not.toHaveBeenCalled() + }) +}) + +describe("removing a gift card that has been charged", () => { + const charged = () => + order({ payment_sessions: [giftCardSession({ ...SETTLED, status: "paid" })] }) + + it("refunds it instead of trying a delete the API would refuse", async () => { + // A session with transactions attached cannot be deleted — the API raises + // and surfaces it as an unhandled 500 — so the refund is the only route. + renderList(charged()) + + expect(seen.removal).toBe("refund") + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + + expect(refundGiftCardSessionsMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionIds: ["gc-1"] }) + ) + expect(removeGiftCardMock).not.toHaveBeenCalled() + }) + + it("does not delete the session afterwards", async () => { + // Nothing has to hide the row: the session lands on `refunded`, and that + // status is what drops it out of the applied list and puts the amount back + // into the remainder. + renderList(charged()) + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + + expect(removeGiftCardMock).not.toHaveBeenCalled() + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("marks the whole row busy while the refund runs, not just the button", async () => { + // A discard is one request; a refund waits on a background job and then + // polls for it. The row is where an application can say so. + let release: () => void = () => {} + refundGiftCardSessionsMock.mockImplementation( + async () => + await new Promise((resolve) => { + release = () => { + resolve({ refundedSessionIds: ["gc-1"], errors: [], timedOut: false }) + } + }) + ) + renderList(charged()) + + expect(seen.isRemoving).toBe(false) + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + expect(seen.isRemoving).toBe(true) + + await act(async () => { + release() + }) + await waitFor(() => { + expect(seen.isRemoving).toBe(false) + }) + }) +}) + +describe("when a gift card cannot come off at all", () => { + it("renders no control once the order has been placed", async () => { + // A storefront token's refund grant names `pending` exactly. This is the + // timed-out place — the shopper's cards are charged and a storefront + // cannot give the money back. + renderList( + order({ + status: "placed", + payment_sessions: [giftCardSession({ ...SETTLED, status: "paid" })], + }) + ) + + expect(seen.removal).toBeUndefined() + expect(screen.queryByTestId("remove")).toBeNull() + // The card itself stays on screen: it is a payment in place, and hiding it + // would leave the shopper unable to explain why less is owed. + expect(screen.getByTestId("row")).toBeTruthy() + }) + + it("renders no control while the charge is still settling", async () => { + // Neither route is open for those few seconds: the delete would be refused + // and the refund has no capture to point at yet. + renderList( + order({ + payment_sessions: [giftCardSession({ payment_authorization: { status: "pending" } })], + }) + ) + + expect(seen.removal).toBeUndefined() + expect(screen.queryByTestId("remove")).toBeNull() + }) + + it("renders no control in a readonly subtree", async () => { + render( + + + + + {({ removal }) => { + seen.removal = removal + return + }} + + + + + ) + + expect(seen.removal).toBeUndefined() + expect(screen.queryByTestId("remove")).toBeNull() + }) +}) + +describe("when the refund does not go through", () => { + it("shows the API's own refusal", async () => { + refundGiftCardSessionsMock.mockResolvedValue({ + refundedSessionIds: [], + errors: [{ code: "VALIDATION_ERROR", message: "Refund amount exceeds the capture." }], + timedOut: false, + }) + render( + + + {({ errors }) => ( + <> + {errors.map((e) => e.message).join(" ")} + + + {() => } + + + + )} + + + ) + + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + + await waitFor(() => { + expect(screen.getByTestId("errors").textContent).toBe("Refund amount exceeds the capture.") + }) + }) + + it("says nothing to the shopper on a timeout, and leaves the card charged", async () => { + // Nothing was refused — the capture had not appeared. The card is still + // applied and still charged, which is the truth, and inventing copy here + // would put payment wording in a package that cannot know the locale. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + refundGiftCardSessionsMock.mockResolvedValue({ + refundedSessionIds: [], + errors: [], + timedOut: true, + }) + render( + + + {({ errors }) => ( + <> + {errors.map((e) => e.message).join(" ")} + + + {() => } + + + + )} + + + ) + + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + + expect(screen.getByTestId("errors").textContent).toBe("") + expect(warn).toHaveBeenCalledWith(expect.stringContaining("gave up waiting for the capture")) + }) +}) diff --git a/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx b/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx index 7ad08985..0021b26d 100644 --- a/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx +++ b/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx @@ -33,7 +33,7 @@ export function GiftCardOrCouponForm(props: Props): JSX.Element | null { // so equal lengths mean identical contents — dispatching it would only mint a fresh // `errors` array reference, which this effect depends on, re-firing it forever // (React 19 hard-crashes with "Maximum update depth exceeded"). Same identity-churn - // failure class as docs/adr/0001-payment-source-effect-invariants.md. + // failure class as the payment-source sync effect. if (fieldErrors.length === current.length) return setOrderErrors(fieldErrors) onSubmit?.({ value: "", success: false }) diff --git a/packages/react-components/src/components/orders/PlaceOrderButton.tsx b/packages/react-components/src/components/orders/PlaceOrderButton.tsx index 35ec2b49..273b4c1d 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButton.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButton.tsx @@ -37,8 +37,7 @@ interface Props extends Omit` keeps working unchanged, * whichever model its orders are on. The two implementations behind it share - * almost nothing — see - * `docs/adr/2026-08-18-place-order-split-by-payments-model.md`. + * almost nothing. * * Mount `` or * `` directly to skip the routing when an diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx index c7f29710..ca34e1b7 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -1,18 +1,30 @@ import { + authorizeGiftCardSessions, + DEFAULT_GATEWAY_PLACEABLE_ATTEMPTS, + DEFAULT_GATEWAY_PLACEABLE_INTERVAL_MS, DEFAULT_PLACEABLE_ATTEMPTS, DEFAULT_PLACEABLE_INTERVAL_MS, + discardPaymentSession, placeOrderWithPaymentSessions, } from "@commercelayer/core-components" import type { Order } from "@commercelayer/sdk" -import { type JSX, type MouseEvent, type ReactNode, useContext, useState } from "react" +import { + type JSX, + type MouseEvent, + type ReactNode, + useContext, + useEffect, + useRef, + useState, +} from "react" import Parent from "#components/utils/Parent" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" +import { useCollectionPermitted } from "#hooks/useCollectionPermitted" +import { usePaymentGatewayHandoff } from "#hooks/usePaymentGatewayHandoff" import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" -import { useTermsAndConditions } from "#hooks/useTermsAndConditions" import type { BaseError } from "#typings/errors" import type { ChildrenFunction } from "#typings/index" -import { useOrganizationConfig } from "#utils/organization" interface ChildrenProps extends Omit { handleClick: () => Promise @@ -26,11 +38,15 @@ interface Props extends Omit void /** * Placeability attempts before the errors are shown to the shopper. - * Defaults to 5. See `placeOrderWithPaymentSessions` for why retrying first - * is the correct behaviour rather than an optimisation. + * + * Defaults depend on who took the payment: a setting whose authorization is a + * local background job needs a few hundred milliseconds, while anything + * collected by a gateway settles on that gateway's webhook and needs an order + * of magnitude longer. See `placeOrderWithPaymentSessions` for why retrying + * before reporting is correct behaviour and not an optimisation. */ placeableAttempts?: number - /** Delay between placeability attempts, in milliseconds. Defaults to 1000. */ + /** Delay between placeability attempts, in milliseconds. */ placeableIntervalMs?: number } @@ -53,8 +69,14 @@ interface Props extends Omit): Promise => { - event?.preventDefault() - event?.stopPropagation() - if (order == null || accessToken == null || isLoading) return + // Anything a gateway collected settles on that gateway's webhook, whoever + // pressed the button — so the placeability wait is a different order of + // magnitude either way. An explicit prop still wins: a consumer who has + // measured their own gateway knows better than a default. + const isGatewayPayment = collection != null + const attempts = + placeableAttempts ?? + (isGatewayPayment ? DEFAULT_GATEWAY_PLACEABLE_ATTEMPTS : DEFAULT_PLACEABLE_ATTEMPTS) + const intervalMs = + placeableIntervalMs ?? + (isGatewayPayment ? DEFAULT_GATEWAY_PLACEABLE_INTERVAL_MS : DEFAULT_PLACEABLE_INTERVAL_MS) - setIsLoading(true) - setOrderErrors([]) + const reportErrors = (errors: BaseError[], placedOrder?: Order): void => { + setOrderErrors(errors) + onClick?.({ placed: false, order: placedOrder, errors }) + } + + const refetch = async (): Promise => { + if (order == null) return try { - // The whole order goes in: which sessions get authorized, and in which - // order — gift cards first, then the one paying the difference — is - // domain knowledge that belongs with the sequence, not here. - const result = await placeOrderWithPaymentSessions({ + await getOrder(order.id) + } catch { + // The error already on screen is the one worth showing; a failed refetch + // must not replace it with a second one. + } + } + + /** + * Delete the Payment Session a refusal burnt, best effort. + * + * Its failure is not worth reporting: if the delete is refused it is because + * the failed authorization has already landed, and a session in that state is + * excluded from both the current selection and the reuse predicate anyway. + */ + const discardBurntSession = async (): Promise => { + if (accessToken == null || currentPaymentSession == null) return + await discardPaymentSession({ + accessToken, + interceptors, + paymentSessionId: currentPaymentSession.id, + }) + } + + /** Take the order the rest of the way, for a payment that is already in place. */ + const placeCollected = async (working: Order): Promise => { + if (accessToken == null) return + // The whole order goes in: which sessions get authorized, and in which + // order — gift cards first, then the one paying the difference — is domain + // knowledge that belongs with the sequence, not here. + const result = await placeOrderWithPaymentSessions({ + accessToken, + interceptors, + order: working, + attempts, + intervalMs, + }) + + if (result.placed) { + onClick?.({ placed: true, order: result.order }) + return + } + + reportErrors( + result.errors.map((error) => ({ + code: "VALIDATION_ERROR" as const, + resource: "orders" as const, + message: error.message, + field: error.field, + ...(error.meta != null ? { meta: error.meta } : {}), + })), + result.order + ) + // The order moved on without us — an authorization may have landed, or + // auto_place may have fired — so pull the truth back in rather than leaving + // the shopper looking at stale amounts. + await refetch() + } + + /** + * The click path: authorize the gift cards, ask the gateway to collect, place. + * + * The gift cards go **first**, before the gateway is asked for anything. That + * is the charge order the whole design depends on, and owning the submit is + * the only reason it can be kept here: + * the money leaves a card the moment the Drop-in is submitted, so leaving the + * gift cards to `placeOrderWithPaymentSessions` — which runs afterwards — + * would charge them second. + * + * The order is refetched in between because that sequence skips a session + * that already carries a live authorization by reading the order it was + * *handed*: passing the pre-authorization copy on would authorize the same + * cards again and take the money twice. + */ + const collectAndPlace = async (): Promise => { + if (order == null || accessToken == null) return + + // A method with its own button is not ours to collect through. The button + // is disabled for it, so this is a guard rather than a branch — but it is + // the guard that stops the order being placed with nothing collected, which + // is what happened when a gateway component registered from a card it was + // not rendering. + if (collection?.by === "gateway") return + + let working = order + + if (collection?.by === "host") { + const authorized = await authorizeGiftCardSessions({ accessToken, interceptors, - order, - attempts: placeableAttempts, - intervalMs: placeableIntervalMs, + order: working, }) - if (result.placed) { - onClick?.({ placed: true, order: result.order }) + if (authorized.errors.length > 0) { + reportErrors( + authorized.errors.map((error) => ({ + code: "VALIDATION_ERROR" as const, + resource: "orders" as const, + message: error.message, + field: error.field, + ...(error.meta != null ? { meta: error.meta } : {}), + })) + ) + await refetch() return } - const errors: BaseError[] = result.errors.map((error) => ({ - code: "VALIDATION_ERROR", - resource: "orders", - message: error.message, - field: error.field, - ...(error.meta != null ? { meta: error.meta } : {}), - })) - setOrderErrors(errors) - onClick?.({ placed: false, order: result.order, errors }) - // The order moved on without us — an authorization may have landed, or - // auto_place may have fired — so pull the truth back in rather than - // leaving the shopper looking at stale amounts. - await getOrder(order.id) + if (authorized.authorizedSessionIds.length > 0) { + working = (await getOrder(order.id)) ?? working + } + + const collected = await collection.submit() + + if (collected.status === "incomplete") { + // The gateway is showing its own validation. Nothing to report, and + // nothing to roll back: no money moved. + return + } + + if (collected.status === "failed") { + // A verdict: the card took nothing. + // + // **The gift cards stay charged and applied**, and this is the version + // that replaced an automatic refund. A refused card is the *ordinary* + // failure of a checkout — the shopper tries another card — so giving + // their credit back here destroys exactly what the next attempt needs, + // and they cannot simply re-apply it: `canAddGiftCard` is false while + // anything is authorized, and the codes would have to be typed again. + // Giving the money back is theirs to ask for, one card at a time, + // through ``. + // + // The Payment Session is burnt, and deleted: retrying on it is broken + // server-side, and until the gateway's webhook lands the failed + // authorization it goes on reading as reusable. Nothing is created in + // its place — the shopper picks the payment method again. + await discardBurntSession() + reportErrors([gatewayError(collected.code)]) + await refetch() + return + } + + if (collected.status === "unknown") { + // The payment may have gone through. Nothing is rolled back and the + // session is **not** deleted — refunding could take back money for a + // card that did charge, and the session is the record the gateway's + // webhook settles against. + reportErrors([gatewayError(collected.code)]) + await refetch() + return + } + } + + await placeCollected(working) + } + + const place = async (run: () => Promise): Promise => { + if (order == null || accessToken == null || isLoading) return + setIsLoading(true) + setOrderErrors([]) + try { + await run() } catch (error) { - const errors: BaseError[] = [ + reportErrors([ { code: "VALIDATION_ERROR", resource: "orders", message: error instanceof Error ? error.message : "The order could not be placed.", }, - ] - setOrderErrors(errors) - onClick?.({ placed: false, errors }) + ]) // Refetch here too, and not only on the reported-error path above. // Authorizations may well have been created before this threw, and the // order in context still shows their sessions without one — which reads @@ -147,27 +305,88 @@ export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { // stale order gets a second authorization over the first, and the money // taken twice. Pulling the order back makes the existing // `hasLiveAuthorization` guard see what actually happened. - try { - await getOrder(order.id) - } catch { - // The error already on screen is the one worth showing; a failed - // refetch must not replace it with a second one. - } + await refetch() } finally { setIsLoading(false) } } + const handleClick = async (event?: MouseEvent): Promise => { + event?.preventDefault() + event?.stopPropagation() + await place(collectAndPlace) + } + + /** + * An **Out-of-Band Collection** completed: the money is taken and nobody + * clicked anything. + * + * Two things produce it and they are one event. A 3DS redirect came back, on + * a page that reloaded — so acceptance of the terms did not survive, and + * asking for it again would leave anyone who declines with a paid, unplaced + * order. Or a method with its own button collected, where the click was never + * ours to gate. Acceptance did happen in both: before the redirect, or inside + * the method's own click. So the order is placed here on the library's own + * initiative, and these are the only paths where that is true. + * + * The order is refetched first. On the redirect path the resume hook has + * already done it, but a gateway's own button has not — and the gift cards it + * authorized before collecting are invisible in the copy held here, which + * would authorize them a second time. + */ + const outOfBandRef = useRef(async (): Promise => {}) + outOfBandRef.current = async (): Promise => { + if (order == null) return + await place(async () => { + const refreshed = (await getOrder(order.id)) ?? order + await placeCollected(refreshed) + }) + } + const failedOutOfBandRef = useRef(async (): Promise => {}) + failedOutOfBandRef.current = async (): Promise => { + await discardBurntSession() + await refetch() + } + + const outOfBandHandledRef = useRef(false) + useEffect(() => { + if (collectedOutOfBand === "done" && !outOfBandHandledRef.current) { + outOfBandHandledRef.current = true + void outOfBandRef.current() + return + } + if (collectedOutOfBand === "failed" && !outOfBandHandledRef.current) { + outOfBandHandledRef.current = true + setOrderErrors(outOfBandErrors) + // Burnt for the same reason as a refusal on our own click, so it goes the + // same way. The gift cards are **not** given back here: on a redirect + // they were charged on a previous page load and this one has no record of + // which of them this attempt authorized, so returning them could take + // money for a payment that is still settling. + void failedOutOfBandRef.current() + } + }, [collectedOutOfBand, outOfBandErrors, setOrderErrors]) + + const isCollecting = collectedOutOfBand === "in-progress" || collectedOutOfBand === "done" + const busy = isLoading || isCollecting + // A method with its own button cannot be collected through this one, so the + // button says so rather than offering a second route to one action where its + // own route leads nowhere: `submit` on such a gateway throws by design. + const cannotCollect = collection?.by === "gateway" const disabledButton = - disabled !== undefined ? disabled : !privacyAccepted || !isPaymentInPlace - const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label + disabled !== undefined + ? disabled + : isCollecting || cannotCollect || !collectionPermitted || !isPaymentInPlace + const labelButton = busy ? loadingLabel : typeof label === "function" ? label() : label return children ? ( - {children} + + {children} + ) : (