diff --git a/CONTEXT.md b/CONTEXT.md index 3b1f2e47..1708bca6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -65,6 +65,30 @@ _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 tells `` that it can collect a payment, and how the button asks it to. An external store keyed by order id, carrying `{ submit, isReady }` plus the phase of a **Redirect Return** — not context, because the two components are siblings in a checkout rather than parent and child. `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 — means the payment may have gone through and nothing may be undone. Deliberately gateway-neutral: the button asks whether *a* gateway has registered, never which one. The same shape terms acceptance already uses, for the same reason. +_Avoid_: payment ref (the `payment_source`-model mechanism, which publishes a form ref instead), submit handler + +**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 +102,11 @@ _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 ## Example dialogue @@ -105,6 +134,26 @@ _Avoid_: using "gateway" to mean the Payment Method - "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" })`. +## 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 — gift cards > **Dev:** "The shopper applied a $50 gift card on a $71 order. Why is the API still saying $71 is left?" diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md index 1438fb60..ec5c1a96 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 @@ -238,14 +238,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 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..9a2f6313 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 @@ -237,11 +237,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..05c4ed25 --- /dev/null +++ b/docs/adr/2026-09-02-adyen-payment-setting.md @@ -0,0 +1,626 @@ +# 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, so a rollback is + safe. +- **`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 and it has a remedy: a refused card leaves gift cards +charged, and the API grants exactly the refund needed to undo that (gift card sessions, order +`pending`). Reversing the order would trade a **common** failure for a **rare** but +**unrecoverable** one: a card charged for the remainder with the gift cards unpaid, +`canAddGiftCard` already false, and no way out. + +### 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: + +1. The button also decides whether the gift cards are given back, and a refund changes what is + left to pay. A replacement created by the gateway component would be sized for the + pre-refund remainder. +2. Re-selecting the setting to get a fresh session does not work from inside the 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 how they see that their gift cards came back and the amount changed. + +**On the redirect path the gift cards are not refunded.** They were charged on a previous page +load, and which of them *this* attempt authorized went with it — so giving them back could +take money for a payment that is still settling. They stay applied and visible on the order, +the stance `2026-08-20-gift-cards-as-payment-sessions.md` already takes for a timed-out place. +The burnt session is still deleted. + +### 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: Apple Pay, Google Pay and PayPal inside the Drop-in + render their own pay buttons and submit themselves, which would bypass `` + and the terms gate — the one property the whole design is built on. +- **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 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. + +**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`.** `action_by_status` has no default + branch, and that alone is why the authorization stays `pending` rather than landing in + `failed`. `#authorize!` also lacks the `if result.status >= 300` check its sibling `#create` + has. Nothing tests this. "Fixing" that asymmetry would kill every Drop-in payment *and* + poison the webhook that would otherwise rescue it, because `succeed` cannot be reached from + `failed`. **A regression spec in `core-api` pinning "a 422 from `/payments` leaves the + authorization `pending`" is worth more than anything we can write here.** +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 | +| 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/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..07b186b6 --- /dev/null +++ b/packages/core-components/src/payment_sessions/authorizeGiftCardSessions.ts @@ -0,0 +1,82 @@ +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 + * `2026-08-20-gift-cards-as-payment-sessions.md` established. + * + * 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. + * + * 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[] = [] + + for (const session of giftCardSessions) { + // 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..5143782f --- /dev/null +++ b/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { 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") + }) +}) 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..e9b9acda --- /dev/null +++ b/packages/core-components/src/payment_sessions/buildAdyenReturnUrl.ts @@ -0,0 +1,47 @@ +/** + * 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 + +/** + * 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 = "" + + return url.toString() +} 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/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/giftCardRollback.spec.ts b/packages/core-components/src/payment_sessions/giftCardRollback.spec.ts new file mode 100644 index 00000000..49f081b5 --- /dev/null +++ b/packages/core-components/src/payment_sessions/giftCardRollback.spec.ts @@ -0,0 +1,274 @@ +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"]) + }) + + 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 + + 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.mockResolvedValue( + order([giftCard("gc-1", { status: "paid", payment_captures: [capture("cap-1")] })]) + ) + + 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 capture the background job has not created yet", async () => { + const { retrieve, refundCreate } = stubSdk() + retrieve + .mockResolvedValueOnce(order([giftCard("gc-1", { payment_captures: [] })])) + .mockResolvedValueOnce( + order([giftCard("gc-1", { status: "paid", payment_captures: [capture("cap-1")] })]) + ) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + intervalMs: 0, + }) + + expect(retrieve).toHaveBeenCalledTimes(2) + 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 an already refunded session as done rather than refunding it twice", async () => { + const { retrieve, refundCreate } = stubSdk() + retrieve.mockResolvedValue( + order([ + giftCard("gc-1", { + status: "refunded", + payment_captures: [capture("cap-1")], + payment_refunds: [{ id: "refund-0" } as never], + }), + ]) + ) + + const result = await refundGiftCardSessions({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSessionIds: ["gc-1"], + intervalMs: 0, + }) + + expect(refundCreate).not.toHaveBeenCalled() + expect(result).toEqual({ refundedSessionIds: [], 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() + retrieve.mockResolvedValue( + order([ + giftCard("gc-1", { status: "paid", payment_captures: [capture("cap-1")] }), + giftCard("gc-2", { status: "paid", payment_captures: [capture("cap-2")] }), + ]) + ) + 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..aea693a1 100644 --- a/packages/core-components/src/payment_sessions/index.ts +++ b/packages/core-components/src/payment_sessions/index.ts @@ -1,7 +1,11 @@ export { applyGiftCard } from "./applyGiftCard" +export type { AuthorizeGiftCardSessionsResult } from "./authorizeGiftCardSessions" +export { authorizeGiftCardSessions } from "./authorizeGiftCardSessions" +export { 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" @@ -11,12 +15,21 @@ 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 +37,14 @@ export type { PlaceabilityError, } from "./types" export { + ADYEN_SETTING_TYPE, GIFT_CARD_SETTING_TYPE, + hasAuthorizationInFlight, + hasFailedAuthorization, hasLiveAuthorization, + isAdyenSession, isGiftCardSession, 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..f9326742 --- /dev/null +++ b/packages/core-components/src/payment_sessions/refundGiftCardSessions.ts @@ -0,0 +1,153 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +import type { PlaceabilityError } from "./types" + +/** Attempts spent waiting for the capture a refund has to point at. */ +export const DEFAULT_REFUND_ATTEMPTS = 6 +/** 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 { + refundedSessionIds: string[] + /** Sessions still charged when this gave up, and why. */ + errors: PlaceabilityError[] + /** + * True when the captures never appeared. The gift cards are still charged and + * the shopper's balance is 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.** `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. Each attempt is one `GET`, and the + * loop ends as soon as every session is handled. + * + * A session that already carries a refund is treated as done rather than + * refunded twice; the balance was restored the first time. + * + * 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) + 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) { + // Already given back, by us on an earlier attempt or by someone else. + if ((session.payment_refunds ?? []).length > 0) { + pending.delete(session.id) + continue + } + + const capture = refundableCapture(session) + // The 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. + }) + refundedSessionIds.push(session.id) + pending.delete(session.id) + } 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..b9c6e5e0 100644 --- a/packages/core-components/src/payment_sessions/types.ts +++ b/packages/core-components/src/payment_sessions/types.ts @@ -171,3 +171,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..5d1848fb --- /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").resumePhase).toBe("idle") + }) + + 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").resumePhase).toBe("resumed") + }) + }) + + 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.resumePhase).toBe("failed") + expect(snapshot.resumeErrors[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.resumePhase).toBe("failed") + expect(snapshot.resumeErrors[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..6647f9e6 --- /dev/null +++ b/packages/react-components/specs/orders/place-order-gateway-handoff.spec.tsx @@ -0,0 +1,326 @@ +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, + registerPaymentGateway, + resetPaymentGatewayStore, + setPaymentGatewayResume, +} 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 ``: registers, answers on demand. */ +function useFakeGateway(result: PaymentGatewaySubmitResult) { + const submit = vi.fn(async () => result) + registerPaymentGateway("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("refunds the gift cards it charged and burns the session on a refusal", async () => { + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: ["gc-1"], errors: [] }) + useFakeGateway({ status: "failed", code: "Refused" }) + renderButton() + + await clickPlace() + + expect(refundGiftCardsMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionIds: ["gc-1"] }) + ) + expect(discardPaymentSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionId: "session-adyen" }) + ) + expect(placeOrderMock).not.toHaveBeenCalled() + expect(setOrderErrors).toHaveBeenCalledWith([ + expect.objectContaining({ message: "Refused", meta: { error: "Refused" } }), + ]) + }) + + it("refunds only what this attempt charged", async () => { + // A card charged by an earlier timed-out attempt is not ours to give back. + authorizeGiftCardsMock.mockResolvedValue({ authorizedSessionIds: [], errors: [] }) + useFakeGateway({ status: "failed", code: "Refused" }) + renderButton() + + await clickPlace() + + expect(refundGiftCardsMock).not.toHaveBeenCalled() + }) + + it("touches nothing when the outcome is unknown", async () => { + // The payment may have gone through: refunding could take back money for a + // card that did charge, and the session is what the webhook settles against. + 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({ meta: { error: "NETWORK_ERROR" } }), + ]) + }) +}) + +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 () => { + setPaymentGatewayResume("order-1", "resumed") + }) + + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + }) + + it("places once, however many times the phase is republished", async () => { + renderButton() + + await act(async () => { + setPaymentGatewayResume("order-1", "resumed") + }) + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledTimes(1) + }) + await act(async () => { + setPaymentGatewayResume("order-1", "idle") + setPaymentGatewayResume("order-1", "resumed") + }) + + 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 () => { + setPaymentGatewayResume("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 () => { + setPaymentGatewayResume("order-1", "resuming") + }) + + expect((screen.getByTestId("place") as HTMLButtonElement).disabled).toBe(true) + }) +}) 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..00e7c0ad --- /dev/null +++ b/packages/react-components/specs/payment_settings/PaymentSettingAdyenPayment.spec.tsx @@ -0,0 +1,429 @@ +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 { 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 { 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, + discardPaymentSession: discardPaymentSessionMock, + } +}) + +// `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() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +function renderAdyen(currentOrder: Partial | null = order()) { + return render( + + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + resetPaymentGatewayStore() + adyen.isValid = true + adyen.captured.options = null + adyen.captured.dropinOptions = null + 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 cards only", async () => { + // Apple Pay, Google Pay and PayPal render their own pay buttons and submit + // themselves, which would bypass the place-order button and the terms gate. + renderAdyen() + + await waitFor(() => { + expect(adyen.captured.options).not.toBeNull() + }) + expect(adyen.captured.options.allowPaymentMethods).toEqual(["scheme"]) + }) + + 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").submit).not.toBeNull() + }) + }) + + it("resolves as completed when Adyen reports the payment taken", async () => { + renderAdyen() + await waitFor(() => { + expect(adyen.dropinMount).toHaveBeenCalled() + }) + + const submit = getHandoffSnapshot("order-1").submit + 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 = getHandoffSnapshot("order-1").submit + 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 = getHandoffSnapshot("order-1").submit + 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 = getHandoffSnapshot("order-1").submit + 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(getHandoffSnapshot("order-1").isReady).toBe(true) + + await act(async () => { + adyen.captured.options.onChange({ isValid: false }) + }) + expect(getHandoffSnapshot("order-1").isReady).toBe(false) + }) +}) + +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" }) + }) +}) diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx index c7f29710..90cfe36e 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -1,13 +1,27 @@ import { + authorizeGiftCardSessions, + DEFAULT_GATEWAY_PLACEABLE_ATTEMPTS, + DEFAULT_GATEWAY_PLACEABLE_INTERVAL_MS, DEFAULT_PLACEABLE_ATTEMPTS, DEFAULT_PLACEABLE_INTERVAL_MS, + discardPaymentSession, placeOrderWithPaymentSessions, + refundGiftCardSessions, } 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 { usePaymentGatewayHandoff } from "#hooks/usePaymentGatewayHandoff" import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" import { useTermsAndConditions } from "#hooks/useTermsAndConditions" import type { BaseError } from "#typings/errors" @@ -26,11 +40,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 a card + * 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 } @@ -55,6 +73,12 @@ interface Props extends Omit): Promise => { - event?.preventDefault() - event?.stopPropagation() - if (order == null || accessToken == null || isLoading) return + // A gateway that collects client-side settles on its own webhook, so the + // placeability wait is a different order of magnitude. An explicit prop still + // wins — a consumer who has measured their own gateway knows better than a + // default. + const isGatewayPayment = submit != 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([]) - 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({ + const reportErrors = (errors: BaseError[], placedOrder?: Order): void => { + setOrderErrors(errors) + onClick?.({ placed: false, order: placedOrder, errors }) + } + + /** + * Authorize, collect, then place. + * + * The gift cards go **first**, before the gateway is asked for anything. That + * is the charge order `2026-08-20-gift-cards-as-payment-sessions.md` + * established, and owning the submit is the only reason it can be kept: 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 skip reads the order it was + * handed: passing the pre-authorization copy on would authorize the same cards + * again and take the money twice. + */ + const runPlace = async (): Promise => { + if (order == null || accessToken == null) return + + let working = order + let authorizedGiftCardIds: string[] = [] + + if (isGatewayPayment) { + const authorized = await authorizeGiftCardSessions({ accessToken, interceptors, - order, - attempts: placeableAttempts, - intervalMs: placeableIntervalMs, + order: working, }) + authorizedGiftCardIds = authorized.authorizedSessionIds - 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", + if (authorizedGiftCardIds.length > 0) { + working = (await getOrder(order.id)) ?? working + } + + const collected = await 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, so the rollback is safe — and it is the whole reason the + // gift cards go first. Failures are swallowed on purpose: the error + // worth showing is the gateway's, and a refund that could not be taken + // leaves the cards applied and visible on the order, which is the + // recovery surface the gift card ADR already relies on. + if (authorizedGiftCardIds.length > 0) { + const refund = await refundGiftCardSessions({ + accessToken, + interceptors, + orderId: order.id, + paymentSessionIds: authorizedGiftCardIds, + }) + if ( + process.env.NODE_ENV !== "production" && + (refund.timedOut || refund.errors.length > 0) + ) { + console.warn( + "[commercelayer] could not give back every gift card charged for a refused payment. They stay applied to the order.", + refund + ) + } + } + // The Payment Session is burnt: retrying on it is broken server-side, + // and until the gateway's webhook lands the failed authorization it + // still reads as reusable. Deleting is the only deterministic way to + // keep the next attempt off it. + // + // Done here rather than in the gateway component because the refund + // above changes what is left to pay, and a replacement created before + // it would be sized for the wrong amount. Nothing is created in its + // place: the shopper picks the payment method again, which is also how + // they see that their gift cards came back. + 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 needs to settle against. + reportErrors([gatewayError(collected.code)]) + await refetch() + 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. Any gift card + // authorized above is skipped, which is why `working` had to be refreshed. + 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 } : {}), - })) - 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. + })), + 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() + } + + /** + * 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, + }) + } + + const refetch = async (): Promise => { + if (order == null) return + 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. + } + } + + const place = async (): Promise => { + if (order == null || accessToken == null || isLoading) return + setIsLoading(true) + setOrderErrors([]) + try { + await runPlace() } 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 +319,65 @@ 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() + } + + // A 3DS redirect has come back and the gateway has confirmed the payment. + // Nobody clicked anything and nobody can: acceptance of the terms did not + // survive the navigation, and asking for it again would leave a shopper who + // declines with a paid, unplaced order. Acceptance did happen — before the + // redirect, or the button was not clickable — so the order is placed here on + // the library's own initiative. This is the only path where that is true. + const placeRef = useRef(place) + placeRef.current = place + const resumeFailedRef = useRef(async (): Promise => {}) + resumeFailedRef.current = async (): Promise => { + await discardBurntSession() + await refetch() + } + const resumeHandledRef = useRef(false) + useEffect(() => { + if (resumePhase === "resumed" && !resumeHandledRef.current) { + resumeHandledRef.current = true + void placeRef.current() + return + } + if (resumePhase === "failed" && !resumeHandledRef.current) { + resumeHandledRef.current = true + setOrderErrors(resumeErrors) + // Burnt for the same reason as an in-page refusal, so it goes the same + // way. The gift cards are **not** refunded here: they were charged on a + // previous page load and this one has no record of which of them this + // attempt authorized, so giving them back could take money for a payment + // that is still settling. They stay applied and visible on the order — + // the same stance the gift card ADR takes for a timed-out place. + void resumeFailedRef.current() + } + }, [resumePhase, resumeErrors, setOrderErrors]) + + const isResuming = resumePhase === "resuming" || resumePhase === "resumed" + const busy = isLoading || isResuming const disabledButton = - disabled !== undefined ? disabled : !privacyAccepted || !isPaymentInPlace - const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label + disabled !== undefined ? disabled : isResuming ? true : !privacyAccepted || !isPaymentInPlace + const labelButton = busy ? loadingLabel : typeof label === "function" ? label() : label return children ? ( - {children} + + {children} + ) : (