diff --git a/CONTEXT.md b/CONTEXT.md index e83ae717..3b1f2e47 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,17 +1,62 @@ # React Components — Payment -This context covers the React components and state that let a storefront select a payment method and attach a payment source to an order during checkout. +This context covers the React components and state that let a storefront pay for an order during checkout, under either Payments Model. ## Language +**Payments Model**: +Which of the two mutually exclusive payment models an order uses. Two values, named after the order relationship that carries the payment: **`payment_source`** (the older model: `payment_gateways` + `payment_methods` + a per-gateway payment source) and **`payment_sessions`** (the newer model: `payment_settings` + `payment_sessions`). An order is bound to one model for its whole life; it can never switch. A third transient value, `undetermined`, means the order data needed to decide has not loaded yet. +The API version and the Payments Model are two different things: the version says what the API *can* express (`available_payment_settings` exists only from `2026-05`), the order says which model it *uses*. API version `2026-05` is backward compatible and serves both models, so two organizations on the same version can be on different Payments Models, and a single response can carry both `available_payment_methods` and `available_payment_settings`. +_Avoid_: legacy vs new (ages badly), payments version (collides with the API version, e.g. `2026-05`), v1/v2 + **Payment Method**: -A selectable payment option on an order (a configured gateway option, e.g. "Stripe"). Backed by the `payment_method` resource. +A selectable payment option on an order (a configured gateway option, e.g. "Stripe"). Backed by the `payment_method` resource. Belongs to the `payment_source` Payments Model only. _Avoid_: gateway (as a synonym), payment type **Payment Source**: -The concrete payment instrument record attached to a single order (e.g. `stripe_payment`, `adyen_payment`, `wire_transfer`). Created per order from the selected Payment Method. +The concrete payment instrument record attached to a single order (e.g. `stripe_payment`, `adyen_payment`, `wire_transfer`). Created per order from the selected Payment Method. Belongs to the `payment_source` Payments Model only. _Avoid_: payment, card, token +**Payment Setting**: +A configured payment gateway (Stripe, Adyen, Manual, Gift Card, …) at the organization/market level, modelled per-provider (`payment_setting_manuals`, `payment_setting_stripes`, …). The `payment_sessions`-model counterpart of a Payment Method + Payment Gateway pair. An order lists the ones it can use in `available_payment_settings`. +_Avoid_: payment gateway, payment method + +**Payment Session**: +One intended payment against an order, for an `amount_cents`, through a Payment Setting. The `payment_sessions`-model counterpart of a Payment Source. Its lifecycle is Payment Session → Payment Authorization → Payment Capture (→ Payment Refund). An order can carry several sessions (split payment) — unlike a Payment Source, of which there is at most one. `amount_cents` is set once and never updated; omit it on create and the server sizes the session to the order's remaining amount, while an explicit value is silently capped to it. Its `status` is one of seven values (`unpaid`, `authorized`, `voided`, `paid`, `partially_paid`, `refunded`, `partially_refunded`) and only the middle three count as payment taken. +_Avoid_: payment source, charge, payment intent + +**Payment Authorization**: +The record proving a Payment Session's money was actually taken. A session is only a stated *intent* to pay; a session with a `succeeded` Payment Authorization is the one and only evidence of payment on the `payment_sessions` model. Also what makes the order placeable. +_Avoid_: authorized session, payment (as a synonym for the session) + +**Current Payment Session**: +The Payment Session the shopper's selection points at — the one paying whatever the Applied Gift Cards do not. It is the shopper's choice made durable: the order has no `payment_setting` relationship, so the selection is read back as `order.payment_sessions[].payment_setting`. Browser state is a rendering cache of it, never the authority. There is at most one, it is the most recent live non-gift-card session, and **gift card sessions are never it** — those are additive, and several are live at once. Its `amount_cents` covers the Remaining Amount at the moment it was created, not the order total. +_Avoid_: pending session, selected payment method, "the payment session" (an order has several) + +**Placeable**: +Whether the API would accept placing the order. Two distinct things share the word: `order.placeable` and the `_placeable` trigger — but they are **not** two ways to ask the same question, and only one of them is usable. `order.placeable` is transient and served **only in an update response**, never on a `GET`, so it can never gate a button on render. The `_placeable` trigger is a `PATCH` that *validates* the order: 200 with the whole order on success, 422 with a JSON:API `errors` array on failure. Because a failed validation persists nothing, repeating it is cheap. Payment coverage is checked by a default payment rule whose threshold an organization can change — so placeability is a server judgement, never a client calculation. +_Avoid_: "can be placed" (ambiguous between the attribute and the check), validated + +**Reusable Session**: +A Payment Session the library may adopt instead of creating a new one: same Payment Setting, `status` still `unpaid`, not past `expires_at`, and with no Payment Authorization in a terminal failure state. Anything failing that predicate is abandoned in place, not deleted — an `unpaid` session counts toward nothing, and a sales-channel token may be refused the delete anyway. +_Avoid_: pending session, stale session, orphan session (the last one is what an abandoned session *becomes*) + +**Applied Gift Card**: +A gift card the shopper has spent on an order — a Payment Session against a `payment_setting_gift_cards` setting. Additive rather than an alternative: an order carries zero or more of them *plus* at most one other session for the difference. Its `amount_cents` is what it covers **of this order**, capped by the server to whatever was still owed — never the card's balance, which a session does not carry at all. Removable for free until it is authorized; after that only a refund could return the money, and the balance is debited the instant the authorization succeeds. +_Avoid_: gift card discount (it is a payment, not a discount), gift card balance (a different number) + +**Remaining Amount**: +What the shopper still has to pay: the order total, less the Applied Gift Cards at face value, less any other session that has already taken money. Derived on the client, because `order.session_amount_cents` — despite its name meaning the same thing — does not move until a session is authorized, and gift cards are authorized at place time. The figures summed are the ones the server computed per session. +_Avoid_: session amount (the API's name for the same idea, but only after authorization), balance, outstanding total + +**Coverage**: +Whether the Remaining Amount has reached zero, so no other payment method is needed. Requires the order total to be known: an order fetched without `total_amount_with_taxes_cents` looks free, and treating that as covered hides the whole payment step. +_Avoid_: paid (coverage is intent; only a succeeded Payment Authorization means paid), fully funded + +**Undetermined**: +The window in which the order has not yet been loaded with the relationship needed to tell the Payments Model apart, so neither payment tree may be mounted. It is an observable state that every consumer must render something for — not a transient detail that can be ignored. +_Avoid_: loading (the order may well be loaded; what is missing is the include), unknown + **Customer Payment Source**: A Payment Source saved to a Customer for reuse across orders (a stored card). Selected via the order's `_customer_payment_source_id`. _Avoid_: saved card (informal), wallet @@ -22,8 +67,16 @@ _Avoid_: using "gateway" to mean the Payment Method ## Relationships -- An **Order** has at most one **Payment Method** and one **Payment Source** -- A **Payment Source** is created from the selected **Payment Method** +- An **Order** is on exactly one **Payments Model**, permanently +- On the `payment_source` model: an **Order** has at most one **Payment Method** and one **Payment Source**; the Payment Source is created from the selected Payment Method +- On the `payment_sessions` model: an **Order** has zero or more **Payment Sessions**, each created from one **Payment Setting** +- There is no `order.payment_setting`: the selected **Payment Setting** is reachable only through `order.payment_sessions[].payment_setting` +- A **Payment Session** has at most one **Payment Authorization**; a session without one has taken no money, and one whose authorization failed stays `unpaid` and is abandoned rather than deleted +- A **Payment Session** only counts toward the order's paid amount once its **Payment Authorization** has succeeded — and for a manual Payment Setting that happens in a background job, so it is never immediate +- An **Order** on the `payment_sessions` model can still carry `available_payment_methods`: API version `2026-05` is additive, and the new model simply takes precedence +- On the `payment_sessions` model a gift card is a **Payment Session**, so it never changes `order.total_amount_with_taxes_cents` — unlike the older model, where it was a negative line item and the total already came back net +- 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 ## Example dialogue @@ -31,6 +84,34 @@ _Avoid_: using "gateway" to mean the Payment Method > **Dev:** "When the shopper picks Stripe, do we create the **Payment Source** in the `StripePayment` component?" > **Domain expert:** "No — Stripe has no dedicated creation component. The `StripeGateway`/`PaymentGateway` effect creates the **Payment Source** once the **Payment Method** is selected. Adyen and Braintree, by contrast, create it inside their own components." +> **Dev:** "So on the `payment_sessions` model, `` picks the manual component instead?" +> **Domain expert:** "No. `` only exists on the `payment_source` model — it switches on `payment_source_type`, which the newer model has no equivalent of. On the `payment_sessions` model there are no Payment Methods to iterate, so nothing ever mounts it. The two models are two separate component trees, chosen once per order." + +> **Dev:** "The shopper picked bank transfer and the button says the payment doesn't cover the order. Do I show that?" +> **Domain expert:** "Not yet. Authorizing a manual payment runs in a background job, so the first `_placeable` fails while the money is still being taken. Retry a few times first — that error is only true if it survives the last attempt." + +> **Dev:** "Their previous attempt failed. Do I delete that Payment Session before making a new one?" +> **Domain expert:** "No. It stayed `unpaid`, so it counts toward nothing — leave it. And a sales-channel token may not be allowed to delete it anyway, once a failed authorization is hanging off it." + +> **Dev:** "Where does the library keep which Payment Setting the shopper picked?" +> **Domain expert:** "On the order. The Payment Session is created when they pick, so the choice is `order.payment_sessions[].payment_setting` — it survives a reload, and on a mismatch the order wins over local state. What browser state adds is only the rendering of it." + ## Flagged ambiguities +- ~~Gift cards on the `payment_sessions` model are undecided and out of scope.~~ **Resolved.** A gift card is spent by creating a Payment Session against a `payment_setting_gift_cards` setting — it is a payment method, not an order-level code. On the `payment_sessions` model `GiftCardOrCouponForm` therefore works on the coupon only, overriding an explicit `codeType="gift_card_code"`. Note the trigger named in the original entry never existed: there is no `_gift_card_or_coupon_code`, only the plain order attribute `gift_card_or_coupon_code`, and the components write `gift_card_code` / `coupon_code` directly. See `docs/adr/2026-08-20-gift-cards-as-payment-sessions.md` for the full lifecycle. +- **Three official sources are wrong about this domain**, so verify against `core-api` rather than the SDK types or the docs: `available_payment_methods` is annotated `@deprecated Last available in API version 2017-08` but is still served on `2026-05`; `payment_type` is documented as `"manual_payment"` but the real values are uppercase (`MANUAL`, `GIFT_CARD`, …); and both `status` fields are typed as bare `string` with a single `@example`, hiding state machines of seven and eight values. +- "the session's type" was used to mean the gateway (e.g. "manual") — but `payment_session.type` is always the literal `"payment_sessions"` (the resource type). The gateway is `payment_session.payment_setting.type` (e.g. `payment_setting_manuals`). When someone says "the session type", ask which one they mean. +- "the payment is done" was used for both a created **Payment Session** and a taken payment — resolved: only a `succeeded` **Payment Authorization** means paid; a session on its own means nothing was taken. +- "placeable" was used for both the readable order attribute and the `_placeable` validation trigger — resolved in the glossary above; when someone says "check if it's placeable", ask whether they mean reading the attribute or asking the API. - "set payment source" was used to mean both the async operation that creates/attaches a Payment Source *and* the reducer action that stores it in state — resolved: the operation is `setPaymentSource(...)`, the reducer action is `dispatch({ type: "setPaymentSource" })`. + +## 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?" +> **Domain expert:** "Because nothing has been authorized yet. `session_amount_cents` only drops once a session has taken money, and gift cards are authorized at place time so they can still be removed. The **Remaining Amount** you show is derived on the client." + +> **Dev:** "So for the second gift card I just leave `amount_cents` out again?" +> **Domain expert:** "No — that is the one exception. The server would size it against a remainder that has not moved, so it would ask for the whole order. Send the real remainder; the server clamps it down if it disagrees, never up." + +> **Dev:** "The shopper wants to take a gift card off, but the order was already placed. Do I delete the session?" +> **Domain expert:** "You cannot. Authorizing a gift card debits the balance straight away, and the API refuses to delete a session with transactions on it — it comes back as a 500. Only a refund would return that money, and we do not implement one, so the remove control is not rendered at all." diff --git a/packages/react-components/docs/adr/0001-payment-source-effect-invariants.md b/docs/adr/0001-payment-source-effect-invariants.md similarity index 100% rename from packages/react-components/docs/adr/0001-payment-source-effect-invariants.md rename to docs/adr/0001-payment-source-effect-invariants.md diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md new file mode 100644 index 00000000..1438fb60 --- /dev/null +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -0,0 +1,290 @@ +# Payment Session lifecycle on the `payment_sessions` model + +## Context + +On the `payment_sessions` model the shopper's choice of how to pay is not an order +attribute — there is no `order.payment_setting`. The choice only exists as a +**Payment Session** created against a **Payment Setting**, read back through +`order.payment_sessions[].payment_setting`. + +This ADR covers how the library creates, reuses and reads those sessions, and where the +**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. + +### What the API actually does + +Verified in `core-api`, because none of it is documented and parts of the SDK types and +public docs are wrong (see "Sources that are wrong", below). + +**`amount_cents` is optional on create, and an explicit value is silently capped.** +`PaymentSessionCreate` requires only `payment_setting`. Omitting `amount_cents` makes the +server default it to the order's *remaining* amount, not the total: + +```ruby +# app/models/payment_session.rb:181-189 +def set_amount_cents + self.amount_cents ||= default_amount_cents +end + +def cap_amount_cents + max = default_amount_cents + return unless max + self.amount_cents = [amount_cents, max].min if amount_cents +end +``` + +`default_amount_cents` resolves to `Order#session_amount_cents`, which — despite the name +— is the **remaining** amount: `total_amount_with_taxes_cents - sum(payment-taken sessions)` +(`app/models/concerns/order_amounts.rb:24-28`). The capping is silent, so sending an amount +is not merely redundant, it can produce a session that quietly differs from what was asked. + +**`amount_cents` cannot be changed afterwards.** It is absent from `PaymentSessionUpdate` +("not updatable once the session is created"). Changing an amount means a new session. + +**Session status is a 7-state AASM machine with no per-state timestamps.** + +```ruby +# app/models/payment_session.rb:21-28 — initial: :unpaid +unpaid | authorized | voided | paid | partially_paid | refunded | partially_refunded +# :15 +PAYMENT_TAKEN_STATES = %w(authorized paid partially_paid).freeze +``` + +The `payment_sessions` table has only `expires_at`, `created_at`, `updated_at` — there is +no `authorized_at`/`paid_at`. Transaction resources are the opposite: they *do* carry one +timestamp per state. + +**Transactions share one state machine across all four types.** `PaymentAuthorization`, +`PaymentCapture`, `PaymentVoid` and `PaymentRefund` are STI subclasses of +`PaymentTransaction` and none defines its own states: + +```ruby +# app/models/payment_transaction.rb:22-31 — initial: :pending +pending | requires_action | processing | succeeded | declined | failed | canceled | expired +``` + +**A session only counts once its authorization has succeeded.** The session transitions to +`authorized` from `PaymentAuthorization#change_session_status!`, which runs on +`after_commit ..., if: :succeeded?` (`payment_transaction.rb:82`). A `pending`, +`processing` or `failed` authorization leaves the session `unpaid`, contributing nothing. + +**Authorizing a manual payment is asynchronous.** There is no manual-specific gateway class +(`PaymentSettingManual` is an empty subclass), so it falls through to +`Payment::Session::Base#authorize!`, which merely calls `transaction.succeed!` — but it +runs in a Sidekiq job (`Workers::PaymentTransaction`, queue `payments`) dispatched from +`after_commit :handle_session, on: :create`. The work is trivial; the hop is real. + +## Decision + +### Creation: eager, on selection, with reuse + +Selecting a Payment Setting creates the Payment Session immediately. The selection *is* the +session — that is what makes it survive a reload. + +Create with **`payment_setting` and `order` only**. Never send `amount_cents`; let the +server size the session against the remaining amount. + +> **Superseded in one place.** Gift cards after the first *do* send an explicit +> `amount_cents`, because the server's remainder does not move until a session is +> authorized and gift cards are authorized at place time — so it would size every card for +> the whole order. See `2026-08-20-gift-cards-as-payment-sessions.md`. The rule above still +> holds for the session paying the difference, where the server must work out what is left. + +Before creating, **reuse** an existing session when all of these hold: + +- its `payment_setting.id` matches the selected setting, **and** +- `status === "unpaid"`, **and** +- `expires_at` is absent or in the future, **and** +- it has no `payment_authorization` in a terminal failure state + (`failed`, `declined`, `canceled`, `expired`). + +### Reading: the order is the only source of truth + +There is **no local selection state**. The current selection is derived from the order on +every render by searching `order.payment_sessions` with the predicate above. Local state is +limited to a per-setting "operation in progress" indicator, which is not a selection. + +The session is always **searched for**, never read positionally. `payment_sessions[0]` is +wrong today for orders carrying a gift-card session and will be wrong for everyone once +split payment is supported. + +**The selection is single per order, and it is the most recent live session.** This +followed from the decision not to delete: switching setting leaves the previous session on +the order, so a per-setting reading of "is this selected?" would light up every setting the +shopper has ever tried at once — a radio group with several selections. Taking the newest +keeps the group coherent without deleting anything a token may be refused. + +Consequence for the reuse rule above: with only one setting implemented, the *adopt* branch +is currently unreachable through the UI, because a reusable session already reads as +selected and the radio ignores a click on the current selection. What is reachable, and +covered by tests, is the retry path — a burnt session does not count as the selection, so +clicking again creates a fresh one. The adopt branch is kept because it becomes live as +soon as a second setting exists. + +### Sessions that took no money are deleted; everything else is abandoned + +> **Reformulated** by `2026-08-20-gift-cards-as-payment-sessions.md`. The constraint behind +> the original "never delete" was that a burnt session might not be deletable — not that +> deletion is always wrong. Removing a gift card, and invalidating a session whose amount is +> no longer correct, both require deleting; both only ever touch sessions that have taken +> nothing. + +### Failed sessions are abandoned, not deleted + +A session whose authorization failed stays `unpaid`, so it is outside +`PAYMENT_TAKEN_STATES` and contributes nothing to any total. The library leaves it on the +order and creates a fresh one. + +Deleting is not merely unnecessary, it may be impossible: a sales-channel or customer token +can be refused the delete when a failed authorization hangs off the session. + +### The authorization is created at place time, not at selection + +Creating it on selection — as the `examples-new-payments` playground does — means choosing +a radio button takes the shopper's money, and changing their mind requires cascade-deleting +an accounting record. Deferring it to the place-order click keeps selection free and +reversible. See the place-order ADR for the full sequence. + +### Setting types are filtered internally, with no public flag + +`` iterates `available_payment_settings` and renders nothing at all for +types the library does not yet implement. There is no `isImplemented` prop: a public flag +would only push the same decision onto every consumer, and it would need deprecating once +the family is complete. + +The two failure modes are not symmetric. Rendering a radio button for an unimplemented +setting produces a control that does nothing when clicked — worse than omitting it. + +## Considered options + +- **Lazy creation** (radio is local state; the session is created by an explicit action). + Rejected: it contradicts the definition of the Current Payment Session and loses the + selection on reload. +- **Eager creation without reuse.** Rejected: because `amount_cents` is immutable, any + remount or refetch that re-triggers the effect creates another session. This repo has + already shipped that bug class twice — commit `242e64a3` and + `0001-coalesce-payment-source-requests.md`. +- **Checking that the reused session's amount covers the order.** Rejected: since the + server sizes the session, verifying the amount client-side means reimplementing the + server's calculation — the same trap as deriving placeability locally. +- **Reading state from timestamps instead of `status`.** Attractive for transactions, but + impossible for sessions, which have no state timestamps at all. Rejected for uniformity. +- **Reusing a session by creating a second authorization on it.** Rejected: + `payment_authorization` is a singular relationship and there is no evidence the API + accepts a replacement. +- **Deleting burnt sessions.** Rejected on both token permissions and necessity. +- **Skipping unimplemented settings but exposing `isImplemented` to the consumer.** + Rejected — see above. + +## Consequences + +Status unions are **hand-written from the AASM machines** and are not in the SDK, which +types both as bare `string`. They must be written as +`"unpaid" | ... | (string & {})` so that unknown values stay assignable, and every branch +that decides on a status needs an explicit `default`. **These unions must be re-checked +against `core-api` whenever the SDK is upgraded.** + +Repeated failed attempts accumulate orphan `unpaid` sessions on the order. They are inert, +but they are the reason every read must search rather than index. + +Reading the selection back requires `payment_sessions.payment_authorization` in the order +`include`, not just `payment_sessions.payment_setting` — without it, a reusable session +cannot be told apart from a burnt one. + +Because selection now round-trips to the API, the radio does not light up on click. A +per-setting pending indicator is required, and it is *not* the selection. + +An organization on the new model with only unimplemented settings configured gets a +checkout with **no payment options and no explanation**. A development-only `console.warn` +fires whenever `` skips a setting; it is not public API and should be +removed once the table below is complete. + +`` must stay mounted even when the order turns out to be on the older +model. It registers the payment-session includes, and that has to happen *before* the order +is fetched: adding an include afterwards does not trigger a refetch, so a component mounted +only once the model is known would never receive its data. Consumers that mount it +conditionally will see an order whose `payment_sessions` never expand. + +### Known debt, due with the second setting + +~~`` holds one `errors` state for the whole list.~~ **Closed** by +`2026-08-20-gift-cards-as-payment-sessions.md`: gift card errors live on their own context, +because gift cards and the method turned out to be two disjoint sets with two separate UIs +rather than two entries in one list. No `` was needed. + +**Auto-selecting a single setting is deliberately absent.** `` offers +`autoSelectSinglePaymentMethod` and the symmetric prop belongs here, but the obvious +condition is a trap: `` renders only the *implemented* settings, so "one +entry rendered" is not "one option offered". An organization with five settings configured +and one implemented would have its shoppers silently committed to bank transfer while the +card options it pays for stay invisible — and that is the situation for every organization +until the table below is complete. + +The condition has to be `available_payment_settings.length === 1` on the raw array, plus +the setting being implemented and no live session already on the order. Deferred rather +than written blind: under the correct condition it cannot fire on any organization that +still has unimplemented settings, so it would ship untested against the real API. Pick it +up with the second setting, when the rendered list and the real one start to converge. + +Note that selecting is not a neutral gesture on either model. Creating a Payment Session +makes `new_payments_engaged?` true and the API then stops serving +`available_payment_methods` — observed: an order went from one available method to zero the +moment its first session existed. The mirror of what writing `payment_method` does to +`available_payment_settings`. Whichever tree acts first commits the order for good, which +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` | + +### Gift cards + +On this model a gift card is spent by creating a Payment Session against a +`payment_setting_gift_cards` setting. It is therefore a payment, not an order-level code — +the full lifecycle is in `2026-08-20-gift-cards-as-payment-sessions.md`. + +`GiftCardOrCouponForm` pins its code type to +`coupon_code` whenever the model is `payment_sessions` — **overriding an explicit +`codeType="gift_card_code"` prop**. Writing `gift_card_code` on the order is meaningless +here, and allowing it through would silently apply a gift card that no session reflects. +`GiftCardOrCouponCode` and `GiftCardOrCouponRemoveButton` follow the same rule; their +`managePaymentProviderGiftCards` branch is Adyen-specific and unreachable on this model. + +The coupon and the gift card end up in different places on this model, and deliberately: +the coupon is a discount and belongs with the order summary, while the gift card is a +payment and belongs in the payment step. Putting a control that creates a payment session +next to the discounts would have the total drop as if it were a coupon, while the card is +in fact charged at place time. + +This supersedes the "gift cards are undecided and out of scope" ambiguity previously +flagged in `CONTEXT.md`. + +## Sources that are wrong + +Three official sources were found to contradict `core-api` during the design of this work. +**Verify payment semantics against `core-api`, not against the SDK types or the docs.** + +1. `available_payment_methods` / `payment_method` are annotated + `@deprecated Last available in API version 2017-08` in the SDK types. They are still + served on `2026-05`. +2. `payment_type` is documented with the example `"manual_payment"` + (`config/attributes/payment_transaction.yml:36`). The values the code produces are + uppercase — `MANUAL`, `GIFT_CARD`, `STRIPE`, … — derived from the setting's class name + (`app/models/payment_setting.rb:36-38`). +3. `PaymentSession.status` and `PaymentTransaction.status` are typed as bare `string` in + the SDK with a single `@example` each. The real value sets are the AASM machines quoted + above. + +A fourth, for honesty: during this design `ensure_pending` was assumed from its name to +require a pending order. It does the opposite — it *promotes* a draft order and returns +`true` for every other status (`app/models/order.rb:966-969`). diff --git a/docs/adr/2026-08-18-payments-model-detection.md b/docs/adr/2026-08-18-payments-model-detection.md new file mode 100644 index 00000000..c7f1338d --- /dev/null +++ b/docs/adr/2026-08-18-payments-model-detection.md @@ -0,0 +1,97 @@ +# Detect the Payments Model from the order + +> Naming note: from this ADR onwards, files in this directory use a **date prefix** +> instead of a sequence number. Sequential numbers collide silently across parallel +> feature branches — two branches both pick the next number, both merge cleanly, and +> the directory ends up with duplicates. That already happened here: the two `0001-` +> files predate this convention and are kept under their historical names. + +## Context + +Commerce Layer has two mutually exclusive payment models, and an order is bound to one +for its whole life (see `CONTEXT.md` for the vocabulary): + +- **`payment_source`** — `payment_gateways` + `payment_methods`, surfaced on the order as + `available_payment_methods`, with at most one `payment_source` per order. +- **`payment_sessions`** — `payment_settings` + `payment_sessions`, surfaced on the order + as `available_payment_settings`, with zero or more sessions per order. + +API version `2026-05` is **purely additive**: it adds the new resources and relationships +without removing anything, so a single order response can legitimately carry **both** +`available_payment_methods` and `available_payment_settings`. The library must therefore +decide which model to drive from the order payload itself, not from configuration. + +The library pins the API version for every consumer — `getSdk` passes +`apiVersion: API_VERSION` unconditionally (`packages/core-components/src/sdk/index.ts:58`). +No consumer can reach the API on `2017-08` through these components, so the detection only +ever has to distinguish the two payloads, never two versions. + +### Load-bearing fact: the SDK's deprecation markers are wrong + +`@commercelayer/sdk` v8 annotates the old-model relationships as gone: + +```ts +// api-L7ji9S8h.d.ts:19497 +available_payment_methods?: PaymentMethod[] | null; // @deprecated Last available in API version 2017-08. +// api-L7ji9S8h.d.ts:19507 +payment_method?: PaymentMethod | null; // @deprecated Last available in API version 2017-08. +``` + +**This is inaccurate.** On `2026-05` those relationships are still served. Verified against +the running API. Anyone reading that JSDoc will reach the opposite conclusion and delete +the precedence rule below as dead code — it is not. + +## Decision + +Expose a pure, public hook `usePaymentsModel()` that derives the model from `OrderContext`: + +| Condition | Result | +| --- | --- | +| `available_payment_settings` non-empty | `"payment_sessions"` | +| else `available_payment_methods` non-empty | `"payment_source"` | +| else (including "order not loaded yet") | `"undetermined"` | + +**The precedence lives in the library, never in the consuming application.** When both +arrays are present the new model wins and the old flow is excluded entirely. + +`` registers `available_payment_settings` in the order `include` for **every** +consumer, unconditionally. Without it, an absent array is indistinguishable from an array +that was never requested, and the derivation cannot be trusted. The two nested +session relationships are *not* global — `` registers +`payment_sessions.payment_setting` and `payment_sessions.payment_authorization` when it +mounts, because only the payment UI needs them and they are the expensive part. + +Old and new components **self-silence** by consulting this hook, so both trees can be +mounted side by side without a coordinator. No `PaymentsModelStrategy` component ships in +this iteration. + +## Considered options + +- **A `` wrapper that mounts one branch.** Rejected *for now*, not + on merit: with self-silencing components it saves the consumer a two-line conditional and + nothing else. A public component is forever; a hook plus an inline `switch` is deletable. + Revisit if mfe-checkout finds the manual switch tedious. +- **Have the application blank out `available_payment_methods` after fetching.** Rejected. + It moves a domain rule into every consumer, and every consumer other than mfe-checkout + will get it wrong. It also requires mutating an API response to make components behave. +- **Store the model in the order reducer at fetch time.** Rejected: duplicated state that + can drift from the order it was derived from. A pure derivation cannot drift. +- **Register the nested session includes globally too.** Rejected: two levels of nesting on + a collection, paid by every cart and product page that never mentions payment. + +## Consequences + +`"undetermined"` is a **real, observable state**, not a transient implementation detail. +Every consumer of the hook — the place-order router, the payment setting list, the +gift-card form — must render something sensible during that window. It lasts until the +order has been fetched with the include resolved. + +Every application now pays one extra relationship (`available_payment_settings`) on every +order fetch, including carts that will never show a payment method. This is the price of +making the derivation trustworthy, and it was chosen deliberately over the alternative of +requiring each consumer to opt in. + +Because the deprecation markers cannot be trusted, **verify payment semantics against +`core-api`, not against the SDK types or the public docs.** This rule is not specific to +this ADR — see the closing section of `2026-08-18-payment-session-lifecycle.md` for the +full list of documents found to be wrong. diff --git a/docs/adr/2026-08-18-place-order-split-by-payments-model.md b/docs/adr/2026-08-18-place-order-split-by-payments-model.md new file mode 100644 index 00000000..08e6389e --- /dev/null +++ b/docs/adr/2026-08-18-place-order-split-by-payments-model.md @@ -0,0 +1,201 @@ +# Split PlaceOrderButton into two branches behind a router + +## Context + +`PlaceOrderButton` cannot be adapted to the `payment_sessions` model by adding a guard. +Its enablement machine is built entirely on concepts that model does not have. + +The upstream permission check hard-blocks every non-free order: + +```ts +// PlaceOrderReducer.ts:107-149 +total_amount_with_taxes_cents !== 0 && isEmpty(payment_method?.id) // → not permitted +``` + +`order.payment_method` does not exist on the new model, so **every non-free order would be +permanently disabled**. And the block is not a single `if`: the same reducer publishes +`paymentType = payment_method.payment_source_type`, and the button's enablement effect +(`PlaceOrderButton.tsx:103-161`) cross-references it with `currentPaymentMethodType`, +`order.payment_source.payment_response.status`, `getCardDetails(...).brand` and +`currentPaymentMethodRef.current.onsubmit` — four concepts with no counterpart. + +Below that sit five gateway-specific auto-place effects for redirect returns (PayPal payer +id, Stripe payment-intent polling, three Adyen branches guarded on `merchantReference`, +Checkout.com session id, plus an Adyen gift-card callback on an already-placed order), and +a three-branch validation tree inside `handleClick` with a `partially_authorized` override. +Of 598 lines, what carries over is the button ref, the render-prop and +`setPlaceOrderStatus`. + +The constraint from the consuming side is that **the component hierarchy a consumer mounts +must not change**. An application on v5 must upgrade without editing its component tree. + +### What the API actually does + +Verified in `core-api`. + +**`_placeable` is a validation, not a read.** The SDK sends it as +`PATCH /orders/:id` with `attributes: { _placeable: true }`, wired as +`validate :placeable?, if: -> { truthy?(@_placeable) }` (`app/models/order.rb:303`). +On success it returns **200** with the whole order and the transient attribute +`placeable: true`; on failure **422** with a JSON:API `errors` array. Because a failed +validation persists nothing, repeating the call is cheap and side-effect free. + +**`placeable` is never served on a GET.** It is an `attr_accessor` +(`app/models/order.rb:371-378`) exposed as `"transient": true, "actions": ["update"]`, so +it appears only in an update response. It cannot be polled by refetching the order, and it +cannot gate a button on render. + +**What can gate the button on render is whether anything is paying for the order** — +`derivePaymentSessionsState` answers that from the order alone. Amended 2026-09-01: the +button originally had the privacy checkbox as its only gate, on the reasoning above, which +conflated two questions. Removing the gift card that was covering the remainder also +deletes the session paying the difference, leaving a live button whose only outcome was a +placeability failure. The gate is now `isCovered || total === 0 || currentPaymentSession`, +the same derivation the payment components use, so the button cannot disagree with the +selector above it. Placeability itself is still only knowable after the click. + +**Coverage is enforced by a default payment rule, not by a hard-coded guard.** On the new +model `validate_payments` is a no-op — both `validate_payment_method` and +`validate_payment_source` return `true` when `new_payments?` +(`app/models/concerns/order_payments.rb:426-447`) — and no guard in `PLACE_GUARDS` reads +`session_amount_cents`. What does enforce payment is `validate_payment_rules` plus a rule +every market auto-creates: + +```ruby +# app/models/market.rb:16, 31, 155-159 +DEFAULT_PAYMENT_RULE = { template_id: "08ddf06d-...", template_settings: { "value" => 100.0 } }.freeze +after_save :create_default_payment_rule! +``` + +The threshold is a **rule parameter**, so an organization can lower it to accept part +payments. + +**`ensure_pending` does not block an already-placed order.** It promotes a draft and +returns `true` otherwise (`app/models/order.rb:966-969`), so `_placeable` on a placed order +returns 200 rather than an error. This matters because `auto_place` on a Payment Setting +places the order inside the authorization job (`app/models/payment_session.rb:32-35`), +before the client can observe it. + +## Decision + +`PlaceOrderButton` becomes a **pure router** over two implementations: + +- `PlaceOrderButtonPaymentSource` — the current file, **moved unchanged**. Not a refactor: + the 598 lines keep their git blame and their existing specs. +- `PlaceOrderButtonPaymentSessions` — new. + +The router selects on `usePaymentsModel()`. The public props are unchanged; `options` +(`paypalPayerId`, `stripe`, `adyen`, `checkoutCom`) is forwarded **only** to the +`payment_source` branch. + +While the model is `"undetermined"` the router renders a **neutral disabled button** — not +the old branch, and not `null`. Defaulting to the old branch would mount five redirect +effects that read `payment_source.payment_response` on an order that has none; rendering +`null` makes the button appear late, which is a visible change even though the mount +hierarchy is identical. + +`PlaceOrderContext` stays exclusive to the `payment_source` model. The new branch has no +children to serve, and `PrivacyAndTermsCheckbox` already communicates through the +`PLACE_ORDER_RECHECK_EVENT` DOM event rather than context. A consumer that still mounts the +deprecated `` above a new-model order is harmless: the router ignores it. + +The privacy-and-terms gate applies to **both** branches. It is a legal requirement of the +checkout, not a property of the payment model. + +### The new branch's sequence + +1. Create the **Payment Authorization** against the current Payment Session. +2. Poll `_placeable`: **5 attempts, 1 second apart**, by default. + - **200** → read `order.status` from the returned order. + `"placed"` → the order was auto-placed; skip step 3 and report success. + Otherwise → step 3. + - **422** → wait and retry. Do **not** surface the errors yet. +3. `_place`. +4. If the attempts are exhausted, surface the errors from the **last** 422. + +Retrying before reporting is the point of the loop, not an optimization. Authorization is +asynchronous, so the first `_placeable` legitimately fails with "the payment doesn't cover +the required percentage" while the job is still in flight. Reporting that immediately would +tell the shopper their payment failed a second before it succeeded. + +**No client-side coverage gate.** Since the threshold is a configurable rule, a local check +such as `session_amount_cents === 0` would block an order that an organization has +deliberately made placeable with a part payment. Only the server knows the threshold. + +Each 422 error maps to one `BaseError`: `field` from the last segment of +`source.pointer` (`/data/attributes/`, or `base` for `/data`) and `code` from +`meta.error`. One error per reason, so the consumer can address them individually rather +than parsing a concatenated message. Populating `field` also keeps the existing +non-blocking-error filter (`PlaceOrderButton.tsx:162-174`, which exempts coupon and +gift-card fields) working unchanged. + +Attempts and interval are a **parameter of the domain function** in `core-components` and a +prop on `PlaceOrderButtonPaymentSessions`, which forwards it. + +## Considered options + +- **Route inside the existing component.** Rejected: two disjoint state machines in one + file, with the first one untouchable by constraint. +- **Generalise `PlaceOrderContext` to cover both models.** Rejected: it couples the two + machines at the exact seam we are separating. +- **Enable the button from `order.placeable`.** Impossible — it is not served on a GET — + and wrong even if it were: it does not become true until the asynchronous authorization + has succeeded, so it would disable the button precisely while payment is in progress. +- **Derive placeability client-side from session amounts.** Rejected: reimplements a + server rule that organizations can extend, and breaks any organization using a threshold + below 100%. +- **Poll `order.session_amount_cents` by refetching, then call `_placeable` once.** This + was the earlier decision, taken while `_placeable`'s contract was unknown. Superseded: + `_placeable` returns the actual reasons and, on failure, persists nothing, so a second + mechanism buys nothing. +- **Call `_place` directly and use its errors.** Tempting, because `_place` short-circuits + on an already-placed order (`(placed? || placeable?)`, `app/models/order.rb:900-908`). + Rejected: it erases the distinction between checking and acting, so every silent retry + would be a real attempt to place. + +## Consequences + +The router must render a button itself for the `undetermined` window, so `label` and the +render-prop are handled at that level too, not only inside the branches. + +**A 200 from `_placeable` is not proof of payment.** Three routes bypass the coverage rule, +none of them defensible from the client: + +1. The default rule is an ordinary row with no delete protection — it can be removed, + disabled, expired, or its threshold lowered. +2. It is created by an `after_save` on Market with **no backfill migration**, so markets + never re-saved since the feature shipped do not have it. +3. `validate_payment_rules` returns early when + `!new_payments_engaged? && (old_payments? || old_payments_engaged?)`. A `new_payments?` + order with zero sessions but a leftover `payment_method_id` skips rule evaluation + entirely, and since every other guard is a no-op on this model, **it places uncovered**. + +All payment-rule failures arrive with the same `field: payment_action` +(`app/models/business_rule/payment_action.rb:5`), distinguishable only by message text. A +consumer cannot programmatically tell "not covered" from "payment setting not allowed" and +can only display the message. + +The authorization worker is configured `retry: 0` (`app/lib/workers/base.rb:16`), so a +failed job never retries and the authorization stays `pending` **forever**. Exhausting the +attempts is therefore a reachable steady state, not just a slow path. It is reported as a +recoverable condition inviting another attempt — never as a payment failure, and never by +deleting anything, since the payment may in fact have succeeded. + +### Open: what a timeout should actually show the shopper + +Reporting the last refusal is a placeholder, not a considered answer. It tells someone whose +payment is still settling that something went wrong. + +The direction to explore, in a grilling session of its own: always land on a thank-you page +carrying a payment-status summary and a refresh control, and — when a payment did fail — +re-offer the payment components there so the shopper can settle the remainder and the order +finally reads as paid. That reframes the timeout as "we are still checking" rather than an +error, which is what it actually is. + +Not designed here because it reaches past this component into checkout navigation and into +what happens after placement, and deserves its own set of questions. + +Until then: on timeout, nothing is rolled back. A gift card authorized during a timed-out +attempt stays authorized and bound to the order, and its remove control disappears — +the money has been taken and only a refund could return it, which this iteration does not +implement. 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 new file mode 100644 index 00000000..406556f0 --- /dev/null +++ b/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md @@ -0,0 +1,247 @@ +# Gift cards as Payment Sessions + +## Context + +On the `payment_sessions` model a gift card is a **payment**, not a discount. It is spent by +creating a Payment Session against a `payment_setting_gift_cards` setting, and the order +total never changes. + +That is the reverse of the older model, where a gift card became a negative line item and +`total_amount_with_taxes_cents` came back already net. The mechanism is switched off +entirely for new payments — `order.gift_card_applicator` returns a null object when +`new_payments?` (`app/models/order.rb:984-987`), and `GiftCardApplicator#call` self-guards +too — so `gift_card_amount_cents` stays at zero. The old `order.gift_card_code` attribute is +additionally hard-blocked once any session exists, raising a 400 `UNSUPPORTED` +(`order_payments.rb:195-198`, `:569-572`). + +This is the **only** split payment this iteration supports: zero or more gift cards, plus at +most one other session for the difference. Anything wider is out of scope. + +### What the API actually does + +Verified in `core-api`. + +**A gift card session is sized `min(remaining, balance)`** — +`app/models/payment/session/gift_card.rb:12-16`: + +```ruby +def default_amount_cents + total = session.session_amount_cents + return total unless gift_card + total > balance_cents ? balance_cents : total +end +``` + +**…but `session_amount_cents` only drops once a session is authorized.** It is +`total_amount_with_taxes_cents - sum(payment_taken sessions)` +(`order_amounts.rb:24-28`), and `payment_taken` is `authorized`/`paid`/`partially_paid`. +Merely applying a gift card moves nothing on the order. + +**Authorizing a gift card charges it immediately and irreversibly-ish.** +`auto_capture?` is hard-coded `true` for the gift card client, so the authorization is +followed straight away by a succeeded capture and the session lands on `paid` +(`payment/session/base.rb:72-92`, `payment_capture.rb:20-22`). `authorize!` calls +`gift_card.use!`, which debits the balance. A **void always fails** by construction — +`void!` does `auto_capture? ? transaction.fail! : transaction.succeed!` — so the only way +back is a `PaymentRefund`, which calls `gift_card.restore!`. + +**Deleting a session is allowed, until it has transactions.** A sales-channel token may +`destroy` a `PaymentSession` while the order is `draft`/`pending`/`editing` +(`sales_channel_ability.rb:20-22`), but has no `destroy` on authorizations, captures or +refunds. And `PaymentSession` declares its transactions +`dependent: :restrict_with_exception` (`payment_session.rb:76-82`), so deleting a charged +session raises `ActiveRecord::DeleteRestrictionError` — which is **not** in the exception +handler and surfaces as an unhandled **500**. Deleting an unauthorized session touches no +balance: nothing was taken. + +**Several gift cards are allowed, with no maximum.** The only constraint is that the same +code cannot be applied twice while the first session is still `unpaid` +(`payment_session.rb:212-221`). + +**Every bad-code reason collapses into one message.** No such code, expired, zero balance, +or bound to another market all fail the same lookup and produce +`gift_card_code: doesn't match any active gift card` (422, `source.pointer` ending +`/gift_card_code`). A duplicate gives `has already been taken`. There is no way to tell the +shopper *which* it was. + +**Once coverage is complete the API gives no clean signal.** Creating another session +defaults its amount to zero and fails `numericality: { greater_than: 0 }` — a 422 about +`amount_cents` that means nothing to a shopper. + +## Decision + +### Two disjoint sets, not one list + +Gift cards are a **list**; the difference is paid by **at most one** other session. +`findCurrentPaymentSession` excludes gift card types entirely, so it keeps governing the +radio group, and `derivePaymentSessionsState` returns the gift cards separately. + +A gift card is not one of the alternatives the shopper picks between — it is applied on top, +and several are live at once. Putting them in the radio group would mean more than one +selection in a group with room for exactly one. + +`` therefore sits **outside** ``, which skips gift +card settings silently: they are implemented, just elsewhere. + +### The remainder is derived, not read + +`order.session_amount_cents` cannot be used: it stays at the full total for as long as the +shopper is choosing, because gift cards are authorized at place time. So +`derivePaymentSessionsState` computes + +``` +remaining = total − Σ(live gift card amounts) − Σ(authorized method amounts) +``` + +Gift cards count as soon as applied; a method session counts only once it has taken money, +because an unauthorized one is an intent, not a payment. + +The amounts summed are the ones the **server** computed per session, so this is arithmetic +over server values rather than a reimplementation of its rules. + +`isCovered` additionally requires `total > 0`. Without that guard an order fetched without +`total_amount_with_taxes_cents` in its `fields` reads as free, and coverage would hide the +entire payment step — which is exactly what happened before a spec caught it. + +### An explicit `amount_cents` from the second card onwards + +This is the one place the "never send `amount_cents`" rule is broken, and it has to be. + +On a $71 order, a $50 card followed by a $100 card produces sessions of $50 and **$71** — +the server's remainder has not moved, so the second card is sized for the whole order. +$121 of credit for a $71 order, and nothing server-side prevents it. + +Sending an amount is safe because `cap_amount_cents` clamps it **down** to what the server +would have allowed and never up, so the server stays the authority on the maximum. And the +number sent is a sum of amounts the server itself computed. + +The rule still holds for the session paying the difference, where the server must be the one +to work out what is left. + +**The `examples-new-payments` playground has this bug.** It applies every card without an +amount (`payment-section.tsx:1082-1106`) and never gates its input on the remainder +(`gift-card-section.tsx:66-79`). It is not a precedent to copy. + +### Applying or removing invalidates the session paying the difference + +`amount_cents` is set once and never updatable, so a session created against a different +remainder is not stale but *wrong*: it would still read as the shopper's selection, and at +place time we would authorize more than is owed. + +Both operations therefore delete it, as part of the same domain operation — binding the +invalidation to the action that causes it, rather than to an effect somewhere comparing +amounts. Effects that delete resources in response to an amount comparison are the class of +code that has produced render loops and duplicate creations in this repo three times. + +The cleanup swallows its own failures: it runs alongside an action the shopper asked for and +can see the result of, and turning a cleanup failure into a visible error would report the +wrong thing. + +### Only what took nothing is deleted + +This reformulates the earlier "never delete" rule without contradicting it. The constraint +behind that rule was that a burnt session might not be deletable — not that deletion is +always wrong. + +**A session is deleted only when it has taken no money; everything else is abandoned.** A +charged gift card cannot be removed: the API would refuse and surface a 500, a sales-channel +token cannot clear the transactions, and only a refund would return the balance — which this +iteration does not implement. Its remove control is therefore not rendered at all, rather +than rendered and failing. + +### Place: gift cards first, stop at the first failure + +`placeOrderWithPaymentSessions` takes the order and authorizes the gift cards in sequence, +then the session paying the difference **if there is one** — gift cards can cover the order +outright. + +Gift cards go first as a client-side safety property; nothing server-side enforces it. Each +authorization shrinks what the next session may take, and a gift card charged after a failed +method payment would leave the shopper's balance spent on an order that never got placed. + +On a failure partway, it stops and reports. The cards already charged stay charged: carrying +on would only charge more for an order that is not going to be placed, and **no rollback is +implemented**. The gift card list is itself the recovery surface — after a reload the shopper +sees which cards were charged and what is left to pay. + +On a **timeout** nothing is touched at all, because the payment may well have succeeded. See +`2026-08-18-place-order-split-by-payments-model.md`. + +### Amounts on screen + +`` deducts the gift cards on this model, restoring parity with the older one +where the total already came back net. It deducts the **gift cards only**, not everything +authorized: on the older model an authorized payment source never reduced the total shown. + +`` sums the sessions, since `gift_card_amount_cents` stays at zero, and +shows the figure as a deduction. + +Both branch off the same derivation as everything else. A consuming application that needs +the number outside these components uses `derivePaymentSessionsState`, so the figures cannot +disagree. + +### Errors in two channels + +Gift card errors live on the gift card context; the method's live on ``. +Not one list filtered by setting: gift cards and the method are two disjoint sets with two +separate UIs, so a failure in one must never surface under the other. This closes the shared +error-state debt that was due with the second setting. + +An error carries both a `code` we chose and the message the API sent, because a translated +consumer needs the former and the API's single collapsed message is the only detail +available. + +## Considered options + +- **One list of sessions with a flag, UI decides.** Rejected: pushes the same distinction + onto every consumer. +- **Full split payment, N sessions of any type.** Rejected: rethinking the whole interaction + for a case explicitly out of scope. +- **Authorize each gift card on entry**, so the server's remainder is always right. + Rejected: it charges the card on entry and turns removal into a refund, losing the one + property that makes the flow forgiving. +- **A single gift card.** Rejected: multiple was a requirement. +- **Leave the stale method session and exclude it by comparing amounts.** Rejected: a second + notion of "valid session" based on a client-side amount comparison, and a session that + looks like the selection but is not. +- **Recreate the method session at place time.** Rejected: moves a deletion into the moment + money is being taken. +- **Read `order.session_amount_cents` for the remainder.** Impossible: it does not move + until authorization. +- **``/`` left alone, with a new remainder component.** + Rejected: the shopper reads the existing figures, and showing them gross is the wrong + number in the place they actually look. + +## Consequences + +**The shopper cannot be told why a code was rejected.** Four different causes arrive as one +message. Not a choice of ours — it is all the API gives. + +**Nothing more can be applied once anything is authorized.** `canAddGiftCard` goes false as +soon as any session carries a live authorization, gift cards included. So after a timed-out +place — where the cards are already `paid` — no further card is accepted. This is the interim +answer while settling a partially-paid order remains undesigned; the target state is the +thank-you page with a payment summary described in the place-order ADR. + +**`` no longer equals `order.total_amount_with_taxes_cents`** on this model. +Consumers doing their own arithmetic must use `derivePaymentSessionsState` rather than the +order attribute, or the page will contradict itself. + +**An order fetched without `total_amount_with_taxes_cents` breaks the amounts.** The +`isCovered` guard stops it hiding the payment step, but the deduction and the remainder both +need the figure. Any consumer with a `fields` allowlist has to include it. + +**Orphan sessions accumulate**, now from two sources: abandoned method sessions and the ones +invalidated on every gift card change. All are `unpaid`, so they are inert and contribute to +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 | diff --git a/docs/adr/2026-09-01-presentation-belongs-to-the-application.md b/docs/adr/2026-09-01-presentation-belongs-to-the-application.md new file mode 100644 index 00000000..73c58290 --- /dev/null +++ b/docs/adr/2026-09-01-presentation-belongs-to-the-application.md @@ -0,0 +1,72 @@ +# Presentation state belongs to the consuming application + +**Date:** 2026-09-01 +**Status:** accepted +**Scope:** the `payment_settings` components on the `payment_sessions` model + +## Context + +The first cut of `` held a piece of UI state: `inputRequested`, +from which it derived `isInputVisible = giftCardSessions.length === 0 || inputRequested`. +The input, the submit button and a `` were all gated on +it, and applying a card set it back to false so the field folded away on its own. + +Designing the checkout against these components exposed the cost. mfe-checkout wants the +whole gift card section behind a "use a gift card" switch, opening and closing on the +shopper's click. Two pieces of state then decide whether one input is on screen — the +application's switch and the library's flag — and the library's wins: mounting the input +while `isInputVisible` is false renders nothing, for no reason the application can see. + +The same gap showed up one component up. `` exposed the selection only +through ``'s render prop, so an application could style the +control but not the card around it, and could not make the whole card the click target. + +None of these components have shipped yet, so there is no compatibility to weigh. + +## Decision + +**The library owns domain rules; the application owns presentation.** + +Concretely, on the gift card side: + +- `isInputVisible` and `showInput` are gone from the context, along with + `` — a component whose only job was to flip that flag. + "Add another one" is now ordinary markup in the application. +- `` and `` render + whenever the **domain** allows it: not readonly, and `canAddGiftCard`. That rule stays + here because applying a card that is not needed fails with a 422 about `amount_cents` + that no shopper can act on. +- `` accepts a function child receiving the gift card state + (`giftCardSessions`, `canAddGiftCard`, `isCovered`, `remainingAmountCents`, + `giftCardAmountCents`, `isApplying`, `errors`, `readonly`). An application drives its own + disclosure off those — opening the section when a card is already applied, folding the + field away when the applied count goes up. + +And on the setting side: + +- `` accepts a function child receiving `{ setting, isSelected, isPending, + currentPaymentSession, errors, selectSetting }`, so the chosen option can be styled as a + card and the whole card can select it. +- `selectSetting` is guarded by a **ref**, not by `pendingSettingId`. A single click on a + card wrapping the radio reaches the handler twice, and both reads of the state variable + still say "idle" — which would leave two Payment Sessions behind for one click. + +The state components keep their own conditions, because those are domain rules too: +`` stays visible in readonly and when the order is covered, +and a charged card still renders no remove control. + +## Consequences + +**The field no longer folds away by itself after an apply.** An application that wants +that behaviour watches `giftCardSessions.length` through the function child. mfe-checkout +does exactly that, in `CheckoutPaymentSessions.tsx`. + +**One less component to document and version.** The add button was pure disclosure. + +**Function children are now the single way to read a subtree's state.** Both new render +props follow the shape already used across the library rather than introducing a hook, so +there is one idiom, not two. + +**Nothing prevents an application from rendering a control the domain would refuse** — it +can render its own button and call nothing. The components that actually talk to the API +still refuse, so the failure mode is a dead control, not a bad request. diff --git a/packages/core-components/package.json b/packages/core-components/package.json index d7cedb98..2e205d79 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -56,6 +56,6 @@ }, "dependencies": { "@commercelayer/js-auth": "^8.0.0", - "@commercelayer/sdk": "8.0.0-beta.11" + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75" } } diff --git a/packages/core-components/src/index.ts b/packages/core-components/src/index.ts index 338e9393..c2f0ba6b 100644 --- a/packages/core-components/src/index.ts +++ b/packages/core-components/src/index.ts @@ -7,6 +7,7 @@ export * from "./gift_cards" export * from "./in_stock_subscriptions" export * from "./line_items" export * from "./orders" +export * from "./payment_sessions" export * from "./prices" export * from "./sdk" export * from "./shipments" diff --git a/packages/core-components/src/payment_sessions/apiErrors.ts b/packages/core-components/src/payment_sessions/apiErrors.ts new file mode 100644 index 00000000..e21804b2 --- /dev/null +++ b/packages/core-components/src/payment_sessions/apiErrors.ts @@ -0,0 +1,34 @@ +/** Shape of a JSON:API error object as the API sends it. */ +export interface ApiErrorObject { + code?: string + title?: string + detail?: string + source?: { pointer?: string } + meta?: { error?: string } +} + +/** + * The SDK surfaces API errors as a thrown object, but the exact wrapper has + * moved between versions, so probe the two shapes rather than assuming one. + * + * Note what is *not* here: `error.message`. On an SDK error that is empty — the + * API's wording only ever lives in this array — so anything reading `message` + * shows the shopper a blank. + */ +export function extractApiErrors(error: unknown): ApiErrorObject[] { + if (error == null || typeof error !== "object") return [] + const candidate = error as { errors?: unknown; response?: { data?: { errors?: unknown } } } + const errors = candidate.errors ?? candidate.response?.data?.errors + return Array.isArray(errors) ? (errors as ApiErrorObject[]) : [] +} + +/** + * `/data/attributes/payment_action` → `payment_action`. + * `/data` (a base error, not tied to an attribute) → `base`. + */ +export function fieldFromPointer(pointer?: string): string | undefined { + if (pointer == null || pointer === "") return undefined + const last = pointer.split("/").pop() + if (last == null || last === "") return undefined + return last === "data" ? "base" : last +} diff --git a/packages/core-components/src/payment_sessions/applyGiftCard.ts b/packages/core-components/src/payment_sessions/applyGiftCard.ts new file mode 100644 index 00000000..c3f5c0e1 --- /dev/null +++ b/packages/core-components/src/payment_sessions/applyGiftCard.ts @@ -0,0 +1,65 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" +import { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" + +interface ApplyGiftCardParams extends Pick { + /** The order as last fetched. Its sessions decide the amount to ask for. */ + order: Order + giftCardCode: string +} + +/** + * Spend a gift card on an order by creating a Payment Session for it. + * + * **Why an explicit `amount_cents` from the second card onwards.** The server + * sizes a gift card session as `min(order.session_amount_cents, balance)`, and + * `session_amount_cents` only drops once a session is *authorized*. Gift cards + * are authorized at place time, so while the shopper is still choosing the + * server sees the full total: a second card would be sized for the whole order + * rather than the difference. On a $71 order, a $50 card followed by a $100 card + * would produce sessions of $50 and $71 — $121 of credit for a $71 order, with + * nothing server-side to stop it. + * + * Sending an amount is safe because `cap_amount_cents` clamps it *down* to what + * the server would have allowed and never up, so the server stays the authority + * on the maximum. And the number sent is a sum of amounts the server itself + * computed for the earlier sessions — not a reimplementation of its rules. + * + * This is the one place the "never send `amount_cents`" rule is broken. It still + * holds for the session paying the difference, where the server must be the one + * to work out what is left. + * + * Applying a gift card also **invalidates the session paying the difference**: + * its `amount_cents` is immutable, so the moment the remainder changes that + * session is dead. Both happen here, as one operation, because binding the + * invalidation to the action that causes it is what makes it deterministic — + * rather than an effect somewhere comparing amounts. + */ +export async function applyGiftCard({ + accessToken, + interceptors, + order, + giftCardCode, +}: ApplyGiftCardParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + const state = derivePaymentSessionsState(order) + + if (state.giftCardSettingId == null) { + throw new Error("This order has no gift card payment setting available.") + } + + const session = await sdk.payment_sessions.create({ + gift_card_code: giftCardCode, + payment_setting: sdk.payment_settings.relationship(state.giftCardSettingId), + order: sdk.orders.relationship(order.id), + // Omitted for the first card so the server sizes it against the balance; + // supplied afterwards because the server's own remainder has not moved. + ...(state.giftCardSessions.length > 0 ? { amount_cents: state.remainingAmountCents } : {}), + }) + + await invalidateCurrentPaymentSession({ accessToken, interceptors, order }) + + return session +} diff --git a/packages/core-components/src/payment_sessions/createPaymentSession.spec.ts b/packages/core-components/src/payment_sessions/createPaymentSession.spec.ts new file mode 100644 index 00000000..da0f7395 --- /dev/null +++ b/packages/core-components/src/payment_sessions/createPaymentSession.spec.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { createPaymentSession } from "./createPaymentSession" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ACCESS_TOKEN = "token" + +function stubSdk(): ReturnType { + const create = vi.fn().mockResolvedValue({ id: "session-new" }) + getSdkMock.mockReturnValue({ + payment_sessions: { create }, + payment_settings: { relationship: vi.fn((id: string) => ({ id, type: "payment_settings" })) }, + orders: { relationship: vi.fn((id: string) => ({ id, type: "orders" })) }, + }) + return create +} + +describe("createPaymentSession", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The server sizes an omitted amount to the full total until the gift cards + // are authorized at place time, so the remainder has to be sent. + it("sends the remainder as amount_cents", async () => { + const create = stubSdk() + + await createPaymentSession({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSettingId: "ps-manual", + amountCents: 5100, + }) + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ amount_cents: 5100 })) + }) + + it("omits amount_cents when the remainder is unknown", async () => { + const create = stubSdk() + + await createPaymentSession({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSettingId: "ps-manual", + }) + + expect(create).toHaveBeenCalledWith( + expect.not.objectContaining({ amount_cents: expect.any(Number) }) + ) + }) + + // `amount_cents` must be greater than zero, so a covered order would get a + // 422 rather than a session — leave the sizing to the server instead. + it.each([0, -100])("omits amount_cents when the remainder is %i", async (amountCents) => { + const create = stubSdk() + + await createPaymentSession({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSettingId: "ps-manual", + amountCents, + }) + + expect(create).toHaveBeenCalledWith( + expect.not.objectContaining({ amount_cents: expect.any(Number) }) + ) + }) +}) diff --git a/packages/core-components/src/payment_sessions/createPaymentSession.ts b/packages/core-components/src/payment_sessions/createPaymentSession.ts new file mode 100644 index 00000000..00cae710 --- /dev/null +++ b/packages/core-components/src/payment_sessions/createPaymentSession.ts @@ -0,0 +1,56 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface CreatePaymentSessionParams extends Pick { + orderId: string + /** Id of the selected Payment Setting, from `order.available_payment_settings`. */ + paymentSettingId: string + /** + * What this session has to pay, from `derivePaymentSessionsState`. Omit it + * only when the remainder is genuinely unknown — see below for why leaving it + * to the server is not the safe default it looks like. + */ + amountCents?: number +} + +/** + * Create a Payment Session for an order against the selected Payment Setting. + * + * **Why `amount_cents` is sent.** The server sizes an omitted amount to + * `order.session_amount_cents`, and that number does not drop until a session + * is *authorized*. Gift cards are authorized at place time, so while the + * shopper is still choosing, the server sees the full total: on a $71 order + * with a $20 gift card applied, an omitted amount produces a $71 session, and + * at place time $91 is taken for a $71 order. The order is placed but lands on + * `payment_status: "partially_authorized"` — `total - taken` is *negative*, not + * zero — which reads to a consuming checkout as payment not completed. + * + * This mirrors what `applyGiftCard` does from the second card onwards, and for + * the same reason. The number is safe to send because `cap_amount_cents` clamps + * it *down* to what the server would have allowed and never up, so the server + * stays the authority on the maximum; and the value is a sum of amounts the + * server itself computed for the gift card sessions, not a reimplementation of + * its pricing. + * + * The relationship is set through the polymorphic `payment_settings` resource + * rather than the per-provider one, which is what the API expects for any + * setting type. + */ +export async function createPaymentSession({ + accessToken, + interceptors, + orderId, + paymentSettingId, + amountCents, +}: 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), + // 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. + ...(amountCents != null && amountCents > 0 ? { amount_cents: amountCents } : {}), + }) +} diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts new file mode 100644 index 00000000..3b2ced70 --- /dev/null +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts @@ -0,0 +1,183 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } +const TOTAL = 7100 + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: MANUAL, + ...overrides, + } as PaymentSession +} + +function giftCard(id: string, amountCents: number, overrides: Partial = {}) { + return session({ + id, + amount_cents: amountCents, + payment_setting: GIFT_CARD as never, + ...overrides, + }) +} + +function order(sessions: PaymentSession[], settings = [MANUAL, GIFT_CARD]): Order { + return { + id: "order-1", + type: "orders", + total_amount_with_taxes_cents: TOTAL, + payment_sessions: sessions, + available_payment_settings: settings, + } as Order +} + +describe("derivePaymentSessionsState", () => { + it("reports nothing paid for an order with no sessions", () => { + const state = derivePaymentSessionsState(order([])) + expect(state).toMatchObject({ + giftCardSessions: [], + giftCardAmountCents: 0, + remainingAmountCents: TOTAL, + isCovered: false, + canAddGiftCard: true, + giftCardSettingId: "ps-gift", + }) + expect(state.currentPaymentSession).toBeUndefined() + }) + + // A total we do not have must never read as "nothing left to pay": an order + // fetched without `total_amount_with_taxes_cents` would otherwise hide the + // entire payment step. + it("does not report coverage for a missing order", () => { + const state = derivePaymentSessionsState(undefined) + expect(state.remainingAmountCents).toBe(0) + expect(state.isCovered).toBe(false) + }) + + it("does not report coverage when the total is absent from the fetch", () => { + const state = derivePaymentSessionsState({ + id: "order-1", + payment_sessions: [], + } as never) + expect(state.isCovered).toBe(false) + }) + + // The point of this derivation: the server's own remainder does not move + // until a session is authorized, and gift cards are authorized at place time. + it("counts an applied gift card before it is authorized", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", 2000)])) + expect(state.giftCardAmountCents).toBe(2000) + expect(state.remainingAmountCents).toBe(5100) + expect(state.isCovered).toBe(false) + }) + + it("sums several gift cards", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000), giftCard("gift-b", 1500)]) + ) + expect(state.giftCardSessions.map((s) => s.id)).toEqual(["gift-a", "gift-b"]) + expect(state.remainingAmountCents).toBe(3600) + }) + + it("reports full coverage when gift cards reach the total", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", TOTAL)])) + expect(state.remainingAmountCents).toBe(0) + expect(state.isCovered).toBe(true) + expect(state.canAddGiftCard).toBe(false) + }) + + it("never reports a negative remainder", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", TOTAL + 5000)])) + expect(state.remainingAmountCents).toBe(0) + }) + + // A burnt or refunded card took no money; showing it would tell the shopper a + // payment is in place when none is. + it.each(["declined", "failed", "canceled", "expired"])( + "drops a gift card whose authorization is %s", + (status) => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { payment_authorization: { status } as never })]) + ) + expect(state.giftCardSessions).toEqual([]) + expect(state.remainingAmountCents).toBe(TOTAL) + } + ) + + it("drops a refunded gift card", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { payment_refunds: [{ id: "refund-1" }] as never })]) + ) + expect(state.giftCardSessions).toEqual([]) + }) + + it("keeps a gift card whose authorization is still in flight", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { payment_authorization: { status: "pending" } as never })]) + ) + expect(state.giftCardSessions.map((s) => s.id)).toEqual(["gift-a"]) + }) + + describe("the session paying the difference", () => { + it("is the current selection, and is not a gift card", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000), session({ id: "method", amount_cents: 5100 })]) + ) + expect(state.currentPaymentSession?.id).toBe("method") + }) + + // An unauthorized method session is an intent, not a payment: counting it + // would hide the fact that nothing has been taken. + it("does not reduce the remainder until it is authorized", () => { + const state = derivePaymentSessionsState(order([session({ amount_cents: 7100 })])) + expect(state.remainingAmountCents).toBe(TOTAL) + }) + + it("reduces the remainder once authorized", () => { + const state = derivePaymentSessionsState( + order([ + session({ + amount_cents: 5100, + payment_authorization: { status: "succeeded" } as never, + }), + giftCard("gift-a", 2000, { payment_authorization: { status: "succeeded" } as never }), + ]) + ) + expect(state.remainingAmountCents).toBe(0) + expect(state.isCovered).toBe(true) + }) + }) + + describe("canAddGiftCard", () => { + // Settling a partially-paid order is a flow this iteration does not + // implement, so once money is taken or in flight nothing more is accepted. + it("is false once any session has been authorized", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 1000, { payment_authorization: { status: "pending" } as never })]) + ) + expect(state.remainingAmountCents).toBe(6100) + expect(state.canAddGiftCard).toBe(false) + }) + + it("is true while everything is still unauthorized and something is owed", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", 1000)])) + expect(state.canAddGiftCard).toBe(true) + }) + + it("is unaffected by a failed authorization", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 1000, { payment_authorization: { status: "failed" } as never })]) + ) + expect(state.canAddGiftCard).toBe(true) + }) + }) + + it("reports no gift card setting when the order has none available", () => { + const state = derivePaymentSessionsState(order([], [MANUAL])) + expect(state.giftCardSettingId).toBeUndefined() + }) +}) diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts new file mode 100644 index 00000000..1b5f6b76 --- /dev/null +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts @@ -0,0 +1,108 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" +import { GIFT_CARD_SETTING_TYPE, hasLiveAuthorization, isGiftCardSession } from "./types" + +export interface PaymentSessionsState { + /** + * Gift cards the shopper has applied and can still see, newest last. Excludes + * any whose authorization failed or that have been refunded: those took no + * money and showing them would tell the shopper a payment is in place when + * none is. + */ + giftCardSessions: PaymentSession[] + /** Sum of the applied gift cards, at face value. */ + giftCardAmountCents: number + /** + * What the shopper still has to pay. + * + * Derived here rather than read from `order.session_amount_cents`, which does + * not move until a session is authorized — and gift cards are authorized at + * place time, so the server's number stays at the full total for the whole + * time the shopper is choosing. The amounts summed are the ones the server + * computed for each session, so this is arithmetic over server values, not a + * reimplementation of its rules. + */ + remainingAmountCents: number + /** + * True when the order had something to pay and it is now all covered. + * + * False when the total is unknown or zero: an order fetched without + * `total_amount_with_taxes_cents` must not read as paid for. + */ + isCovered: boolean + /** + * Whether another gift card may be applied. + * + * False once anything has been authorized: money is taken or in flight, and + * settling a partially-paid order is a flow this iteration does not + * implement. See the place-order ADR. + */ + canAddGiftCard: boolean + /** The non-gift-card session paying the difference, if the shopper picked one. */ + currentPaymentSession?: PaymentSession + /** The gift card Payment Setting, when the order has one available. */ + giftCardSettingId?: string +} + +/** + * Everything the payment UI needs to know about an order's Payment Sessions, + * in one place. + * + * One source deliberately: the remaining amount drives the order total, whether + * the method selector renders at all, whether the gift card input accepts + * another code, and whether a consuming application still considers payment + * required. Deriving it in more than one place is how those four end up + * disagreeing. + */ +export function derivePaymentSessionsState(order?: Order | null): PaymentSessionsState { + const sessions = order?.payment_sessions ?? [] + const total = order?.total_amount_with_taxes_cents ?? 0 + + const giftCardSessions = sessions.filter( + (session) => isGiftCardSession(session) && isLiveGiftCard(session) + ) + const giftCardAmountCents = sumAmounts(giftCardSessions) + + // A method session reduces what is left only once it has taken money — an + // unauthorized one is just an intent. Gift cards count as soon as applied, + // which is the whole reason this derivation exists. + const takenMethodAmountCents = sumAmounts( + sessions.filter((session) => !isGiftCardSession(session) && hasLiveAuthorization(session)) + ) + + const remainingAmountCents = Math.max(0, total - giftCardAmountCents - takenMethodAmountCents) + + // `total > 0` guards against reading "nothing left to pay" out of a total we + // do not have. An order fetched without `total_amount_with_taxes_cents` in + // its `fields` looks free, and a bare `remainingAmountCents === 0` would then + // hide the whole payment step. A genuinely free order needs no payment either, + // but that is decided from the total itself, not from coverage. + const isCovered = total > 0 && remainingAmountCents === 0 + + return { + giftCardSessions, + giftCardAmountCents, + remainingAmountCents, + isCovered, + canAddGiftCard: remainingAmountCents > 0 && !sessions.some(hasLiveAuthorization), + currentPaymentSession: findCurrentPaymentSession({ paymentSessions: sessions }), + giftCardSettingId: (order?.available_payment_settings ?? []).find( + (setting) => setting.type === GIFT_CARD_SETTING_TYPE + )?.id, + } +} + +/** + * A gift card session still worth showing: nothing failed and nothing was given + * back. A refunded one is history, not an applied card. + */ +function isLiveGiftCard(session: PaymentSession): boolean { + if ((session.payment_refunds ?? []).length > 0) return false + const status = session.payment_authorization?.status + if (status == null) return true + return hasLiveAuthorization(session) +} + +function sumAmounts(sessions: PaymentSession[]): number { + return sessions.reduce((total, session) => total + (session.amount_cents ?? 0), 0) +} diff --git a/packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts new file mode 100644 index 00000000..f49fdc85 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts @@ -0,0 +1,92 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" + +const MANUAL = "setting-manual" +const GIFT_CARD = "setting-gift-card" + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: { id: MANUAL, type: "payment_setting_manuals" }, + ...overrides, + } as PaymentSession +} + +describe("findCurrentPaymentSession", () => { + it("returns undefined when the order has no sessions", () => { + expect(findCurrentPaymentSession({ paymentSessions: [] })).toBeUndefined() + expect(findCurrentPaymentSession({ paymentSessions: null })).toBeUndefined() + }) + + // Unlike a reusable session, one that already took money is still the + // shopper's selection — it just must not be adopted for a new payment. + it.each(["unpaid", "authorized", "paid", "partially_paid"])( + "treats a session in status %s as the selection", + (status) => { + const current = session({ status }) + expect(findCurrentPaymentSession({ paymentSessions: [current] })).toBe(current) + } + ) + + // Showing a burnt session as the selection would tell the shopper a payment + // is in place when none is. + it.each(["declined", "failed", "canceled", "expired"])( + "ignores a session whose authorization is %s", + (status) => { + const burnt = session({ payment_authorization: { status } as never }) + expect(findCurrentPaymentSession({ paymentSessions: [burnt] })).toBeUndefined() + } + ) + + it("keeps a session whose authorization is still in flight", () => { + const inFlight = session({ payment_authorization: { status: "processing" } as never }) + expect(findCurrentPaymentSession({ paymentSessions: [inFlight] })).toBe(inFlight) + }) + + // Switching setting leaves the previous session behind, so "newest wins" is + // what keeps a radio group from showing two selections at once. + it("returns the most recent session when several are live", () => { + const older = session({ + id: "older", + created_at: "2026-08-18T10:00:00Z", + payment_setting: { id: GIFT_CARD } as never, + }) + const newer = session({ id: "newer", created_at: "2026-08-18T11:00:00Z" }) + expect(findCurrentPaymentSession({ paymentSessions: [older, newer] })?.id).toBe("newer") + expect(findCurrentPaymentSession({ paymentSessions: [newer, older] })?.id).toBe("newer") + }) + + it("skips burnt sessions when picking the most recent", () => { + const burntNewer = session({ + id: "burnt", + created_at: "2026-08-18T12:00:00Z", + payment_authorization: { status: "failed" } as never, + }) + const liveOlder = session({ id: "live", created_at: "2026-08-18T10:00:00Z" }) + expect(findCurrentPaymentSession({ paymentSessions: [burntNewer, liveOlder] })?.id).toBe("live") + }) + + it("falls back to array order when created_at is absent", () => { + const first = session({ id: "first" }) + const second = session({ id: "second" }) + expect(findCurrentPaymentSession({ paymentSessions: [first, second] })?.id).toBe("second") + }) + + it("narrows to one setting when asked", () => { + const manual = session({ id: "manual", created_at: "2026-08-18T10:00:00Z" }) + const giftCard = session({ + id: "gift", + created_at: "2026-08-18T11:00:00Z", + payment_setting: { id: GIFT_CARD } as never, + }) + expect( + findCurrentPaymentSession({ + paymentSessions: [manual, giftCard], + paymentSettingId: MANUAL, + })?.id + ).toBe("manual") + }) +}) diff --git a/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts new file mode 100644 index 00000000..15134b07 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts @@ -0,0 +1,63 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { isGiftCardSession, TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" + +interface FindCurrentPaymentSessionParams { + paymentSessions?: PaymentSession[] | null + /** + * Narrow to one Payment Setting. Omit to get the order's single current + * selection across every setting, which is what a radio group needs. + */ + paymentSettingId?: string +} + +/** + * Find the Payment Session the shopper's selection points at. + * + * The order has no `payment_setting` relationship, so a selection only exists + * as a session — this is how the choice is read back, and why it survives a + * reload. Browser state is a rendering cache of this, never the authority. + * + * Distinct from {@link findReusablePaymentSession}, which answers a narrower + * question: "may I adopt this instead of creating one?". A session that has + * already taken money still *is* the selection, but must not be reused. + * + * A session carrying a failed authorization is excluded: it is burnt, the + * shopper has to try again, and showing it as the current selection would tell + * them a payment is in place when none is. + * + * **Gift card sessions are excluded entirely.** A gift card is not one of the + * alternatives the shopper picks between — it is additive, applied on top, and + * an order may carry several at once. Including them here would put more than + * one session in a group that has room for exactly one. They are read through + * `derivePaymentSessionsState` instead. + * + * **The selection is single, and it is the most recent session.** Switching + * setting leaves the previous session on the order — it is never deleted, both + * because an inert `unpaid` session costs nothing and because a sales-channel + * token can be refused the delete. Picking the newest is therefore what keeps + * a radio group coherent: without it, every setting the shopper has ever tried + * would read as selected at once. + */ +export function findCurrentPaymentSession({ + paymentSessions, + paymentSettingId, +}: FindCurrentPaymentSessionParams): PaymentSession | undefined { + const live = (paymentSessions ?? []).filter((session) => { + if (isGiftCardSession(session)) return false + if (paymentSettingId != null && session.payment_setting?.id !== paymentSettingId) return false + const authorizationStatus = session.payment_authorization?.status + if (authorizationStatus == null) return true + return !TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + authorizationStatus as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) + }) + + // `created_at` is optional on the type, so fall back to array order rather + // than dropping a session that came back without it. + return live.reduce((newest, session) => { + if (newest == null) return session + const a = session.created_at == null ? 0 : Date.parse(session.created_at) + const b = newest.created_at == null ? 0 : Date.parse(newest.created_at) + return a >= b ? session : newest + }, undefined) +} diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts new file mode 100644 index 00000000..5b01b4a9 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts @@ -0,0 +1,194 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { findReusablePaymentSession } from "./findReusablePaymentSession" + +const NOW = new Date("2026-08-18T12:00:00Z") +const SETTING_ID = "setting-manual" + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + created_at: "", + updated_at: "", + payment_setting: { id: SETTING_ID, type: "payment_setting_manuals" }, + ...overrides, + } as PaymentSession +} + +describe("findReusablePaymentSession", () => { + it("adopts an unpaid session belonging to the selected setting", () => { + const reusable = session() + expect( + findReusablePaymentSession({ + paymentSessions: [reusable], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(reusable) + }) + + it("returns undefined when there are no sessions at all", () => { + expect( + findReusablePaymentSession({ paymentSessions: [], paymentSettingId: SETTING_ID, now: NOW }) + ).toBeUndefined() + expect( + findReusablePaymentSession({ paymentSessions: null, paymentSettingId: SETTING_ID, now: NOW }) + ).toBeUndefined() + }) + + it("ignores a session belonging to a different setting", () => { + const other = session({ payment_setting: { id: "setting-gift-card" } as never }) + expect( + findReusablePaymentSession({ + paymentSessions: [other], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + }) + + // A session that already took money must never be adopted: `amount_cents` is + // immutable, and re-selecting it would misreport what the shopper still owes. + it.each(["authorized", "paid", "partially_paid", "voided", "refunded", "partially_refunded"])( + "ignores a session in status %s", + (status) => { + expect( + findReusablePaymentSession({ + paymentSessions: [session({ status })], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + } + ) + + it("ignores an expired session", () => { + const expired = session({ expires_at: "2026-08-18T11:59:59Z" }) + expect( + findReusablePaymentSession({ + paymentSessions: [expired], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + }) + + it("adopts a session whose expiry is still in the future", () => { + const live = session({ expires_at: "2026-08-18T12:00:01Z" }) + expect( + findReusablePaymentSession({ + paymentSessions: [live], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(live) + }) + + // The decisive case: a failed authorization leaves the session `unpaid`, + // because only a *succeeded* authorization advances it. Status alone cannot + // tell a fresh session from a burnt one. + it.each(["declined", "failed", "canceled", "expired"])( + "ignores an unpaid session whose authorization is %s", + (status) => { + const burnt = session({ payment_authorization: { status } as never }) + expect( + findReusablePaymentSession({ + paymentSessions: [burnt], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + } + ) + + // In flight, not burnt — adopting it is what stops a remount creating a second. + it.each(["pending", "processing", "requires_action"])( + "adopts an unpaid session whose authorization is still %s", + (status) => { + const inFlight = session({ payment_authorization: { status } as never }) + expect( + findReusablePaymentSession({ + paymentSessions: [inFlight], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(inFlight) + } + ) + + it("searches the array rather than reading the first entry", () => { + const giftCard = session({ id: "gift", payment_setting: { id: "setting-gift-card" } as never }) + const burnt = session({ id: "burnt", payment_authorization: { status: "failed" } as never }) + const fresh = session({ id: "fresh" }) + expect( + findReusablePaymentSession({ + paymentSessions: [giftCard, burnt, fresh], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(fresh) + }) + + it("does not decide on statuses it has never heard of", () => { + const unknown = session({ status: "some_future_state" }) + expect( + findReusablePaymentSession({ + paymentSessions: [unknown], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + }) +}) + +// Applying a gift card moves the remainder, and `amount_cents` is immutable — +// so the session created before it is wrong, not merely stale. The deletion in +// `invalidateCurrentPaymentSession` is best effort, which is why this check +// exists here too. +describe("findReusablePaymentSession amount check", () => { + it("ignores a session sized for a different remainder", () => { + expect( + findReusablePaymentSession({ + paymentSessions: [session({ amount_cents: 7100 })], + paymentSettingId: SETTING_ID, + amountCents: 5100, + now: NOW, + }) + ).toBeUndefined() + }) + + it("adopts a session sized for the current remainder", () => { + const reusable = session({ amount_cents: 5100 }) + expect( + findReusablePaymentSession({ + paymentSessions: [reusable], + paymentSettingId: SETTING_ID, + amountCents: 5100, + now: NOW, + }) + ).toBe(reusable) + }) + + it("still adopts when either amount is unknown", () => { + const noSessionAmount = session() + expect( + findReusablePaymentSession({ + paymentSessions: [noSessionAmount], + paymentSettingId: SETTING_ID, + amountCents: 5100, + now: NOW, + }) + ).toBe(noSessionAmount) + + const withAmount = session({ amount_cents: 7100 }) + expect( + findReusablePaymentSession({ + paymentSessions: [withAmount], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(withAmount) + }) +}) diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts new file mode 100644 index 00000000..535e6ad8 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts @@ -0,0 +1,76 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" + +interface FindReusablePaymentSessionParams { + /** The order's sessions, from `order.payment_sessions`. */ + paymentSessions?: PaymentSession[] | null + /** The Payment Setting the shopper selected. */ + paymentSettingId: string + /** + * What the session would have to pay, from `derivePaymentSessionsState`. When + * given, a session sized for a different remainder is not adopted. Omit it + * only where the remainder is unknown. + */ + amountCents?: number + /** Injectable for tests. Defaults to now. */ + now?: Date +} + +/** + * Find a Payment Session the library may adopt instead of creating a new one. + * + * A session qualifies when it belongs to the selected Payment Setting, has not + * taken any money yet, has not expired, and is not carrying a failed + * authorization. + * + * Reuse is not an optimisation — it is what makes selection survive a reload + * and what stops a remount from creating a second session. `amount_cents` is + * immutable once a session exists, so "update the existing one" is not an + * option the API offers. + * + * Sessions are always searched for, never read positionally: failed attempts + * leave inert `unpaid` sessions behind, and a gift card (or, later, a split + * payment) puts other settings' sessions in the same array. + * + * A session sized for a different remainder is not reusable either. Applying a + * gift card is supposed to delete it (`invalidateCurrentPaymentSession`), but + * that deletion is best effort and swallows its failures — so the amount is + * checked here as well, where adopting the wrong one would authorize more than + * is owed. + */ +export function findReusablePaymentSession({ + paymentSessions, + paymentSettingId, + amountCents, + now = new Date(), +}: FindReusablePaymentSessionParams): PaymentSession | undefined { + return (paymentSessions ?? []).find((session) => { + if (session.payment_setting?.id !== paymentSettingId) return false + + // Only when both numbers are known: an order fetched without + // `amount_cents` in its `fields` must not lose reuse altogether. + if (amountCents != null && session.amount_cents != null && session.amount_cents !== amountCents) + return false + + // Anything past `unpaid` has taken money (or has been voided/refunded); + // in both cases creating or adopting it again would be wrong. + if (session.status !== "unpaid") return false + + if (session.expires_at != null && new Date(session.expires_at) <= now) return false + + // A session whose authorization failed stays `unpaid` forever — the + // session only advances on a *succeeded* authorization — so status alone + // cannot tell a fresh session from a burnt one. + const authorizationStatus = session.payment_authorization?.status + if ( + authorizationStatus != null && + TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + authorizationStatus as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) + ) { + return false + } + + return true + }) +} diff --git a/packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts b/packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts new file mode 100644 index 00000000..66837158 --- /dev/null +++ b/packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts @@ -0,0 +1,56 @@ +import type { Order } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { getPaymentsModel } from "./getPaymentsModel" + +const SETTING = { id: "ps-1", type: "payment_setting_manuals" } +const METHOD = { id: "pm-1", payment_source_type: "stripe_payments" } + +function order(overrides: Partial = {}): Order { + return { id: "order-1", type: "orders", ...overrides } as Order +} + +describe("getPaymentsModel", () => { + it("is undetermined without an order", () => { + expect(getPaymentsModel(undefined)).toBe("undetermined") + expect(getPaymentsModel(null)).toBe("undetermined") + }) + + it("reads payment_sessions from available_payment_settings", () => { + expect(getPaymentsModel(order({ available_payment_settings: [SETTING] } as never))).toBe( + "payment_sessions" + ) + }) + + it("reads payment_source from available_payment_methods", () => { + expect(getPaymentsModel(order({ available_payment_methods: [METHOD] } as never))).toBe( + "payment_source" + ) + }) + + // 2026-05 is additive, so both arrays can arrive together. The newer model + // wins, and that precedence belongs to the library, not to its consumers. + it("prefers payment_sessions when the order carries both", () => { + expect( + getPaymentsModel( + order({ + available_payment_settings: [SETTING], + available_payment_methods: [METHOD], + } as never) + ) + ).toBe("payment_sessions") + }) + + // An empty array is not a model: an order with nothing configured must not + // be routed to either tree. + it("is undetermined when both arrays are empty", () => { + expect( + getPaymentsModel( + order({ available_payment_settings: [], available_payment_methods: [] } as never) + ) + ).toBe("undetermined") + }) + + it("is undetermined when the order carries neither relationship", () => { + expect(getPaymentsModel(order())).toBe("undetermined") + }) +}) diff --git a/packages/core-components/src/payment_sessions/getPaymentsModel.ts b/packages/core-components/src/payment_sessions/getPaymentsModel.ts new file mode 100644 index 00000000..f4c94cf6 --- /dev/null +++ b/packages/core-components/src/payment_sessions/getPaymentsModel.ts @@ -0,0 +1,50 @@ +import type { Order } from "@commercelayer/sdk" + +/** + * Which of the two mutually exclusive payment models an order uses. + * + * An order is bound to one model for its whole life and can never switch. The + * values are named after the order relationship that carries the payment, not + * after their age — "legacy" and "new" age badly, and "v1/v2" collides with the + * API version, which is a different thing entirely. + */ +export type PaymentsModel = + /** `payment_gateways` + `payment_methods` + one payment source per order. */ + | "payment_source" + /** `payment_settings` + `payment_sessions`. */ + | "payment_sessions" + /** The order data needed to decide has not loaded yet. */ + | "undetermined" + +/** + * Derive the Payments Model from an order. + * + * API version `2026-05` is purely additive, so a single response can carry + * **both** `available_payment_methods` and `available_payment_settings`. When + * it does, the newer model wins and the older flow is excluded entirely. + * + * That precedence lives here, in the library, and never in the consuming + * application. Pushing it out — for instance by having the app blank out + * `available_payment_methods` after fetching — turns a domain rule into + * something every consumer has to remember, and mutates an API response to make + * components behave. + * + * Pure and React-free on purpose: an application's own data layer needs the + * same answer as the components do, and a hook cannot be called from outside a + * component. `usePaymentsModel()` in `@commercelayer/react-components` is a + * thin wrapper over this. + * + * Requires the order to have been fetched **with** `available_payment_settings` + * in its `include`. Without it, an absent array is indistinguishable from one + * that was never requested and this silently reports the wrong model. + * + * @returns the model, and `"undetermined"` when the order is missing or carries + * neither relationship. That third state is real and observable, not a + * transient detail: every caller has to handle it. + */ +export function getPaymentsModel(order?: Order | null): PaymentsModel { + if (order == null) return "undetermined" + if ((order.available_payment_settings ?? []).length > 0) return "payment_sessions" + if ((order.available_payment_methods ?? []).length > 0) return "payment_source" + return "undetermined" +} diff --git a/packages/core-components/src/payment_sessions/giftCardOperations.spec.ts b/packages/core-components/src/payment_sessions/giftCardOperations.spec.ts new file mode 100644 index 00000000..c8303dfd --- /dev/null +++ b/packages/core-components/src/payment_sessions/giftCardOperations.spec.ts @@ -0,0 +1,222 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { applyGiftCard } from "./applyGiftCard" +import { removeGiftCard } from "./removeGiftCard" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ACCESS_TOKEN = "token" +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } +const TOTAL = 7100 + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: MANUAL, + ...overrides, + } as PaymentSession +} + +function giftCard(id: string, amountCents: number, overrides: Partial = {}) { + return session({ + id, + amount_cents: amountCents, + payment_setting: GIFT_CARD as never, + ...overrides, + }) +} + +function order(sessions: PaymentSession[], settings = [MANUAL, GIFT_CARD]): Order { + return { + id: "order-1", + type: "orders", + total_amount_with_taxes_cents: TOTAL, + payment_sessions: sessions, + available_payment_settings: settings, + } as Order +} + +interface SdkStub { + create: ReturnType + del: ReturnType +} + +function stubSdk(): SdkStub { + const stub: SdkStub = { + create: vi.fn().mockResolvedValue({ id: "gift-new" }), + del: vi.fn().mockResolvedValue(undefined), + } + getSdkMock.mockReturnValue({ + payment_sessions: { create: stub.create, delete: stub.del }, + payment_settings: { relationship: vi.fn((id: string) => ({ id, type: "payment_settings" })) }, + orders: { relationship: vi.fn((id: string) => ({ id, type: "orders" })) }, + }) + return stub +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("applyGiftCard", () => { + // The server sizes the first card against its own balance, which is what we + // want — it knows the balance and we do not. + it("omits amount_cents for the first gift card", async () => { + const sdk = stubSdk() + + await applyGiftCard({ accessToken: ACCESS_TOKEN, order: order([]), giftCardCode: "ABC" }) + + expect(sdk.create).toHaveBeenCalledOnce() + const payload = sdk.create.mock.calls[0]?.[0] + expect(payload.gift_card_code).toBe("ABC") + expect(payload).not.toHaveProperty("amount_cents") + }) + + // The decisive case. The server's remainder has not moved — the first card is + // not authorized yet — so left to itself it would size this one for the whole + // order. On a 7100 order with 2000 already applied, that would be 7100 again: + // 9100 of credit for a 7100 order, with nothing server-side to stop it. + it("sends the real remainder from the second gift card onwards", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000)]), + giftCardCode: "DEF", + }) + + expect(sdk.create.mock.calls[0]?.[0].amount_cents).toBe(5100) + }) + + it("accounts for every applied card when computing the remainder", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000), giftCard("gift-b", 1500)]), + giftCardCode: "GHI", + }) + + expect(sdk.create.mock.calls[0]?.[0].amount_cents).toBe(3600) + }) + + it("refuses when the order has no gift card setting", async () => { + stubSdk() + + await expect( + applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([], [MANUAL]), + giftCardCode: "ABC", + }) + ).rejects.toThrow(/no gift card payment setting/i) + }) + + // `amount_cents` is immutable, so a session created against a larger + // remainder is not stale but wrong: left alone it would still read as the + // selection and authorize more than is owed. + it("deletes the session paying the difference", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([session({ id: "method", amount_cents: 7100 })]), + giftCardCode: "ABC", + }) + + expect(sdk.del).toHaveBeenCalledWith("method") + }) + + it("leaves an authorized method session alone", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([ + session({ id: "method", payment_authorization: { status: "succeeded" } as never }), + ]), + giftCardCode: "ABC", + }) + + expect(sdk.del).not.toHaveBeenCalled() + }) + + // The shopper asked for the gift card and can see whether it landed; turning + // a cleanup failure into a visible error would report the wrong thing. + it("still succeeds when the cleanup delete fails", async () => { + const sdk = stubSdk() + sdk.del.mockRejectedValue(new Error("Forbidden")) + + await expect( + applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([session({ id: "method" })]), + giftCardCode: "ABC", + }) + ).resolves.toMatchObject({ id: "gift-new" }) + }) +}) + +describe("removeGiftCard", () => { + it("deletes the gift card session", async () => { + const sdk = stubSdk() + + await removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000)]), + paymentSessionId: "gift-a", + }) + + expect(sdk.del).toHaveBeenCalledWith("gift-a") + }) + + // Removing raises the remainder, so the method session's fixed amount is now + // too small — the same reason applying one invalidates it. + it("also deletes the session paying the difference", async () => { + const sdk = stubSdk() + + await removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000), session({ id: "method", amount_cents: 5100 })]), + paymentSessionId: "gift-a", + }) + + expect(sdk.del.mock.calls.map((call) => call[0])).toEqual(["gift-a", "method"]) + }) + + // Authorizing a gift card debits the balance immediately, and only a refund + // could return it — which this iteration does not implement. The API would + // refuse the delete anyway, with an unhandled 500. + it("refuses to remove a gift card that has been charged", async () => { + const sdk = stubSdk() + + await expect( + removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gift-a", 2000, { payment_authorization: { status: "succeeded" } as never }), + ]), + paymentSessionId: "gift-a", + }) + ).rejects.toThrow(/already been charged/i) + expect(sdk.del).not.toHaveBeenCalled() + }) + + it("allows removing one whose authorization failed", async () => { + const sdk = stubSdk() + + await removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gift-a", 2000, { payment_authorization: { status: "failed" } as never }), + ]), + paymentSessionId: "gift-a", + }) + + expect(sdk.del).toHaveBeenCalledWith("gift-a") + }) +}) diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts new file mode 100644 index 00000000..3f2240ef --- /dev/null +++ b/packages/core-components/src/payment_sessions/index.ts @@ -0,0 +1,32 @@ +export { applyGiftCard } from "./applyGiftCard" +export { createPaymentSession } from "./createPaymentSession" +export type { PaymentSessionsState } from "./derivePaymentSessionsState" +export { derivePaymentSessionsState } from "./derivePaymentSessionsState" +export { findCurrentPaymentSession } from "./findCurrentPaymentSession" +export { findReusablePaymentSession } from "./findReusablePaymentSession" +export type { PaymentsModel } from "./getPaymentsModel" +export { getPaymentsModel } from "./getPaymentsModel" +export { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" +export { mapGiftCardErrors } from "./mapGiftCardErrors" +export { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +export type { PlaceOrderWithPaymentSessionsResult } from "./placeOrderWithPaymentSessions" +export { + DEFAULT_PLACEABLE_ATTEMPTS, + DEFAULT_PLACEABLE_INTERVAL_MS, + placeOrderWithPaymentSessions, +} from "./placeOrderWithPaymentSessions" +export { removeGiftCard } from "./removeGiftCard" +export type { + KnownPaymentSessionStatus, + KnownPaymentTransactionStatus, + PaymentSessionStatus, + PaymentTransactionStatus, + PlaceabilityError, +} from "./types" +export { + GIFT_CARD_SETTING_TYPE, + hasLiveAuthorization, + isGiftCardSession, + PAYMENT_TAKEN_SESSION_STATUSES, + TERMINAL_FAILURE_TRANSACTION_STATUSES, +} from "./types" diff --git a/packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts b/packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts new file mode 100644 index 00000000..55d51ef4 --- /dev/null +++ b/packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts @@ -0,0 +1,45 @@ +import type { Order } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" +import { hasLiveAuthorization } from "./types" + +interface InvalidateCurrentPaymentSessionParams + extends Pick { + order: Order +} + +/** + * Delete the session paying the difference, because the difference changed. + * + * `amount_cents` is set once and never updatable, so a session created against + * an older remainder is not merely stale — it is wrong. Left in place it would + * still read as the shopper's current selection (it is `unpaid` and unburnt), + * and at place time we would authorize an amount larger than what is owed. + * + * Only sessions that have taken nothing are deleted. One carrying a live + * authorization is left alone: the API refuses to delete a session with + * transactions attached — and surfaces that refusal as an unhandled 500 — and a + * sales-channel token cannot delete the authorization either. Money already + * taken is not ours to undo here. + * + * Failures are swallowed. This runs alongside a gift card operation the shopper + * asked for and can see the result of; turning a cleanup failure into a visible + * error would report the wrong thing. The stale session stays out of the + * selection anyway, because the amount check that follows it is server-side. + */ +export async function invalidateCurrentPaymentSession({ + accessToken, + interceptors, + order, +}: InvalidateCurrentPaymentSessionParams): Promise { + const current = findCurrentPaymentSession({ paymentSessions: order.payment_sessions }) + if (current == null || hasLiveAuthorization(current)) return + + const sdk = getSdk({ accessToken, interceptors }) + try { + await sdk.payment_sessions.delete(current.id) + } catch { + // Best effort — see above. + } +} diff --git a/packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts b/packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts new file mode 100644 index 00000000..0b45b4be --- /dev/null +++ b/packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" +import { mapGiftCardErrors } from "./mapGiftCardErrors" + +/** A refused gift card, exactly as the API sent it on 2026-08-25. */ +const REFUSED = { + errors: [ + { + title: "doesn't match any active gift card", + detail: "gift_card_code - doesn't match any active gift card", + code: "VALIDATION_ERROR", + source: { pointer: "/data/attributes/gift_card_code" }, + status: "422", + meta: { error: "invalid_gift_card" }, + }, + { + title: "can't be blank", + detail: "token - can't be blank", + code: "VALIDATION_ERROR", + source: { pointer: "/data/attributes/token" }, + status: "422", + meta: { error: "blank" }, + }, + ], +} + +describe("mapGiftCardErrors", () => { + // `detail` prefixes the attribute name, which is for an API client and not + // for a shopper. + it("uses the title rather than the attribute-prefixed detail", () => { + expect(mapGiftCardErrors(REFUSED)).toEqual([ + { + code: "VALIDATION_ERROR", + message: "doesn't match any active gift card", + field: "gift_card_code", + meta: { error: "invalid_gift_card" }, + }, + ]) + }) + + // The token error is a consequence of the session never being built, not + // something the shopper can act on. + it("drops the errors that are not about the code", () => { + expect(mapGiftCardErrors(REFUSED)).toHaveLength(1) + }) + + it("reads the errors through the response wrapper too", () => { + expect(mapGiftCardErrors({ response: { data: REFUSED } })[0]?.message).toBe( + "doesn't match any active gift card" + ) + }) + + // Better an unattributed message than silence. + it("keeps everything when nothing points at the code", () => { + const mapped = mapGiftCardErrors({ + errors: [{ title: "Something else", source: { pointer: "/data" } }], + }) + expect(mapped).toEqual([ + { code: "VALIDATION_ERROR", message: "Something else", field: "gift_card_code" }, + ]) + }) + + it.each([undefined, null, "boom", new Error("boom"), {}, { errors: "nope" }])( + "returns nothing for %s, which carries no API errors", + (error) => { + expect(mapGiftCardErrors(error)).toEqual([]) + } + ) +}) diff --git a/packages/core-components/src/payment_sessions/mapGiftCardErrors.ts b/packages/core-components/src/payment_sessions/mapGiftCardErrors.ts new file mode 100644 index 00000000..0d650f03 --- /dev/null +++ b/packages/core-components/src/payment_sessions/mapGiftCardErrors.ts @@ -0,0 +1,48 @@ +import { extractApiErrors } from "./apiErrors" +import type { PlaceabilityError } from "./types" + +/** The attribute a gift card failure is reported against. */ +const GIFT_CARD_FIELD = "gift_card_code" + +/** + * Turn a refused gift card operation into errors worth showing a shopper. + * + * **Why not just `error.message`.** On an SDK error that is the empty string — + * the API's wording lives only in the JSON:API `errors` array — so reading it + * renders an error box with nothing in it, which is barely better than the + * silence it replaced. + * + * **Why `title` and not `detail`.** The API sends both, and `detail` is + * `title` prefixed with the attribute name: `"gift_card_code - doesn't match + * any active gift card"`. The prefix is for an API client, not a shopper. + * (`mapPlaceabilityErrors` prefers `detail` because there the two are the same + * text and `detail` is the fuller one.) + * + * **Why the other entries are dropped.** A refused gift card comes back as + * *two* errors: the real one, and `"token - can't be blank"` — a consequence of + * the session never being built, not something the shopper did. Showing it + * would be actively misleading. Everything is kept when nothing points at + * `gift_card_code`, so an unrecognised failure is still reported rather than + * swallowed. + * + * The API collapses four causes — unknown code, expired, empty, bound to + * another market — into one message, so `meta.error` (`"invalid_gift_card"`) is + * as specific as this gets. + */ +export function mapGiftCardErrors(error: unknown): PlaceabilityError[] { + const errors = extractApiErrors(error) + if (errors.length === 0) return [] + + const giftCardErrors = errors.filter((apiError) => + apiError.source?.pointer?.endsWith(`/${GIFT_CARD_FIELD}`) + ) + const relevant = giftCardErrors.length > 0 ? giftCardErrors : errors + + return relevant.map((apiError) => ({ + code: apiError.code ?? "VALIDATION_ERROR", + message: + apiError.title ?? apiError.detail ?? "This gift card code could not be applied to the order.", + field: GIFT_CARD_FIELD, + ...(apiError.meta?.error != null ? { meta: { error: apiError.meta.error } } : {}), + })) +} diff --git a/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts b/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts new file mode 100644 index 00000000..b8b5a021 --- /dev/null +++ b/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts @@ -0,0 +1,36 @@ +import { extractApiErrors, fieldFromPointer } from "./apiErrors" +import type { PlaceabilityError } from "./types" + +/** + * Turn a 422 response from the `_placeable` trigger into one error per reason. + * + * The API answers a refused placement with a JSON:API `errors` array where each + * entry points at the attribute that failed: + * + * ```json + * { "code": "VALIDATION_ERROR", + * "detail": "Your order couldn't be placed because ...", + * "source": { "pointer": "/data/attributes/payment_action" }, + * "meta": { "error": "..." } } + * ``` + * + * One error per reason, rather than one concatenated message, so a consumer can + * address each individually instead of parsing prose. Populating `field` also + * keeps the existing non-blocking-error filter working, which exempts coupon + * and gift-card fields from blocking the place-order button. + * + * Be aware that *every* payment-rule failure — the order not being covered, a + * payment setting not being allowed — arrives as `field: "payment_action"`. + * They can only be told apart by their message. + */ +export function mapPlaceabilityErrors(error: unknown): PlaceabilityError[] { + const errors = extractApiErrors(error) + if (errors.length === 0) return [] + + return errors.map((apiError) => ({ + code: apiError.code ?? "VALIDATION_ERROR", + message: apiError.detail ?? apiError.title ?? "The order cannot be placed.", + field: fieldFromPointer(apiError.source?.pointer), + ...(apiError.meta?.error != null ? { meta: { error: apiError.meta.error } } : {}), + })) +} diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts new file mode 100644 index 00000000..e011e27c --- /dev/null +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts @@ -0,0 +1,472 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { placeOrderWithPaymentSessions } from "./placeOrderWithPaymentSessions" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ORDER_ID = "order-1" +const ACCESS_TOKEN = "token" +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + amount_cents: 7100, + payment_setting: MANUAL, + ...overrides, + } as PaymentSession +} + +function giftCard(id: string, overrides: Partial = {}): PaymentSession { + return session({ id, payment_setting: GIFT_CARD as never, amount_cents: 1000, ...overrides }) +} + +function order(sessions: PaymentSession[], status: Order["status"] = "pending"): Order { + return { + id: ORDER_ID, + type: "orders", + status, + total_amount_with_taxes_cents: 7100, + payment_sessions: sessions, + } as Order +} + +/** A 422 from the `_placeable` trigger, as the API shapes it. */ +function refusal(detail = "The payment doesn't cover the order.") { + return { + errors: [ + { + code: "VALIDATION_ERROR", + detail, + source: { pointer: "/data/attributes/payment_action" }, + meta: { error: "payment_action" }, + }, + ], + } +} + +interface SdkStub { + retrieve: ReturnType + _placeable: ReturnType + _place: ReturnType + createAuthorization: ReturnType +} + +/** + * The order the placeability loop reads at the top of every attempt. Defaults + * to the same sessions the call was given, with no authorizations on them — + * i.e. nothing in flight, so the loop goes straight to `_placeable`. + */ +function stubSdk(retrieved: Order = order([], "pending")): SdkStub { + const stub: SdkStub = { + retrieve: vi.fn().mockResolvedValue(retrieved), + _placeable: vi.fn().mockResolvedValue(order([], "pending")), + _place: vi.fn().mockResolvedValue(order([], "placed")), + createAuthorization: vi.fn().mockResolvedValue({ id: "auth-1" }), + } + getSdkMock.mockReturnValue({ + orders: { + retrieve: stub.retrieve, + _placeable: stub._placeable, + _place: stub._place, + relationship: vi.fn(), + }, + payment_authorizations: { create: stub.createAuthorization }, + payment_sessions: { relationship: vi.fn((id: string) => ({ id, type: "payment_sessions" })) }, + }) + return stub +} + +/** A session carrying an authorization in `status`. */ +function authorized(status: string, overrides: Partial = {}): PaymentSession { + return session({ payment_authorization: { status } as never, ...overrides }) +} + +/** Keeps the retry loop synchronous. The delay is asserted separately. */ +const NO_WAIT = { attempts: 5, intervalMs: 0 } + +/** Session ids the authorizations were created for, in order. */ +function authorizedIds(sdk: SdkStub): string[] { + return sdk.createAuthorization.mock.calls.map((call) => call[0].payment_session.id) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("placeOrderWithPaymentSessions", () => { + it("authorizes, checks placeability, then places", async () => { + const sdk = stubSdk() + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["session-1"]) + expect(sdk._placeable).toHaveBeenCalledWith(ORDER_ID) + expect(sdk._place).toHaveBeenCalledWith(ORDER_ID) + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + // auto_place places the order inside the authorization job, so it can be + // placed before we ever look. + it("skips _place when the order was already placed by auto_place", async () => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order([], "placed")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._place).not.toHaveBeenCalled() + expect(result).toMatchObject({ placed: true, timedOut: false }) + }) + + it("retries a placeability refusal and succeeds once the authorization lands", async () => { + const sdk = stubSdk() + sdk._placeable + .mockRejectedValueOnce(refusal()) + .mockRejectedValueOnce(refusal()) + .mockResolvedValue(order([], "pending")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(3) + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + it("reports the last refusal once the attempts run out", async () => { + const sdk = stubSdk() + sdk._placeable.mockRejectedValue(refusal("Still not covered.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + attempts: 3, + intervalMs: 0, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(3) + expect(sdk._place).not.toHaveBeenCalled() + expect(result.placed).toBe(false) + expect(result.timedOut).toBe(true) + expect(result.errors).toEqual([ + { + code: "VALIDATION_ERROR", + message: "Still not covered.", + field: "payment_action", + meta: { error: "payment_action" }, + }, + ]) + }) + + it("waits between attempts", async () => { + const sdk = stubSdk() + sdk._placeable.mockRejectedValue(refusal()) + const started = Date.now() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + attempts: 3, + intervalMs: 20, + }) + + // Two gaps between three attempts, and no trailing wait after the last. + expect(Date.now() - started).toBeGreaterThanOrEqual(40) + }) + + it("rethrows an error that is not a placeability refusal", async () => { + const sdk = stubSdk() + sdk._placeable.mockRejectedValue(new Error("Unauthorized")) + + await expect( + placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + ).rejects.toThrow("Unauthorized") + expect(sdk._placeable).toHaveBeenCalledTimes(1) + }) + + describe("gift cards", () => { + // Each authorization shrinks what the next session may take, and a gift + // card charged after a failed method payment leaves the shopper's balance + // spent on an order that never got placed. + it("authorizes every gift card before the session paying the difference", async () => { + const sdk = stubSdk() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + session({ id: "method", amount_cents: 5100 }), + giftCard("gift-a", { created_at: "2026-08-20T10:00:00Z" }), + giftCard("gift-b", { created_at: "2026-08-20T11:00:00Z" }), + ]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-a", "gift-b", "method"]) + }) + + // Gift cards can cover the order outright, and then there is no other + // session at all. + it("places an order covered entirely by gift cards", async () => { + const sdk = stubSdk() + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", { amount_cents: 7100 })]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-a"]) + expect(result.placed).toBe(true) + }) + + // Carrying on would charge more cards for an order that is not going to be + // placed. Nothing is rolled back: this iteration implements no refund. + it("stops at the first authorization failure and reports it", async () => { + const sdk = stubSdk() + sdk.createAuthorization.mockResolvedValueOnce({ id: "auth-a" }).mockRejectedValueOnce({ + errors: [{ code: "VALIDATION_ERROR", detail: "Gift card is empty." }], + }) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + session({ id: "method" }), + giftCard("gift-a", { created_at: "2026-08-20T10:00:00Z" }), + giftCard("gift-b", { created_at: "2026-08-20T11:00:00Z" }), + ]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-a", "gift-b"]) + expect(sdk._placeable).not.toHaveBeenCalled() + expect(result).toMatchObject({ placed: false, timedOut: false }) + expect(result.errors[0]?.message).toBe("Gift card is empty.") + }) + + it("skips gift cards that are already authorized", async () => { + const sdk = stubSdk() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gift-done", { payment_authorization: { status: "succeeded" } as never }), + giftCard("gift-todo"), + ]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-todo"]) + }) + }) + + describe("authorization of the session paying the difference", () => { + // Creating a second authorization over one still in flight risks taking the + // money twice. + it.each(["pending", "processing", "requires_action", "succeeded"])( + "does not create a second one when the existing authorization is %s", + async (status) => { + const sdk = stubSdk() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session({ payment_authorization: { status } as never })]), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).not.toHaveBeenCalled() + } + ) + + // A burnt session is not the selection any more — the radio reads as + // unchecked and the shopper picks again, which creates a fresh session. So + // nothing is authorized here, and nothing is charged twice. + it.each(["declined", "failed", "canceled", "expired"])( + "authorizes nothing when the only session's authorization is %s", + async (status) => { + const sdk = stubSdk() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session({ payment_authorization: { status } as never })]), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).not.toHaveBeenCalled() + } + ) + + it("authorizes nothing for an order with no sessions", async () => { + const sdk = stubSdk() + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([]), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).not.toHaveBeenCalled() + expect(result.placed).toBe(true) + }) + }) + + describe("waiting for the authorizations to settle", () => { + // Asking `_placeable` while the money is still moving produces a 422 that + // means nothing: it says the order is not covered because the payment has + // not landed yet, not because it will not. + it("does not ask _placeable while an authorization is in flight", async () => { + const sdk = stubSdk() + sdk.retrieve + .mockResolvedValueOnce(order([authorized("pending")])) + .mockResolvedValueOnce(order([authorized("processing")])) + .mockResolvedValue(order([authorized("succeeded")])) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk.retrieve).toHaveBeenCalledTimes(3) + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + // `requires_action` waits for the shopper, not for the server, so polling + // it would spend the budget on something no amount of waiting resolves. + it("does not wait on an authorization that requires shopper action", async () => { + const sdk = stubSdk(order([authorized("requires_action")])) + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + }) + + // Its refusal is the only message we will have to show, so the budget + // running out must not mean returning nothing. + it("asks _placeable on the last attempt even with an authorization in flight", async () => { + const sdk = stubSdk(order([authorized("pending")])) + sdk._placeable.mockRejectedValue(refusal("Still not covered.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + attempts: 3, + intervalMs: 0, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result.timedOut).toBe(true) + expect(result.errors[0]?.message).toBe("Still not covered.") + }) + + // A settled failure is a verdict. Retrying would only delay the same + // answer, and `timedOut` would misreport it as a payment still in progress. + it("reports a failed authorization at once, without timing out", async () => { + const sdk = stubSdk(order([authorized("declined")])) + sdk._placeable.mockRejectedValue(refusal("The payment doesn't cover the order.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ placed: false, timedOut: false }) + expect(result.errors[0]?.message).toBe("The payment doesn't cover the order.") + }) + + // A burnt session stays on the order after the shopper re-selects. Reading + // it as this attempt's verdict would end the loop before the authorization + // just created has had a chance to settle. + it("ignores a failed authorization on a session that is not paying", async () => { + const sdk = stubSdk() + const burnt = session({ id: "burnt", payment_authorization: { status: "declined" } as never }) + sdk.retrieve + .mockResolvedValueOnce(order([burnt, authorized("pending")])) + .mockResolvedValue(order([burnt, authorized("succeeded")])) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([burnt, session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ placed: true, timedOut: false }) + }) + + it("recognises an order auto_place already placed, before asking _placeable", async () => { + const sdk = stubSdk(order([], "placed")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).not.toHaveBeenCalled() + expect(sdk._place).not.toHaveBeenCalled() + expect(result).toMatchObject({ placed: true, timedOut: false }) + }) + }) + + describe("the window between _placeable and _place", () => { + // auto_place can fire in that window. `_place` then refuses an order that + // was placed successfully, and reporting the refusal would tell the shopper + // their payment failed on an order that is paid for. + it("treats a _place refusal on an already placed order as success", async () => { + const sdk = stubSdk() + sdk.retrieve + .mockResolvedValueOnce(order([], "pending")) + .mockResolvedValue(order([], "placed")) + sdk._place.mockRejectedValue(new Error("Cannot place a placed order.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + it("still reports a _place refusal when the order really is not placed", async () => { + const sdk = stubSdk() + sdk._place.mockRejectedValue(refusal("The payment doesn't cover the order.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + attempts: 2, + intervalMs: 0, + }) + + expect(sdk._place).toHaveBeenCalledTimes(2) + expect(result).toMatchObject({ placed: false, timedOut: true }) + expect(result.errors[0]?.message).toBe("The payment doesn't cover the order.") + }) + }) +}) diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts new file mode 100644 index 00000000..25e92910 --- /dev/null +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts @@ -0,0 +1,261 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" +import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +import { + hasAuthorizationInFlight, + hasFailedAuthorization, + hasLiveAuthorization, + type PlaceabilityError, +} from "./types" + +/** + * Attempts before the placeability check gives up. + * + * Each attempt starts by reading the order back, so an attempt spent waiting + * for an authorization costs one `GET` rather than a refused `PATCH`. That + * makes attempts cheap enough to run more of them, more closely spaced, than + * when every one of them was a `_placeable` call. + */ +export const DEFAULT_PLACEABLE_ATTEMPTS = 8 +/** Delay between placeability attempts, in milliseconds. */ +export const DEFAULT_PLACEABLE_INTERVAL_MS = 500 + +/** Order includes the placeability loop needs to read authorization states. */ +const AUTHORIZATION_INCLUDES = ["payment_sessions.payment_authorization"] + +interface PlaceOrderWithPaymentSessionsParams + extends Pick { + /** + * The order as last fetched, with `payment_sessions.payment_setting` and + * `payment_sessions.payment_authorization` included. Its sessions are what + * gets authorized, and in which order. + */ + order: Order + /** Placeability attempts before giving up. Defaults to 5. */ + attempts?: number + /** Delay between attempts, in milliseconds. Defaults to 1000. */ + intervalMs?: number +} + +export interface PlaceOrderWithPaymentSessionsResult { + /** True when the order is placed — whether by us or by `auto_place`. */ + placed: boolean + /** The order as the API last returned it. */ + order?: Order + /** Reasons the order could not be placed, or why an authorization failed. */ + errors: PlaceabilityError[] + /** + * True when the placeability attempts ran out. The payment may still succeed + * later, so this must never be reported as a payment failure. + */ + timedOut: boolean +} + +/** + * Take payment for an order on the `payment_sessions` model and place it. + * + * The sequence is: authorize the gift cards, then authorize the session paying + * the difference, then poll `_placeable`, then `_place`. + * + * **Why gift cards first.** Nothing server-side enforces the order — it is a + * client-side safety property. Each authorization shrinks what the next session + * is allowed to take, and a gift card that is charged after a failed method + * payment would leave the shopper's balance spent on an order that never got + * placed. + * + * **Why the difference may be absent.** Gift cards can cover the order + * entirely, in which case there is no other session at all and this goes + * straight from the gift cards to the placeability check. + * + * **Why the authorizations are created here and not at selection.** An + * authorization is the record proving money was taken. Creating it when the + * shopper picks a radio button, or types a gift card code, would take their + * money on selection and make changing their mind a refund. Creating it here + * keeps both reversible: a gift card can be removed for free right up to this + * point. + * + * **Why `_placeable` is retried instead of reported.** Authorizing is + * asynchronous — it runs in a background job — so a check taken while the money + * is still being taken legitimately fails with "the payment doesn't cover the + * required percentage". Reporting that straight away would tell the shopper + * their payment failed a second before it succeeded. Only an error that + * survives the last attempt is real. A failed `_placeable` persists nothing, so + * retrying is side-effect free despite the PATCH verb. + * + * **Why each attempt reads the order first.** The authorization states are the + * only thing that says whether a refusal is worth waiting on, and `_placeable` + * cannot see them — so asking it while an authorization is in flight produces a + * guaranteed 422 that means nothing. Reading the order first turns that wasted + * attempt into a cheap `GET` and buys three things: + * + * - an authorization that has already **failed** ends the loop at once, instead + * of spending the whole budget waiting for a verdict that has arrived; + * - an order `auto_place` has already placed is recognised wherever in the + * sequence it happens, not only at the point the old code looked; + * - the cost is flat in the number of sessions. Every authorization state + * arrives in the same `GET`, so orders paying with several gift cards poll no + * harder than one paying with a single method — which is what a per-session + * wait, concurrent or not, could not promise. + * + * **The last attempt always asks.** When the budget runs out with an + * authorization still in flight, `_placeable` is called anyway: its refusal is + * the only message the API will give us, and returning none would leave the + * shopper with a failed place and nothing on screen. + * + * **A failed authorization still gets its message from `_placeable`.** A + * Payment Authorization carries no error attribute — only `status`, balances + * and the gateway's raw `response_data` — so there is nothing better to report, + * and inventing copy here would put payment wording in a package that has no + * business owning it. What changes is the timing: the refusal is returned as + * soon as the authorization settles, with `timedOut: false`, because a settled + * failure is a real answer rather than latency. + * + * **There is no client-side coverage gate.** Coverage is enforced by a payment + * rule whose threshold an organization can change — it can be lowered to accept + * part payments — so only the server knows what "covered" means for this order. + * + * **Nothing is ever rolled back.** If an authorization fails partway, the ones + * already taken stay taken: this iteration implements no refund, and the gift + * card list is itself the recovery surface — after a reload the shopper sees + * which cards were charged and what is left to pay. On a timeout nothing is + * touched at all, because the payment may well have succeeded. + */ +export async function placeOrderWithPaymentSessions({ + accessToken, + interceptors, + order, + attempts = DEFAULT_PLACEABLE_ATTEMPTS, + intervalMs = DEFAULT_PLACEABLE_INTERVAL_MS, +}: PlaceOrderWithPaymentSessionsParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + const state = derivePaymentSessionsState(order) + + // The sessions paying for this order, by the same derivation the rest of the + // payment UI uses. Kept as a set because the placeability loop has to tell + // them apart from sessions merely sitting on the order: an authorization that + // failed on an earlier attempt stays there, and reading it as this attempt's + // verdict would end the loop before the authorization just created has had a + // chance to settle. + const payingSessions = [...state.giftCardSessions, state.currentPaymentSession].filter( + (session): session is PaymentSession => session != null + ) + const payingSessionIds = new Set(payingSessions.map((session) => session.id)) + + // Gift cards first, then the difference. Sequential, not concurrent: each + // authorization changes what the next one is allowed to take. + const toAuthorize = payingSessions.filter(needsAuthorization) + + for (const session of toAuthorize) { + try { + await sdk.payment_authorizations.create({ + payment_session: sdk.payment_sessions.relationship(session.id), + }) + } catch (error) { + // Stop at the first failure. Carrying on would charge more cards for an + // order that is not going to be placed. + const errors = mapPlaceabilityErrors(error) + if (errors.length === 0) throw error + return { placed: false, errors, timedOut: false } + } + } + + let lastErrors: PlaceabilityError[] = [] + + for (let attempt = 1; attempt <= attempts; attempt++) { + const isLastAttempt = attempt === attempts + const current = await sdk.orders.retrieve(order.id, { include: AUTHORIZATION_INCLUDES }) + + // `auto_place` on the Payment Setting places the order inside the + // authorization job, so it can be placed before we ever ask to place it. + if (current.status === "placed") { + return { placed: true, order: current, errors: [], timedOut: false } + } + + const sessions = (current.payment_sessions ?? []).filter((session) => + payingSessionIds.has(session.id) + ) + const failed = sessions.some(hasFailedAuthorization) + + // Nothing `_placeable` says while the money is still moving is worth + // reporting, so do not spend a PATCH asking — unless this is the last + // attempt, whose refusal is the only message we will have to show. + if (!failed && !isLastAttempt && sessions.some(hasAuthorizationInFlight)) { + await sleep(intervalMs) + continue + } + + try { + const checked = await sdk.orders._placeable(order.id) + + // `_placeable` returns 200 for a placed order — `ensure_pending` only + // promotes drafts, it does not reject anything else — so a placed order + // here is a success, not a race to recover from. + if (checked.status === "placed") { + return { placed: true, order: checked, errors: [], timedOut: false } + } + + return await placeOrRecover(sdk, order.id) + } catch (error) { + lastErrors = mapPlaceabilityErrors(error) + // An error we cannot read as a placeability refusal is not something + // waiting will fix — surface it instead of burning the attempts. + if (lastErrors.length === 0) throw error + // A failed authorization is a verdict, not latency. Retrying would only + // delay the same answer, and `timedOut` would misreport a settled + // refusal as a payment that might still succeed. + if (failed) return { placed: false, errors: lastErrors, timedOut: false } + if (attempt < attempts) await sleep(intervalMs) + } + } + + return { placed: false, errors: lastErrors, timedOut: true } +} + +/** + * `_place` the order, treating "already placed" as the success it is. + * + * `auto_place` can fire in the window between the placeability check passing + * and this call. `_place` then refuses an order that was placed successfully, + * and — being a state error rather than a payment rule — the refusal does not + * map to a placeability error, so it would be rethrown as a hard failure on an + * order that is paid for and placed. Reading the order back is the only way to + * tell that apart from a genuine refusal. + */ +async function placeOrRecover( + sdk: ReturnType, + orderId: string +): Promise { + try { + const placed = await sdk.orders._place(orderId) + return { placed: placed.status === "placed", order: placed, errors: [], timedOut: false } + } catch (error) { + const current = await sdk.orders.retrieve(orderId).catch(() => undefined) + if (current?.status === "placed") { + return { placed: true, order: current, errors: [], timedOut: false } + } + // Not placed after all: let the caller's loop map and report it as before. + throw error + } +} + +/** + * Skip the authorization when the session already has one that has not failed. + * A `pending` or `processing` authorization is still in flight, and creating a + * second one risks taking the money twice. + * + * Sessions whose authorization *did* fail never reach here: a burnt session is + * excluded from both the current selection and the gift card list, so the + * shopper re-selects and gets a fresh one. This stays as a guard rather than + * because the case is expected — double-charging is the failure mode it + * prevents, and that is worth a redundant check. + */ +function needsAuthorization(paymentSession: PaymentSession): boolean { + return !hasLiveAuthorization(paymentSession) +} + +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/removeGiftCard.ts b/packages/core-components/src/payment_sessions/removeGiftCard.ts new file mode 100644 index 00000000..17efaf7d --- /dev/null +++ b/packages/core-components/src/payment_sessions/removeGiftCard.ts @@ -0,0 +1,50 @@ +import type { Order } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" +import { hasLiveAuthorization } from "./types" + +interface RemoveGiftCardParams extends Pick { + order: Order + /** Id of the gift card Payment Session to remove. */ + paymentSessionId: string +} + +/** + * Take a gift card back off an order by deleting its Payment Session. + * + * Only possible while the card has not been charged. Authorizing a gift card + * debits the balance immediately — the setting forces auto-capture, so the + * session goes straight to `paid` — and from there only a refund could return + * it, which this iteration does not implement. The API would refuse the delete + * anyway, with an unhandled 500, and a sales-channel token cannot delete the + * authorization to clear the way. + * + * Deleting an unauthorized session touches no balance: nothing was taken, so + * there is nothing to give back. + * + * Removing a gift card raises the remainder, which **invalidates the session + * paying the difference** for the same reason applying one does — its amount is + * immutable and now too small. + */ +export async function removeGiftCard({ + accessToken, + interceptors, + order, + paymentSessionId, +}: RemoveGiftCardParams): Promise { + const session = (order.payment_sessions ?? []).find( + (candidate) => candidate.id === paymentSessionId + ) + + if (session != null && hasLiveAuthorization(session)) { + throw new Error( + "This gift card has already been charged and can no longer be removed from the order." + ) + } + + const sdk = getSdk({ accessToken, interceptors }) + await sdk.payment_sessions.delete(paymentSessionId) + + await invalidateCurrentPaymentSession({ accessToken, interceptors, order }) +} diff --git a/packages/core-components/src/payment_sessions/types.ts b/packages/core-components/src/payment_sessions/types.ts new file mode 100644 index 00000000..a8b0d861 --- /dev/null +++ b/packages/core-components/src/payment_sessions/types.ts @@ -0,0 +1,173 @@ +import type { PaymentSession } from "@commercelayer/sdk" + +/** + * Status values for the `payment_sessions` payment model. + * + * The SDK types every one of these as a bare `string`, so these unions are + * transcribed by hand from the AASM state machines in `core-api`. Each is + * widened with `(string & {})` so an unknown value stays assignable: the + * server can add a state without breaking consumers, and every branch that + * decides on a status is required to have a `default`. + * + * **Re-check these against `core-api` whenever the SDK is upgraded.** + */ + +/** Any value the API may send, while still autocompleting the known ones. */ +type Widen = T | (string & {}) + +/** + * `app/models/payment_session.rb:21-28` — initial state is `unpaid`. + * Note there are no per-state timestamp columns on payment sessions, unlike + * transactions, so `status` is the only way to read a session's state. + */ +export type KnownPaymentSessionStatus = + | "unpaid" + | "authorized" + | "voided" + | "paid" + | "partially_paid" + | "refunded" + | "partially_refunded" + +export type PaymentSessionStatus = Widen + +/** + * The subset of session states that count toward the order's paid amount. + * `app/models/payment_session.rb:15` (`PAYMENT_TAKEN_STATES`). + */ +export const PAYMENT_TAKEN_SESSION_STATUSES = [ + "authorized", + "paid", + "partially_paid", +] as const satisfies readonly KnownPaymentSessionStatus[] + +/** + * `app/models/payment_transaction.rb:22-31` — initial state is `pending`. + * Shared by all four STI subclasses: payment authorizations, captures, voids + * and refunds run the same machine. + */ +export type KnownPaymentTransactionStatus = + | "pending" + | "requires_action" + | "processing" + | "succeeded" + | "declined" + | "failed" + | "canceled" + | "expired" + +export type PaymentTransactionStatus = Widen + +/** + * Transaction states a session cannot recover from. A session carrying an + * authorization in one of these is burnt: it stays `unpaid` forever, because + * only a `succeeded` authorization transitions the session. + */ +export const TERMINAL_FAILURE_TRANSACTION_STATUSES = [ + "declined", + "failed", + "canceled", + "expired", +] as const satisfies readonly KnownPaymentTransactionStatus[] + +/** + * Transaction states the API will leave on its own, without anyone acting. + * Authorizing runs in a background job, so an authorization sits here between + * the `POST` that creates it and the money actually being taken. + * + * `requires_action` is deliberately **not** here. It is waiting for the + * *shopper* — a 3DS challenge or an equivalent redirect — so no amount of + * polling resolves it, and treating it as in flight would spend the whole + * retry budget on something that needs a flow this iteration does not + * implement. It is not a failure either (see + * `TERMINAL_FAILURE_TRANSACTION_STATUSES`), so it falls through to the + * placeability check and is reported as the API describes it. + */ +export const IN_FLIGHT_TRANSACTION_STATUSES = [ + "pending", + "processing", +] as const satisfies readonly KnownPaymentTransactionStatus[] + +/** + * One reason the API gave for refusing to place an order, mapped out of a 422 + * JSON:API error object. + * + * Note that every payment-rule failure — insufficient coverage, a payment + * setting the order is not allowed to use — arrives with the same + * `field: "payment_action"`, distinguishable only by `message`. + */ +export interface PlaceabilityError { + /** JSON:API error `code`, e.g. `"VALIDATION_ERROR"`. */ + code: string + /** Human-readable reason. For payment rules this is the rule's message. */ + message: string + /** + * Last segment of `source.pointer`, so `/data/attributes/payment_action` + * becomes `"payment_action"`. Base errors (`/data`) become `"base"`. + */ + field?: string + /** Symbolic reason from `meta.error`, when the API sends one. */ + meta?: { error: string } +} + +/** + * The Payment Setting type a gift card is spent through. + * + * Gift cards are the one setting that is *additive* rather than an alternative: + * an order carries zero or more of them plus at most one other session for the + * difference. Everything that separates the two families keys off this literal. + */ +export const GIFT_CARD_SETTING_TYPE = "payment_setting_gift_cards" + +/** True when this session spends a gift card rather than paying the difference. */ +export function isGiftCardSession(session: PaymentSession): boolean { + return session.payment_setting?.type === GIFT_CARD_SETTING_TYPE +} + +/** + * True when the session is carrying a Payment Authorization that has not failed + * — i.e. money is either taken or in flight. + * + * Note this is *not* "paid": an authorization can still be `pending` while its + * background job runs. What it rules out is a session the shopper may still + * change freely. + */ +export function hasLiveAuthorization(session: PaymentSession): boolean { + const status = session.payment_authorization?.status + if (status == null) return false + return !TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) +} + +/** + * True when the session's authorization is still being worked on server-side. + * + * This is the state the placeability check cannot see through: the money is + * neither taken nor refused, so a refusal read while this holds says nothing + * about whether the payment will succeed. + */ +export function hasAuthorizationInFlight(session: PaymentSession): boolean { + const status = session.payment_authorization?.status + if (status == null) return false + return IN_FLIGHT_TRANSACTION_STATUSES.includes( + status as (typeof IN_FLIGHT_TRANSACTION_STATUSES)[number] + ) +} + +/** + * True when the session's authorization reached a state it cannot leave. + * + * Note the API exposes no reason for it: a Payment Authorization carries only + * `status`, the balances and the gateway's raw `response_data` + * (`config/attributes/payment_authorization.yml` in `core-api`). So this + * answers *whether* waiting is pointless, never *why* the payment failed — + * the message still has to come from the API's own refusal. + */ +export function hasFailedAuthorization(session: PaymentSession): boolean { + const status = session.payment_authorization?.status + if (status == null) return false + return TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) +} diff --git a/packages/core-components/src/sdk/getSdk.spec.ts b/packages/core-components/src/sdk/getSdk.spec.ts index 21229d32..12b4488b 100644 --- a/packages/core-components/src/sdk/getSdk.spec.ts +++ b/packages/core-components/src/sdk/getSdk.spec.ts @@ -49,6 +49,7 @@ describe("getSdk", () => { expect(CommerceLayer).toHaveBeenCalledWith({ accessToken: "fake-token", organization: "my-org", + apiVersion: "2026-05", }) expect(result).toBe(mockSdkInstance) }) diff --git a/packages/core-components/src/sdk/index.ts b/packages/core-components/src/sdk/index.ts index 6af69396..c07c7fe9 100644 --- a/packages/core-components/src/sdk/index.ts +++ b/packages/core-components/src/sdk/index.ts @@ -4,9 +4,26 @@ import { type JWTWebApp, jwtDecode, } from "@commercelayer/js-auth" -import type { CommerceLayerClient, ErrorObj, RequestObj, ResponseObj } from "@commercelayer/sdk" +import type { + ApiVersion, + CommerceLayerClient, + ErrorObj, + RequestObj, + ResponseObj, +} from "@commercelayer/sdk" import { CommerceLayer as Sdk } from "@commercelayer/sdk" +/** + * The API version every request is pinned to. + * + * The SDK types `apiVersion` as optional and builds unversioned URLs when it is + * omitted, so leaving it out silently changes which API surface we talk to. Kept as + * an explicit literal rather than derived from `API_SUPPORTED_VERSIONS`: the types are + * generated for one version, so an SDK bump and a version change should be reviewed + * together instead of one dragging the other along. + */ +const API_VERSION: ApiVersion = "2026-05" + type RequestInterceptor = (request: RequestObj) => RequestObj | Promise type ResponseInterceptor = (response: ResponseObj) => ResponseObj | Promise type ErrorInterceptor = (error: ErrorObj) => ErrorObj | Promise @@ -35,7 +52,11 @@ export function getSdk({ }): CommerceLayerClient { const { payload } = jwtDecode(accessToken) const { organization } = payload as JWTIntegration | JWTWebApp | JWTSalesChannel - const sdk = Sdk({ accessToken, organization: organization.slug }) + const sdk = Sdk({ + accessToken, + organization: organization.slug, + apiVersion: API_VERSION, + }) if (interceptors?.request != null) { sdk.addRequestInterceptor(interceptors.request.onSuccess, interceptors.request.onFailure) } diff --git a/packages/docs/package.json b/packages/docs/package.json index 09a4564b..f468120e 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -18,7 +18,13 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", + "@babel/core": "^7.29.7", + "@babel/preset-env": "^7.29.7", "@commercelayer/js-auth": "^8.0.0", + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75", + "@mdx-js/react": "^3.1.1", + "@storybook/addon-actions": "^9.0.8", + "@storybook/addon-backgrounds": "^9.0.8", "@storybook/addon-docs": "^10.5.3", "@storybook/addon-links": "^10.5.3", "@storybook/addon-mcp": "^0.7.0", diff --git a/packages/react-components/_vitest.config.mts b/packages/react-components/_vitest.config.mts index 64608a17..a2766ba4 100644 --- a/packages/react-components/_vitest.config.mts +++ b/packages/react-components/_vitest.config.mts @@ -6,7 +6,9 @@ import { defineConfig } from "vitest/config" export default defineConfig({ resolve: { alias: { - "@commercelayer/react-hooks-components": path.resolve("../react-hooks-components/src/index.ts"), + "@commercelayer/react-hooks-components": path.resolve( + "../react-hooks-components/src/index.ts" + ), "@commercelayer/core-components": path.resolve("../core-components/src/index.ts"), "#components": path.resolve(__dirname, "src/components"), "#components/auth": path.resolve(__dirname, "src/components/auth"), diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 96583980..4601a2b3 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -64,7 +64,7 @@ "@commercelayer/core-components": "workspace:*", "@commercelayer/organization-config": "^2.8.4", "@commercelayer/react-hooks-components": "workspace:*", - "@commercelayer/sdk": "8.0.0-beta.11", + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75", "@iframe-resizer/parent": "^5.5.9", "@stripe/react-stripe-js": "^6.8.1", "@stripe/stripe-js": "^9.10.0", diff --git a/packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx b/packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx new file mode 100644 index 00000000..8f09d2bd --- /dev/null +++ b/packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx @@ -0,0 +1,67 @@ +import type { Order } from "@commercelayer/sdk" +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it, vi } from "vitest" +import GiftCardOrCouponForm from "#components/gift_cards/GiftCardOrCouponForm" +import GiftCardOrCouponInput from "#components/gift_cards/GiftCardOrCouponInput" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +vi.mock("rapid-form", () => ({ + useRapidForm: () => ({ refValidation: vi.fn(), values: {} }), +})) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } + +function Wrapper({ children, order }: { children: ReactNode; order: Partial }) { + return ( + + {children} + + ) +} + +function renderForm(order: Partial, codeType?: "gift_card_code" | "coupon_code") { + return render( + + + + + + ) +} + +const inputName = () => (screen.getByTestId("input") as HTMLInputElement).name + +describe("GiftCardOrCouponForm on the payment_sessions model", () => { + // On this model a gift card is spent by creating a Payment Session against a + // gift-card Payment Setting, so it belongs among the payment methods. The + // order-level code field would apply a gift card no session reflects. + it("offers only the coupon", () => { + renderForm({ id: "order-1", available_payment_settings: [MANUAL] } as never) + expect(inputName()).toBe("coupon_code") + }) + + it("overrides an explicit gift_card_code request", () => { + renderForm({ id: "order-1", available_payment_settings: [MANUAL] } as never, "gift_card_code") + expect(inputName()).toBe("coupon_code") + }) + + // A leftover gift card code from the older model must not hide the coupon + // form, since the requested type is no longer what gets rendered. + it("still renders when the order carries a stale gift card code", () => { + renderForm( + { + id: "order-1", + available_payment_settings: [MANUAL], + gift_card_code: "OLD-CARD", + } as never, + "gift_card_code" + ) + expect(inputName()).toBe("coupon_code") + }) + + it("leaves the payment_source model untouched", () => { + renderForm({ id: "order-1", available_payment_methods: [{ id: "pm-1" }] } as never) + expect(inputName()).toBe("gift_card_or_coupon_code") + }) +}) diff --git a/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx b/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx new file mode 100644 index 00000000..7290a725 --- /dev/null +++ b/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx @@ -0,0 +1,45 @@ +import type { Order } from "@commercelayer/sdk" +import { renderHook } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it } from "vitest" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import { usePaymentsModel } from "#hooks/usePaymentsModel" + +function wrapper(order?: Partial | null) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const SETTING = { id: "ps-1", type: "payment_setting_manuals" } +const METHOD = { id: "pm-1", payment_source_type: "stripe_payments" } + +// The derivation rules themselves are covered by getPaymentsModel's own spec +// in core-components. What is left to prove here is the binding: that the hook +// reads OrderContext and reports what that function says. +describe("usePaymentsModel", () => { + it("is undetermined until the order has loaded", () => { + const { result } = renderHook(() => usePaymentsModel(), { wrapper: wrapper(null) }) + expect(result.current).toBe("undetermined") + }) + + it("reports the model of the order in context", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ id: "order-1", available_payment_settings: [SETTING] } as never), + }) + expect(result.current).toBe("payment_sessions") + }) + + it("applies the precedence rule through the shared function", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ + id: "order-1", + available_payment_settings: [SETTING], + available_payment_methods: [METHOD], + } as never), + }) + expect(result.current).toBe("payment_sessions") + }) +}) diff --git a/packages/react-components/specs/orders/order-payment-settings-include.spec.tsx b/packages/react-components/specs/orders/order-payment-settings-include.spec.tsx new file mode 100644 index 00000000..8620e391 --- /dev/null +++ b/packages/react-components/specs/orders/order-payment-settings-include.spec.tsx @@ -0,0 +1,70 @@ +import { render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { Order } from "#components/orders/Order" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import CommerceLayerContext from "#context/CommerceLayerContext" + +const { retrieveMock } = vi.hoisted(() => ({ retrieveMock: vi.fn() })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSdk: vi.fn().mockReturnValue({ + orders: { retrieve: retrieveMock }, + }), + } +}) + +beforeEach(() => { + vi.clearAllMocks() + retrieveMock.mockResolvedValue({ id: "order-1", type: "orders", status: "pending" }) +}) + +describe(" order fetch", () => { + // The whole Payments Model detection rests on this: an order that was never + // asked for `available_payment_settings` is indistinguishable from an order + // on the older model, and every consumer would silently take the wrong branch. + it("asks for available_payment_settings even with no payment components mounted", async () => { + render( + + + + + + ) + + await waitFor(() => { + expect(retrieveMock).toHaveBeenCalled() + }) + + const params = retrieveMock.mock.calls.at(-1)?.[1] + expect(params?.include).toContain("available_payment_settings") + }) + + // The other half: reading a selection back needs the session's setting, and + // telling a live session from a burnt one needs its authorization. These are + // registered by and must reach the *initial* fetch — + // registering an include after the order has loaded does not refetch, so a + // late arrival would mean the data never comes. + it("asks for the nested session relationships when is mounted", async () => { + render( + + + + + + + + ) + + await waitFor(() => { + expect(retrieveMock).toHaveBeenCalled() + }) + + const params = retrieveMock.mock.calls.at(-1)?.[1] + expect(params?.include).toContain("available_payment_settings") + expect(params?.include).toContain("payment_sessions.payment_setting") + expect(params?.include).toContain("payment_sessions.payment_authorization") + }) +}) diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx new file mode 100644 index 00000000..0d6172f2 --- /dev/null +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -0,0 +1,360 @@ +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 { PlaceOrderButton } from "#components/orders/PlaceOrderButton" +import { PlaceOrderButtonPaymentSessions } from "#components/orders/PlaceOrderButtonPaymentSessions" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import { + resetTermsAcceptanceStore, + setAccepted as setTermsAccepted, +} from "#utils/termsAcceptanceStore" + +const { placeOrderMock } = vi.hoisted(() => ({ placeOrderMock: vi.fn() })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, placeOrderWithPaymentSessions: placeOrderMock } +}) + +// The older branch drags in gateway SDKs and organization config; this suite is +// about routing and the new branch, so keep it out of the way. +vi.mock("#components/orders/PlaceOrderButtonPaymentSource", () => ({ + PlaceOrderButtonPaymentSource: () => , +})) +vi.mock("#utils/organization", () => ({ useOrganizationConfig: () => ({ urls: {} }) })) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } + +function orderOnSessions(overrides: Partial = {}): Partial { + return { + id: "order-1", + status: "pending", + available_payment_settings: [MANUAL], + payment_sessions: [{ id: "session-1", status: "unpaid", payment_setting: MANUAL }], + ...overrides, + } as Partial +} + +const setOrderErrors = vi.fn() +const getOrder = vi.fn() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + resetTermsAcceptanceStore() + placeOrderMock.mockResolvedValue({ placed: true, order: { status: "placed" }, errors: [] }) +}) + +describe("PlaceOrderButton routing", () => { + it("renders the payment_source branch for an order on that model", () => { + render( + + + + ) + expect(screen.getByRole("button").textContent).toBe("payment_source branch") + }) + + it("renders the payment_sessions branch for an order on that model", () => { + render( + + + + ) + expect(screen.getByRole("button").textContent).toBe("Pay now") + }) + + // Neither branch may run before the model is known: mounting the older one + // would start redirect effects reading a payment source this order has not + // got, and rendering nothing would make the button appear late — a visible + // change for applications that only ever mounted . + it("renders an inert button while the model is undetermined", () => { + render( + + + + ) + const button = screen.getByRole("button") as HTMLButtonElement + expect(button.textContent).toBe("Pay now") + expect(button.disabled).toBe(true) + }) + + it("does not place anything while the model is undetermined", async () => { + render( + + + + ) + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + expect(placeOrderMock).not.toHaveBeenCalled() + }) +}) + +describe("PlaceOrderButtonPaymentSessions", () => { + function renderButton(currentOrder: Partial | null = orderOnSessions(), props = {}) { + return render( + + + + ) + } + + // Placeability cannot be read before clicking: `order.placeable` is never + // served on a GET, and it stays false while the asynchronous authorization + // is still in flight. Disabling on it would block the button exactly when + // payment is under way. + it("stays enabled without waiting for placeability", () => { + renderButton() + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + // The whole order is handed over: which sessions get authorized, and in which + // order, is decided by the sequence rather than here. + it("hands the order to the place-order sequence", async () => { + const onClick = vi.fn() + renderButton(orderOnSessions(), { onClick }) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledWith( + expect.objectContaining({ order: expect.objectContaining({ id: "order-1" }) }) + ) + }) + expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ placed: true })) + }) + + it("surfaces placeability reasons as one error each", async () => { + placeOrderMock.mockResolvedValue({ + placed: false, + timedOut: true, + errors: [ + { + code: "VALIDATION_ERROR", + message: "Payment does not cover the order.", + field: "payment_action", + }, + { + code: "VALIDATION_ERROR", + message: "Billing address is missing.", + field: "billing_address", + }, + ], + }) + const onClick = vi.fn() + renderButton(orderOnSessions(), { onClick }) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(setOrderErrors).toHaveBeenCalled() + }) + const errors = setOrderErrors.mock.calls.at(-1)?.[0] + expect(errors).toHaveLength(2) + expect(errors[0]).toMatchObject({ field: "payment_action", resource: "orders" }) + expect(errors[1]).toMatchObject({ field: "billing_address" }) + expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ placed: false })) + // The order moved on without us, so stale amounts must not stay on screen. + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("reports a thrown error without claiming the order was placed", async () => { + placeOrderMock.mockRejectedValue(new Error("Unauthorized")) + const onClick = vi.fn() + renderButton(orderOnSessions(), { onClick }) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + placed: false, + errors: [expect.objectContaining({ message: "Unauthorized" })], + }) + ) + }) + }) + + // Authorizations may already have been created when the place threw, and the + // order in context still shows their sessions without one — which reads as + // "nothing has been charged yet". A shopper clicking again on that stale + // order would be authorized a second time and charged twice. + it("refetches the order after a thrown error, not only after a reported one", async () => { + placeOrderMock.mockRejectedValue(new Error("Unauthorized")) + renderButton(orderOnSessions()) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + }) + + describe("privacy and terms", () => { + // A legal requirement of the checkout, not a property of the payment model, + // so it gates this branch exactly as it gates the older one. + it("blocks the button when both URLs are configured and the box is unchecked", () => { + renderButton( + orderOnSessions({ + privacy_url: "https://example.com/privacy", + terms_url: "https://example.com/terms", + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + it("allows the button once the box is checked", () => { + setTermsAccepted("order-1", true) + renderButton( + orderOnSessions({ + privacy_url: "https://example.com/privacy", + terms_url: "https://example.com/terms", + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + it("does not gate an order with no privacy and terms URLs", () => { + renderButton() + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + }) + + // Removing a gift card deletes the session paying the difference along with + // it — its amount was fixed against the old remainder. Without this gate the + // shopper is left with a live button whose only possible outcome is a + // placeability failure. + describe("something has to be paying for the order", () => { + it("blocks the button when no session is left", () => { + renderButton( + orderOnSessions({ total_amount_with_taxes_cents: 7100, payment_sessions: [] } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + // A gift card is additive: on its own it pays part of the order, and the + // difference still needs a method. This is the same order state the bug + // report started from, one step earlier. + it("blocks the button when gift cards do not cover the order", () => { + renderButton( + orderOnSessions({ + total_amount_with_taxes_cents: 7100, + payment_sessions: [ + { + id: "session-gift", + status: "unpaid", + amount_cents: 2500, + payment_setting: GIFT_CARD, + }, + ], + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + it("allows the button when gift cards cover the order outright", () => { + renderButton( + orderOnSessions({ + total_amount_with_taxes_cents: 7100, + payment_sessions: [ + { + id: "session-gift", + status: "unpaid", + amount_cents: 7100, + payment_setting: GIFT_CARD, + }, + ], + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + it("allows the button on a free order", () => { + renderButton( + orderOnSessions({ total_amount_with_taxes_cents: 0, payment_sessions: [] } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + // An order fetched without the total in its `fields` reads as `undefined`, + // not as free. + it("blocks the button when the total is unknown and nothing is paying", () => { + renderButton(orderOnSessions({ payment_sessions: [] } as never)) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + // An explicit `disabled` stays the consumer's business either way. + it("leaves an explicit disabled prop in charge", () => { + renderButton(orderOnSessions({ payment_sessions: [] } as never), { disabled: false }) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + }) +}) + +// The checkbox is a *sibling* of the button, never its ancestor, so no provider +// can sit above both: the acceptance store is the shared channel. Subscribing to +// it is what keeps a `payment_sessions` button — which does not use +// PlaceOrderContext — from staying disabled forever. +describe("privacy checkbox reaches the payment_sessions button", () => { + it("enables the button when acceptance lands in the store", async () => { + render( + + + + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + + await act(async () => { + setTermsAccepted("order-1", true) + }) + + await waitFor(() => { + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + }) +}) diff --git a/packages/react-components/specs/orders/place-order.spec.tsx b/packages/react-components/specs/orders/place-order.spec.tsx index c3ecb454..b403e3e4 100644 --- a/packages/react-components/specs/orders/place-order.spec.tsx +++ b/packages/react-components/specs/orders/place-order.spec.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react" import { type ReactNode, useContext, useEffect } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { PlaceOrderButton } from "#components/orders/PlaceOrderButton" +import { PlaceOrderButtonPaymentSource as PlaceOrderButton } from "#components/orders/PlaceOrderButtonPaymentSource" import { PlaceOrderContainer } from "#components/orders/PlaceOrderContainer" import { PrivacyAndTermsCheckbox } from "#components/orders/PrivacyAndTermsCheckbox" import CommerceLayerContext from "#context/CommerceLayerContext" diff --git a/packages/react-components/specs/orders/terms-acceptance.spec.tsx b/packages/react-components/specs/orders/terms-acceptance.spec.tsx index 14133bee..811b89d5 100644 --- a/packages/react-components/specs/orders/terms-acceptance.spec.tsx +++ b/packages/react-components/specs/orders/terms-acceptance.spec.tsx @@ -59,6 +59,11 @@ const ORDER: any = { id: "order-1", status: "pending", total_amount_with_taxes_cents: 1000, + // `` routes on the Payments Model, and an order with + // neither `available_payment_methods` nor `available_payment_settings` is + // undetermined — it would get the inert button and every assertion here + // would pass for the wrong reason. + available_payment_methods: [{ id: "pm-1", payment_source_type: "stripe_payments" }], payment_method: { id: "pm-1", payment_source_type: "stripe_payments" }, payment_source: { id: "ps-1", type: "stripe_payments" }, billing_address: { id: "ba-1" }, diff --git a/packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx b/packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx new file mode 100644 index 00000000..002122a1 --- /dev/null +++ b/packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx @@ -0,0 +1,87 @@ +import type { Order } from "@commercelayer/sdk" +import { render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentMethod } from "#components/payment_methods/PaymentMethod" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" + +const setPaymentMethod = vi.fn().mockResolvedValue({ order: undefined }) +const setPaymentSource = vi.fn().mockResolvedValue(undefined) + +const METHOD = { id: "pm-1", payment_source_type: "wire_transfers", name: "Wire Transfer" } +const SETTING = { id: "ps-manual", type: "payment_setting_manuals", name: "Manual" } + +function Wrapper({ children, order }: { children: ReactNode; order: Partial }) { + return ( + + + + {children} + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe(" on the payment_sessions model", () => { + // Returning null from render does NOT stop a mounted component's effects. + // Without an explicit guard inside them, auto-select writes a payment_method + // onto the order; the API then drops available_payment_settings, and the + // order is flipped onto the older model permanently. + it("does not auto-select behind an inactive tree", async () => { + render( + + + old + + + ) + + await waitFor(() => { + expect(screen.queryByTestId("old-tree")).toBeNull() + }) + expect(setPaymentMethod).not.toHaveBeenCalled() + expect(setPaymentSource).not.toHaveBeenCalled() + }) + + it("still auto-selects on the payment_source model", async () => { + render( + + + old + + + ) + + await waitFor(() => { + expect(setPaymentMethod).toHaveBeenCalledWith( + expect.objectContaining({ paymentMethodId: "pm-1" }) + ) + }) + }) +}) diff --git a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx new file mode 100644 index 00000000..6ed3861f --- /dev/null +++ b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx @@ -0,0 +1,342 @@ +import type { Order } from "@commercelayer/sdk" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentMethod } from "#components/payment_methods/PaymentMethod" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import { PaymentSettingManualPayment } from "#components/payment_settings/PaymentSettingManualPayment" +import { PaymentSettingName } from "#components/payment_settings/PaymentSettingName" +import { PaymentSettingRadioButton } from "#components/payment_settings/PaymentSettingRadioButton" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const { createPaymentSessionMock } = vi.hoisted(() => ({ createPaymentSessionMock: vi.fn() })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createPaymentSession: createPaymentSessionMock } +}) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals", name: "Bank transfer" } +const STRIPE = { id: "ps-stripe", type: "payment_setting_stripes", name: "Stripe" } + +function order(overrides: Partial = {}): Partial { + return { + id: "order-1", + available_payment_settings: [MANUAL], + payment_sessions: [], + ...overrides, + } as Partial +} + +const getOrder = vi.fn() +const addResourceToInclude = vi.fn() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +function renderSettings(currentOrder?: Partial | null) { + return render( + + + + + IBAN} /> + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + createPaymentSessionMock.mockResolvedValue({ id: "session-new" }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("PaymentSetting", () => { + it("renders a setting from available_payment_settings", () => { + renderSettings(order()) + expect(screen.getByTestId("name").textContent).toBe("Bank transfer") + expect(screen.getByTestId("radio")).toBeTruthy() + }) + + // Self-silencing is what lets both payment trees be mounted side by side + // without a coordinator above them. + it("renders nothing when the order is on the payment_source model", () => { + renderSettings( + order({ + available_payment_settings: [], + available_payment_methods: [{ id: "pm-1" }], + } as never) + ) + expect(screen.queryByTestId("radio")).toBeNull() + }) + + it("renders nothing before the order has loaded", () => { + renderSettings(null) + expect(screen.queryByTestId("radio")).toBeNull() + }) + + // A radio for a setting with no implementation behind it does nothing when + // clicked, which is worse for the shopper than not offering it. + it("skips settings it cannot drive yet", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + renderSettings(order({ available_payment_settings: [MANUAL, STRIPE] } as never)) + expect(screen.getAllByTestId("name")).toHaveLength(1) + expect(screen.getByTestId("name").textContent).toBe("Bank transfer") + expect(warn).toHaveBeenCalledWith(expect.stringContaining("payment_setting_stripes")) + }) + + describe("selection", () => { + it("creates a Payment Session when the setting is chosen", async () => { + renderSettings(order()) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "order-1", paymentSettingId: "ps-manual" }) + ) + }) + // The order is the only source of truth for the selection, so it has to + // be pulled back in before anything reflects the new session. + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("never sends amount_cents — the server sizes the session", async () => { + renderSettings(order()) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalled() + }) + expect(createPaymentSessionMock.mock.calls[0]?.[0]).not.toHaveProperty("amount_cents") + }) + + // Switching setting leaves the previous session on the order — it is never + // deleted — so the newest one is what the radio group must follow. + // Otherwise every setting the shopper ever tried reads as selected at once. + it("follows the most recent session when the order carries several", () => { + renderSettings( + order({ + payment_sessions: [ + { + id: "session-manual", + status: "unpaid", + created_at: "2026-08-18T10:00:00Z", + payment_setting: MANUAL, + }, + { + id: "session-other", + status: "unpaid", + created_at: "2026-08-18T11:00:00Z", + payment_setting: STRIPE, + }, + ], + } as never) + ) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(false) + }) + + it("selects the setting whose session is the most recent", () => { + renderSettings( + order({ + payment_sessions: [ + { + id: "session-other", + status: "unpaid", + created_at: "2026-08-18T10:00:00Z", + payment_setting: STRIPE, + }, + { + id: "session-manual", + status: "unpaid", + created_at: "2026-08-18T11:00:00Z", + payment_setting: MANUAL, + }, + ], + } as never) + ) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(true) + }) + + // A failed authorization leaves the session `unpaid`, so status alone + // cannot tell a fresh session from a burnt one. + it("creates a new session when the existing one carries a failed authorization", async () => { + renderSettings( + order({ + payment_sessions: [ + { + id: "session-1", + status: "unpaid", + payment_setting: MANUAL, + payment_authorization: { status: "failed" }, + }, + ], + } as never) + ) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalledOnce() + }) + }) + + it("reads the selection back from the order, not from local state", () => { + renderSettings( + order({ + payment_sessions: [{ id: "session-1", status: "unpaid", payment_setting: MANUAL }], + } as never) + ) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(true) + expect(screen.getByTestId("instructions")).toBeTruthy() + }) + + it("does not show the setting as chosen while no session exists", () => { + renderSettings(order()) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(false) + expect(screen.queryByTestId("instructions")).toBeNull() + }) + + it("ignores a click on the setting already chosen", async () => { + renderSettings( + order({ + payment_sessions: [{ id: "session-1", status: "unpaid", payment_setting: MANUAL }], + } as never) + ) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + expect(createPaymentSessionMock).not.toHaveBeenCalled() + }) + }) + + // Both trees can be mounted together with no coordinator above them. 2026-05 + // is additive, so an order on the newer model still carries + // available_payment_methods — without this the shopper would see two sets of + // payment options, one of them dead. + describe("precedence over the payment_source tree", () => { + it("silences on the payment_sessions model", () => { + render( + + + old + + + + + + ) + expect(screen.queryByTestId("old-tree")).toBeNull() + expect(screen.getByTestId("name").textContent).toBe("Bank transfer") + }) + }) + + // Gift cards live in : additive, not one of the + // alternatives this group picks between. Skipped without a warning, unlike a + // setting that genuinely has no implementation. + it("leaves gift card settings to their own component, silently", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + renderSettings( + order({ + available_payment_settings: [ + MANUAL, + { id: "ps-gift", type: "payment_setting_gift_cards", name: "Gift card" }, + ], + } as never) + ) + expect(screen.getAllByTestId("name")).toHaveLength(1) + expect(warn).not.toHaveBeenCalled() + }) +}) + +// An application styling the chosen option, or making the whole card the click +// target, needs the selection outside the radio's own render prop. +describe("PaymentSetting children as a function", () => { + it("hands each setting its state", () => { + render( + + + {({ setting, isSelected, isPending }) => ( +
{`${setting.id}|${isSelected}|${isPending}`}
+ )} +
+
+ ) + expect(screen.getByTestId("card").textContent).toBe("ps-manual|false|false") + }) + + // One click reaching both a card and the radio inside it must not leave two + // Payment Sessions behind: `pendingSettingId` still reads as idle in the + // second handler, so the guard cannot be state. + it("ignores a second selection while one is in flight", async () => { + render( + + + {({ selectSetting }) => ( + ) - ) { - isValid = (await currentPaymentMethodRef.current?.onsubmit({ - // @ts-expect-error no type - paymentSource: checkPaymentSource, - setPlaceOrder, - onclickCallback: onClick, - })) as boolean - if ( - !isValid && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.resultCode === "Authorised" - ) { - isValid = true - } - } else if ( - currentPaymentMethodRef?.current?.onsubmit && - options?.checkoutCom?.session_id && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.status && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.status?.toLowerCase() === "declined" - ) { - /** - * Permit to place order with declined payment using Checkout.com - */ - isValid = (await currentPaymentMethodRef.current?.onsubmit({ - // @ts-expect-error no type - paymentSource: checkPaymentSource, - setPlaceOrder, - onclickCallback: onClick, - })) as boolean - } else if (card?.brand && checkPaymentSourceStatus !== "declined") { - isValid = true - } - if (currentPaymentStatus === "partially_authorized") { - isValid = false - } - if (isValid && setPlaceOrderStatus != null) { - setPlaceOrderStatus({ status: "placing" }) - setForceDisable(true) - } - const placed = - isValid && - setPlaceOrder && - (checkPaymentSource || isFree) && - (await setPlaceOrder({ - paymentSource: checkPaymentSource, - currentCustomerPaymentSourceId, - })) - if (placed && setPlaceOrderStatus != null) { - if (placed.placed) { - setPlaceOrderStatus({ status: "placing" }) - onClick?.(placed) - } else { - setForceDisable(false) - onClick?.(placed) - setIsLoading(false) - setPlaceOrderStatus({ status: "standby" }) - } - } else { - setIsLoading(false) - setPlaceOrderStatus?.({ status: "standby" }) } } - const disabledButton = disabled !== undefined ? disabled : notPermitted - const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label - const parentProps = { - ...p, - label, - disabled: disabledButton, - handleClick, - parentRef: ref, - isLoading, - } - return children ? ( - {children} - ) : ( - - ) } export default PlaceOrderButton diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx new file mode 100644 index 00000000..c7f29710 --- /dev/null +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -0,0 +1,181 @@ +import { + DEFAULT_PLACEABLE_ATTEMPTS, + DEFAULT_PLACEABLE_INTERVAL_MS, + placeOrderWithPaymentSessions, +} from "@commercelayer/core-components" +import type { Order } from "@commercelayer/sdk" +import { type JSX, type MouseEvent, type ReactNode, useContext, useState } from "react" +import Parent from "#components/utils/Parent" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { useTermsAndConditions } from "#hooks/useTermsAndConditions" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import { useOrganizationConfig } from "#utils/organization" + +interface ChildrenProps extends Omit { + handleClick: () => Promise + isLoading: boolean +} + +interface Props extends Omit { + children?: ChildrenFunction + label?: string | ReactNode | (() => ReactNode) + loadingLabel?: string | ReactNode + onClick?: (response: { placed: boolean; order?: Order; errors?: BaseError[] }) => 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. + */ + placeableAttempts?: number + /** Delay between placeability attempts, in milliseconds. Defaults to 1000. */ + placeableIntervalMs?: number +} + +/** + * Place-order button for the `payment_sessions` model. + * + * Deliberately **not** a branch inside `PlaceOrderButtonPaymentSource`: that + * component's enablement machine is built on `payment_method`, + * `payment_source.payment_response`, `getCardDetails` and gateway `onsubmit` + * refs, none of which exist here — and its upstream permission check hard-fails + * every non-free order without a `payment_method`. + * + * **Placeability** cannot be read before clicking: `order.placeable` is + * transient and never served on a GET, and it does not turn true until the + * asynchronous authorization has succeeded — so using it as a gate would + * disable the button precisely while payment is in progress. The truth arrives + * after the click, from `_placeable`. + * + * **Whether anything is paying for the order**, on the other hand, is plain to + * read from the order, and is gated here: without it a shopper who removes the + * gift card that was covering the remainder — which deletes the session paying + * the difference along with it — is left looking at a live button that can only + * fail. The gate is the same derivation the rest of the payment UI uses, so the + * button cannot disagree with the selector above it. + */ +export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { + const { + children, + label = "Place order", + loadingLabel = "Placing...", + disabled, + onClick, + placeableAttempts = DEFAULT_PLACEABLE_ATTEMPTS, + placeableIntervalMs = DEFAULT_PLACEABLE_INTERVAL_MS, + ...p + } = props + const { order, setOrderErrors, getOrder } = useContext(OrderContext) + const { isCovered, currentPaymentSession } = usePaymentSessionsState() + const { accessToken, interceptors } = useContext(CommerceLayerContext) + const [isLoading, setIsLoading] = useState(false) + const organizationConfig = useOrganizationConfig({ accessToken }) + // The privacy and terms gate is a legal requirement of the checkout, not a + // property of the payment model, so it applies here exactly as it does to the + // older branch — same acceptance store, same "only when both URLs are + // configured" rule as `placeOrderPermitted`. + const { accepted: privacyTermsChecked } = useTermsAndConditions() + + const privacyUrl = order?.privacy_url ?? organizationConfig?.urls?.privacy + const termsUrl = order?.terms_url ?? organizationConfig?.urls?.terms + const privacyAccepted = privacyUrl && termsUrl ? privacyTermsChecked : true + + // Nothing left to pay is a complete answer: gift cards can cover an order + // outright, and a free order has nothing to authorize. Otherwise the + // difference needs its session. + // + // The zero test is strict on purpose. An order fetched without + // `total_amount_with_taxes_cents` in its `fields` has `undefined` there, and + // reading that as free would enable the button on an order nothing is paying + // for — the same trap `isCovered` guards against with its `total > 0`. + const isFree = order?.total_amount_with_taxes_cents === 0 + const isPaymentInPlace = isCovered || isFree || currentPaymentSession != null + + const handleClick = async (event?: MouseEvent): Promise => { + event?.preventDefault() + event?.stopPropagation() + if (order == null || accessToken == null || isLoading) return + + 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({ + accessToken, + interceptors, + order, + attempts: placeableAttempts, + intervalMs: placeableIntervalMs, + }) + + if (result.placed) { + onClick?.({ placed: true, order: result.order }) + return + } + + const errors: BaseError[] = result.errors.map((error) => ({ + code: "VALIDATION_ERROR", + resource: "orders", + message: error.message, + field: error.field, + ...(error.meta != null ? { meta: error.meta } : {}), + })) + setOrderErrors(errors) + onClick?.({ placed: false, order: result.order, errors }) + // The order moved on without us — an authorization may have landed, or + // auto_place may have fired — so pull the truth back in rather than + // leaving the shopper looking at stale amounts. + await getOrder(order.id) + } catch (error) { + const errors: BaseError[] = [ + { + 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 + // as "nothing has been charged yet". A shopper who clicks again on that + // 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. + } + } finally { + setIsLoading(false) + } + } + + const disabledButton = + disabled !== undefined ? disabled : !privacyAccepted || !isPaymentInPlace + const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label + + return children ? ( + {children} + ) : ( + + ) +} + +export default PlaceOrderButtonPaymentSessions diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx new file mode 100644 index 00000000..430b15b2 --- /dev/null +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx @@ -0,0 +1,598 @@ +/** biome-ignore-all lint/correctness/useExhaustiveDependencies: Avoid infinite loop */ +import type { Order } from "@commercelayer/sdk" +import { + type JSX, + type MouseEvent, + type ReactNode, + useContext, + useEffect, + useRef, + useState, +} from "react" +import OrderContext from "#context/OrderContext" +import PaymentMethodContext from "#context/PaymentMethodContext" +import PlaceOrderContext from "#context/PlaceOrderContext" +import useCommerceLayer from "#hooks/useCommerceLayer" +import { usePlaceOrder } from "#hooks/usePlaceOrder" +import type { PlaceOrderOptions } from "#reducers/PlaceOrderReducer" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import getCardDetails from "#utils/getCardDetails" +import { checkPaymentIntent } from "#utils/stripe/retrievePaymentIntent" +import Parent from "../utils/Parent" + +interface ChildrenProps extends Omit { + /** + * Callback function to place the order + */ + handleClick: () => Promise +} + +interface Props extends Omit { + children?: ChildrenFunction + /** + * The label of the button + */ + label?: string | ReactNode | (() => ReactNode) + /** + * The label of the button when it's loading + */ + loadingLabel?: string | ReactNode + /** + * If false, the button doesn't place the order automatically. Default: true + */ + autoPlaceOrder?: boolean + /** + * Callback function that is fired when the button is clicked + */ + onClick?: (response: { placed: boolean; order?: Order; errors?: BaseError[] }) => void + /** + * Place order options (PayPal, Adyen, Stripe, Checkout.com redirect flows). + * Required in standalone mode when used without ``. + */ + options?: PlaceOrderOptions +} + +export function PlaceOrderButtonPaymentSource(props: Props): JSX.Element { + const ref = useRef(null) + const { + children, + label = "Place order", + loadingLabel = "Placing...", + autoPlaceOrder = true, + disabled, + onClick, + options: optionsProp, + ...p + } = props + + // Detect standalone mode: no parent has set _isProvided. + const parentCtx = useContext(PlaceOrderContext) + const isStandalone = parentCtx._isProvided !== true + + // Always call the hook (Rules of Hooks). When not standalone, effects are + // guarded internally and the returned value is not used. + const standaloneCtx = usePlaceOrder({ isStandalone, options: optionsProp }) + + const { + isPermitted, + setPlaceOrder, + options, + paymentType, + setButtonRef, + setPlaceOrderStatus, + status, + } = isStandalone ? standaloneCtx : parentCtx + const [notPermitted, setNotPermitted] = useState(true) + const [forceDisable, setForceDisable] = useState(disabled) + const [isLoading, setIsLoading] = useState(false) + const [hasBlockingErrors, setHasBlockingErrors] = useState(false) + const { sdkClient } = useCommerceLayer() + const { + currentPaymentMethodRef, + loading, + currentPaymentMethodType, + paymentSource, + setPaymentSource, + setPaymentMethodErrors, + currentCustomerPaymentSourceId, + errors: paymentMethodErrors, + } = useContext(PaymentMethodContext) + const { order, setOrderErrors, errors } = useContext(OrderContext) + const isFree = order?.total_amount_with_taxes_cents === 0 + useEffect(() => { + if (hasBlockingErrors) { + setNotPermitted(true) + return () => { + setNotPermitted(true) + } + } + // NOTE: no `isFree && !isPermitted` shortcut here. It used to live at this + // spot but was dead code: `setNotPermitted` is a state setter, so the + // branches below ran in the same effect pass and always overwrote it. + if (loading) setNotPermitted(loading) + else { + if (paymentType === currentPaymentMethodType && paymentType) { + const paymentSourceStatus = + // @ts-expect-error no type + order?.payment_source?.payment_response?.status?.toLowerCase?.() + const card = getCardDetails({ + customerPayment: { + payment_source: paymentSource, + }, + paymentType, + }) + if ( + currentCustomerPaymentSourceId != null && + paymentSource?.id === currentCustomerPaymentSourceId && + card.brand === "" + ) { + // Force creadit card icon for customer payment source imported by API + card.brand = "credit-card" + } + if ( + ((isFree && isPermitted) || currentPaymentMethodRef?.current?.onsubmit || card.brand) && + isPermitted + ) { + setNotPermitted(false) + } + if (!currentPaymentMethodRef?.current?.onsubmit && paymentSourceStatus === "declined") { + setNotPermitted(true) + } + } else if (isFree && isPermitted) { + setNotPermitted(false) + } else { + setNotPermitted(true) + } + } + return () => { + setNotPermitted(true) + } + }, [ + isPermitted, + paymentType != null, + !currentPaymentMethodRef?.current?.onsubmit, + loading, + currentPaymentMethodType, + order?.id, + paymentSource?.id, + order?.total_amount_with_taxes_cents, + hasBlockingErrors, + ]) + useEffect(() => { + const giftCardCouponFields = ["gift_card_code", "coupon_code", "gift_card_or_coupon_code"] + const blockingErrors = errors?.filter((e) => !giftCardCouponFields.includes(e.field ?? "")) + const hasErrors = + (blockingErrors != null && blockingErrors.length > 0) || + (paymentMethodErrors != null && paymentMethodErrors.length > 0) + setHasBlockingErrors(hasErrors) + if (hasErrors) { + setNotPermitted(true) + setIsLoading(false) + setForceDisable(false) + } + }, [errors?.length, paymentMethodErrors?.length]) + useEffect(() => { + // PayPal redirect flow + if ( + paymentType === "paypal_payments" && + options?.paypalPayerId && + order?.status && + ["draft", "pending"].includes(order?.status) && + autoPlaceOrder + ) { + handleClick() + } + }, [options?.paypalPayerId, paymentType != null]) + useEffect(() => { + // Stripe redirect flow + if ( + paymentType === "stripe_payments" && + options?.stripe?.paymentIntentClientSecret && + // @ts-expect-error no type + order?.payment_source?.publishable_key && + order?.status && + ["draft", "pending"].includes(order?.status) && + autoPlaceOrder + ) { + // @ts-expect-error no type + const publicApiKey = order?.payment_source?.publishable_key + const paymentIntentClientSecret = options?.stripe?.paymentIntentClientSecret + + const getPaymentIntent = async (): Promise => { + const paymentIntentResult = await checkPaymentIntent({ + publicApiKey, + paymentIntentClientSecret, + }) + switch (paymentIntentResult.status) { + case "valid": + handleClick() + break + case "processing": + // Set a timeout to check the payment intent status again + setTimeout(() => { + getPaymentIntent() + }, 1000) + break + case "invalid": + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message: paymentIntentResult.message, + }, + ]) + break + } + } + getPaymentIntent() + } + }, [ + options?.stripe?.paymentIntentClientSecret != null, + paymentType != null, + order?.payment_source != null, + ]) + useEffect(() => { + if (order?.status != null && ["draft", "pending"].includes(order?.status)) { + // Adyen redirect flow + const isAuthorized = + // @ts-expect-error no type + order?.payment_source?.payment_response?.resultCode === "Authorised" + const paymentDetails = + // @ts-expect-error no type + order?.payment_source?.payment_request_details?.details != null + const paymentStatus = order?.payment_status + const paymentMethodType = + // @ts-expect-error no type + order?.payment_source?.payment_response?.paymentMethod?.type + if (paymentType === "adyen_payments" && options?.adyen?.redirectResult && !paymentDetails) { + const attributes = { + payment_request_details: { + details: { + redirectResult: options?.adyen?.redirectResult, + }, + }, + _details: 1, + } + setPaymentSource({ + paymentSourceId: paymentSource?.id, + paymentResource: "adyen_payments", + attributes, + }).then((res) => { + // @ts-expect-error no type + const resultCode: string = res?.payment_response?.resultCode + // @ts-expect-error no type + const errorCode = res?.payment_response?.errorCode + // @ts-expect-error no type + const message = res?.payment_response?.message + if (["Authorised", "Pending", "Received"].includes(resultCode) && autoPlaceOrder) { + handleClick() + } else if (errorCode != null) { + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message, + }, + ]) + } + }) + } else if ( + paymentType === "adyen_payments" && + isAuthorized && + paymentDetails && + autoPlaceOrder && + status === "standby" && + !options?.adyen?.redirectResult + ) { + // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source + if ( + // @ts-expect-error no type + order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) + ) { + handleClick() + } + } else if ( + paymentType === "adyen_payments" && + isAuthorized && + paymentStatus === "authorized" && + paymentMethodType === "giftcard" && + autoPlaceOrder && + status === "standby" && + !options?.adyen?.redirectResult + ) { + // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source + if ( + // @ts-expect-error no type + order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) + ) { + handleClick() + } + } + } + }, [ + options?.adyen?.redirectResult != null, + // @ts-expect-error no type + order?.payment_source?.payment_response?.resultCode, + ]) + useEffect(() => { + if ( + order?.status === "placed" && + order?.payment_status === "authorized" && + paymentType === "adyen_payments" + ) { + // Dispatch the onClick callback when the order is placed and the payment status is authorized (Adyen with gift card) + onClick?.({ + placed: true, + order: order, + }) + } + }, [order?.id, order?.payment_status, order?.status, paymentType != null]) + useEffect(() => { + // Checkout.com redirect flow + if ( + paymentType === "checkout_com_payments" && + options?.checkoutCom?.session_id && + order?.status && + ["draft", "pending"].includes(order?.status) && + autoPlaceOrder + ) { + // @ts-expect-error no type + const paymentResponse = order?.payment_source?.payment_response + const paymentStatus = paymentResponse?.status + if (paymentStatus && paymentStatus.toLowerCase() === "pending") { + async function placingOrder(): Promise { + const res = await setPaymentSource({ + paymentSourceId: paymentSource?.id, + paymentResource: "checkout_com_payments", + attributes: { + _details: 1, + }, + }) + // @ts-expect-error no type + const paymentStatus: string = res?.payment_response?.status + const isValidStatus = ["authorized", "captured"].includes(paymentStatus?.toLowerCase()) + if (paymentStatus && isValidStatus) { + handleClick() + } else { + if (options?.checkoutCom) { + options.checkoutCom.session_id = undefined + } + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message: paymentStatus, + }, + ]) + } + } + placingOrder() + } + } else if ( + paymentType === "checkout_com_payments" && + order?.status && + status && + ["pending"].includes(order?.status) && + ["placing"].includes(status) && + autoPlaceOrder + ) { + /** + * Place order with Checkout.com using express payments + */ + const paymentSourceStatus = + // @ts-expect-error no type + order?.payment_source?.payment_response?.status + if ( + paymentSourceStatus && + ["captured", "authorized"].includes(paymentSourceStatus.toLowerCase()) + ) { + setPlaceOrder?.({ + paymentSource, + }).then((placed) => { + if (placed?.placed) { + onClick?.(placed) + setPlaceOrderStatus?.({ status: "placing" }) + } else { + setPlaceOrderStatus?.({ status: "standby" }) + } + }) + } + } + }, [options?.checkoutCom?.session_id, order?.payment_source?.id, status]) + useEffect(() => { + if (ref?.current != null && setButtonRef != null) { + setButtonRef(ref) + } + }, [ref?.current]) + useEffect(() => { + switch (status) { + case "disabled": + case "placing": + setNotPermitted(true) + break + // No default — the payment check effect above is the sole authority for enabling + // the button. Enabling unconditionally here (old default case) caused the button + // to be enabled on mount regardless of whether a payment method was selected. + } + }, [status]) + const handleClick = async (e?: MouseEvent): Promise => { + e?.preventDefault() + e?.stopPropagation() + const sdk = sdkClient() + if (sdk == null) return + if (order == null) return + let isValid = true + let currentPaymentStatus = "unpaid" + + const isStripePayment = paymentType === "stripe_payments" + if (!isStripePayment) { + /** + * Check if the order is already placed or in draft status to avoid placing it again + * and to prevent placing a draft order + * @see https://docs.commercelayer.io/core/how-tos/placing-orders/checkout/placing-the-order + */ + const { status, payment_status: paymentStatus } = await sdk.orders.retrieve(order?.id, { + fields: ["status", "payment_status", "payment_source"], + include: ["payment_source"], + }) + const isAlreadyPlaced = status === "placed" + const isDraftOrder = status === "draft" + currentPaymentStatus = paymentStatus ?? "unpaid" + + if (isAlreadyPlaced) { + /** + * Order already placed + */ + setPlaceOrderStatus?.({ status: "placing" }) + onClick?.({ + placed: true, + order: order, + }) + return + } + if (isDraftOrder) { + /** + * Draft order cannot be placed + */ + setPlaceOrderStatus?.({ status: "standby" }) + onClick?.({ + placed: false, + order: order, + errors: [ + { + code: "VALIDATION_ERROR", + resource: "orders", + message: "Draft order cannot be placed", + }, + ], + }) + setOrderErrors([ + { + code: "VALIDATION_ERROR", + resource: "orders", + message: "Draft order cannot be placed", + }, + ]) + return + } + } + setIsLoading(true) + // setForceDisable(true) + const checkPaymentSource = + paymentType !== "stripe_payments" + ? await setPaymentSource({ + // @ts-expect-error no type not be undefined + paymentResource: paymentType, + paymentSourceId: paymentSource?.id, + }) + : paymentSource + const checkPaymentSourceStatus = + // @ts-expect-error no type + checkPaymentSource?.payment_response?.status?.toLowerCase?.() + const card = + paymentType && + getCardDetails({ + paymentType, + customerPayment: { payment_source: checkPaymentSource }, + }) + if ( + currentPaymentMethodRef?.current?.onsubmit && + [!options?.paypalPayerId, !options?.adyen?.MD, !options?.checkoutCom?.session_id].every( + Boolean + ) + ) { + isValid = (await currentPaymentMethodRef.current?.onsubmit({ + // @ts-expect-error no type + paymentSource: checkPaymentSource, + setPlaceOrder, + onclickCallback: onClick, + })) as boolean + if ( + !isValid && + // @ts-expect-error no type + checkPaymentSource?.payment_response?.resultCode === "Authorised" + ) { + isValid = true + } + } else if ( + currentPaymentMethodRef?.current?.onsubmit && + options?.checkoutCom?.session_id && + // @ts-expect-error no type + checkPaymentSource?.payment_response?.status && + // @ts-expect-error no type + checkPaymentSource?.payment_response?.status?.toLowerCase() === "declined" + ) { + /** + * Permit to place order with declined payment using Checkout.com + */ + isValid = (await currentPaymentMethodRef.current?.onsubmit({ + // @ts-expect-error no type + paymentSource: checkPaymentSource, + setPlaceOrder, + onclickCallback: onClick, + })) as boolean + } else if (card?.brand && checkPaymentSourceStatus !== "declined") { + isValid = true + } + if (currentPaymentStatus === "partially_authorized") { + isValid = false + } + if (isValid && setPlaceOrderStatus != null) { + setPlaceOrderStatus({ status: "placing" }) + setForceDisable(true) + } + const placed = + isValid && + setPlaceOrder && + (checkPaymentSource || isFree) && + (await setPlaceOrder({ + paymentSource: checkPaymentSource, + currentCustomerPaymentSourceId, + })) + if (placed && setPlaceOrderStatus != null) { + if (placed.placed) { + setPlaceOrderStatus({ status: "placing" }) + onClick?.(placed) + } else { + setForceDisable(false) + onClick?.(placed) + setIsLoading(false) + setPlaceOrderStatus({ status: "standby" }) + } + } else { + setIsLoading(false) + setPlaceOrderStatus?.({ status: "standby" }) + } + } + const disabledButton = disabled !== undefined ? disabled : notPermitted + const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label + const parentProps = { + ...p, + label, + disabled: disabledButton, + handleClick, + parentRef: ref, + isLoading, + } + return children ? ( + {children} + ) : ( + + ) +} + +export default PlaceOrderButtonPaymentSource diff --git a/packages/react-components/src/components/orders/TotalAmount.tsx b/packages/react-components/src/components/orders/TotalAmount.tsx index 17b263f5..36a86766 100644 --- a/packages/react-components/src/components/orders/TotalAmount.tsx +++ b/packages/react-components/src/components/orders/TotalAmount.tsx @@ -1,12 +1,42 @@ import { type JSX, useContext } from "react" import Parent from "#components/utils/Parent" import OrderContext from "#context/OrderContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { BaseAmountComponent } from "#typings" import { manageGiftCard } from "#utils/adyen/manageGiftCard" +import { type CurrencyCode, formatCentsToCurrency } from "#utils/currencies" import BaseOrderPrice from "../utils/BaseOrderPrice" export function TotalAmount(props: BaseAmountComponent): JSX.Element | null { const { managePaymentProviderGiftCards, order } = useContext(OrderContext) + const paymentsModel = usePaymentsModel() + const { giftCardAmountCents } = usePaymentSessionsState() + + // On the `payment_sessions` model a gift card is a payment, not a discount, so + // `total_amount_with_taxes_cents` keeps the gross figure. On the older model + // the same gift card was a negative line item and the total already came back + // net — so showing the gross here would be a regression for anyone migrating, + // and would tell the shopper to pay money a gift card has already covered. + // + // Deducting here rather than reading `order.session_amount_cents`: that field + // does not move until a session is authorized, and gift cards are authorized + // at place time. + if (paymentsModel === "payment_sessions" && giftCardAmountCents > 0) { + // Net of the gift cards only, not of everything already authorized: on the + // older model an authorized payment source never reduced the total shown, + // and only the gift card did. This keeps the two models saying the same + // thing. + const netCents = Math.max(0, (order?.total_amount_with_taxes_cents ?? 0) - giftCardAmountCents) + const price = formatCentsToCurrency(netCents, order?.currency_code as CurrencyCode) + const parentProps = { price, priceCents: netCents, ...props } + return props.children ? ( + {props.children} + ) : ( + {price} + ) + } + if (managePaymentProviderGiftCards) { const giftCardData = manageGiftCard({ order }) if (!giftCardData) return diff --git a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx index 2bba11f7..b0225906 100644 --- a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx +++ b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx @@ -6,6 +6,7 @@ import PaymentMethodChildrenContext from "#context/PaymentMethodChildrenContext" import PaymentMethodContext from "#context/PaymentMethodContext" import PlaceOrderContext from "#context/PlaceOrderContext" import { usePaymentMethod } from "#hooks/usePaymentMethod" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { PaymentMethodConfig, PaymentResource } from "#reducers/PaymentMethodReducer" import type { LoaderType } from "#typings" import type { DefaultChildrenType } from "#typings/globals" @@ -88,6 +89,7 @@ export function PaymentMethod({ config: configProp, ...p }: Props): JSX.Element { + const paymentsModel = usePaymentsModel() const [loading, setLoading] = useState(true) const [paymentSelected, setPaymentSelected] = useState("") const [paymentSourceCreated, setPaymentSourceCreated] = useState(false) @@ -123,6 +125,13 @@ export function PaymentMethod({ */ const isPartiallyAuthorized = order?.payment_status === "partially_authorized" useEffect(() => { + // Silencing this component in render is not enough: React runs a mounted + // component's effects whatever it returns, so without this the newer + // model's orders get a payment_method written behind the tree that is + // supposed to be inactive — and the API then drops + // available_payment_settings, flipping the order onto the older model for + // good. + if (paymentsModel === "payment_sessions") return if (paymentMethods != null && !isEmpty(paymentMethods) && expressPayments) { const [paymentMethod] = getAvailableExpressPayments(paymentMethods) if (!paymentSource && paymentMethod != null) { @@ -162,8 +171,16 @@ export function PaymentMethod({ onClick, paymentSource, showLoader, + paymentsModel, ]) useEffect(() => { + // Silencing this component in render is not enough: React runs a mounted + // component's effects whatever it returns, so without this the newer + // model's orders get a payment_method written behind the tree that is + // supposed to be inactive — and the API then drops + // available_payment_settings, flipping the order onto the older model for + // good. + if (paymentsModel === "payment_sessions") return if ( paymentMethods != null && !paymentSourceCreated && @@ -249,6 +266,7 @@ export function PaymentMethod({ paymentSource, showLoader, autoSelectSinglePaymentMethod, + paymentsModel, ]) useEffect(() => { if (paymentMethods) { @@ -392,6 +410,14 @@ export function PaymentMethod({ const content = !loading || hasRenderedMethodsRef.current ? <>{components} : getLoaderComponent(loader) + // Step aside on the `payment_sessions` model. API version 2026-05 is + // additive, so an order on the newer model still carries + // `available_payment_methods` and this component would happily render them + // alongside the newer tree — two sets of payment options, one of them + // meaningless. This is where the precedence rule actually takes effect, and + // it is what lets both trees be mounted together with no coordinator above. + if (paymentsModel === "payment_sessions") return <> + // In standalone mode provide the context so that child components // (PaymentSource, PaymentGateway, etc.) can read payment state without // a surrounding . diff --git a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx new file mode 100644 index 00000000..2340b899 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx @@ -0,0 +1,261 @@ +import { + createPaymentSession, + findCurrentPaymentSession, + findReusablePaymentSession, + GIFT_CARD_SETTING_TYPE, +} from "@commercelayer/core-components" +import type { + Order, + PaymentSession, + PaymentSetting as PaymentSettingResource, +} from "@commercelayer/sdk" +import { type JSX, type ReactNode, useContext, useEffect, useRef, useState } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { usePaymentsModel } from "#hooks/usePaymentsModel" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" + +/** + * Payment Setting types this library can drive today. + * + * Anything not listed is skipped entirely rather than rendered inert: a radio + * button for a setting with no implementation behind it does nothing when + * clicked, which is worse for the shopper than not offering it. + * + * The goal is to cover all six. See the implementation table in + * `docs/adr/2026-08-18-payment-session-lifecycle.md`. + */ +const IMPLEMENTED_SETTING_TYPES = ["payment_setting_manuals"] as const + +export interface PaymentSettingOnSelectParams { + setting: PaymentSettingResource + /** The order as refetched after the selection was stored. */ + order?: Order + /** The Payment Session backing the selection. */ + paymentSession?: PaymentSession +} + +/** + * The state of the setting currently being rendered. + * + * Given to `children` when it is a function, so an application can style the + * chosen option — a highlighted card, say — or make the whole card the click + * target instead of just the radio. Reading the same state off + * ``'s render prop only reaches inside the control. + */ +export interface PaymentSettingChildrenProps { + setting: PaymentSettingResource + /** Whether this setting is the shopper's current choice. */ + isSelected: boolean + /** Whether its Payment Session is being created right now. */ + isPending: boolean + /** The Payment Session backing the selection, once it exists. */ + currentPaymentSession?: PaymentSession + /** Why the last selection failed, if it did. */ + errors: BaseError[] + /** + * Choose this setting. The same call the radio makes, so an application can + * put it on the whole card — a second call while one is in flight is + * ignored, so wrapping the radio does not create two Payment Sessions. + */ + selectSetting: () => Promise +} + +interface Props { + /** + * Markup rendered once per available setting, or a function receiving that + * setting's state. + */ + children?: ReactNode | ChildrenFunction + /** + * Show what was chosen without letting it change — a placed order, for + * instance. Renders only the selected setting, and keeps rendering it even + * once nothing is left to pay. + */ + readonly?: boolean + /** + * Fired once the selection has been stored and the order refetched — not on + * click. The counterpart of ``'s `onClick`. + * + * Keep the identity stable (`useCallback`): it is read during the selection + * handler, and an unstable one is the usual route to a render loop here. + */ + onSelect?: (params: PaymentSettingOnSelectParams) => void +} + +/** + * Iterate the order's `available_payment_settings` and render `children` once + * per setting, with that setting in context. + * + * Renders nothing unless the order is on the `payment_sessions` model, so it + * can be mounted alongside `` without a coordinator above: each + * tree silently steps aside when the order is not its own. + */ +export function PaymentSetting({ children, onSelect, readonly }: Props): JSX.Element | null { + const paymentsModel = usePaymentsModel() + const { isCovered, remainingAmountCents } = usePaymentSessionsState() + const { order, include, includeLoaded, addResourceToInclude, getOrder } = useContext(OrderContext) + const { accessToken, interceptors } = useContext(CommerceLayerContext) + const [pendingSettingId, setPendingSettingId] = useState(null) + // Read synchronously, unlike `pendingSettingId`. One click can reach the + // handler twice — a card wired to `selectSetting` with the radio inside it — + // and both reads of the state variable would still say "idle", leaving two + // Payment Sessions behind for a single click. + const selectionInFlight = useRef(false) + const [errors, setErrors] = useState([]) + + // Reading a selection back needs the session's setting; telling a reusable + // session from a burnt one needs its authorization. Registered here rather + // than globally: two levels of nesting on a collection is the expensive part + // of the payload, and only this subtree needs it. + useEffect(() => { + const needed = [ + "payment_sessions.payment_setting", + "payment_sessions.payment_authorization", + ] as const + if (!needed.every((resource) => include?.includes(resource))) { + addResourceToInclude({ newResource: [...needed] }) + } else if (needed.some((resource) => includeLoaded?.[resource] !== true)) { + addResourceToInclude({ + newResourceLoaded: { + "payment_sessions.payment_setting": true, + "payment_sessions.payment_authorization": true, + }, + }) + } + }, [include, includeLoaded, addResourceToInclude]) + + if (paymentsModel !== "payment_sessions" || order == null) return null + + // Nothing left to pay means nothing to choose. Gift cards can cover an order + // outright, and offering a payment method then is not merely redundant: + // creating that session fails with a 422 about `amount_cents` having to be + // greater than zero, which is not something a shopper can act on. + // + // Readonly is exempt — a placed order is covered by definition, and hiding + // what was used is the opposite of the point. + if (readonly !== true && isCovered) return null + + const settings = (order.available_payment_settings ?? []).filter((setting) => { + // Gift cards are handled by , not here: they are + // additive rather than one of the alternatives this group picks between. + // Skipped silently — they are implemented, just elsewhere. + if (setting.type === GIFT_CARD_SETTING_TYPE) return false + + const implemented = IMPLEMENTED_SETTING_TYPES.includes( + setting.type as (typeof IMPLEMENTED_SETTING_TYPES)[number] + ) + if (!implemented && process.env.NODE_ENV !== "production") { + // Without this, an organization whose only configured settings are + // unimplemented gets a checkout with no payment options and no clue why. + console.warn( + `[commercelayer] skipped "${setting.type}": not implemented yet.` + ) + } + return implemented + }) + + const selectSetting = async (setting: PaymentSettingResource): Promise => { + if (accessToken == null || order == null) return + if (selectionInFlight.current) return + selectionInFlight.current = true + setErrors([]) + setPendingSettingId(setting.id) + try { + // Reuse before creating. `amount_cents` is immutable, so there is no + // "update the existing session" path — without this, every remount or + // refetch that re-runs the click handler would leave another session + // behind. Reuse is also what makes a page refresh resume the selection + // instead of duplicating it. + const reusable = findReusablePaymentSession({ + paymentSessions: order.payment_sessions, + paymentSettingId: setting.id, + amountCents: remainingAmountCents, + }) + if (reusable == null) { + await createPaymentSession({ + accessToken, + interceptors, + orderId: order.id, + paymentSettingId: setting.id, + // The remainder after the gift cards, which the server cannot work + // out for itself until they are authorized at place time. + amountCents: remainingAmountCents, + }) + } + const refreshed = await getOrder(order.id) + onSelect?.({ + setting, + order: refreshed, + paymentSession: findCurrentPaymentSession({ + paymentSessions: refreshed?.payment_sessions ?? order.payment_sessions, + paymentSettingId: setting.id, + }), + }) + } catch (error) { + setErrors([ + { + code: "VALIDATION_ERROR", + resource: "payment_methods", + message: + error instanceof Error ? error.message : "The payment method could not be selected.", + }, + ]) + } finally { + selectionInFlight.current = false + setPendingSettingId(null) + } + } + + // One selection for the whole order, not one per setting. Switching setting + // leaves the previous session behind — it is inert and may not be deletable + // with a sales-channel token — so without this every setting the shopper has + // tried would render as selected at the same time. + const selectedSession = findCurrentPaymentSession({ paymentSessions: order.payment_sessions }) + + return ( + <> + {settings + .filter( + (setting) => readonly !== true || selectedSession?.payment_setting?.id === setting.id + ) + .map((setting) => { + const isSelected = selectedSession?.payment_setting?.id === setting.id + const currentPaymentSession = isSelected ? selectedSession : undefined + const select = async (): Promise => { + await selectSetting(setting) + } + return ( + + {typeof children === "function" + ? children({ + setting, + isSelected, + isPending: pendingSettingId === setting.id, + currentPaymentSession, + errors, + selectSetting: select, + }) + : children} + + ) + })} + + ) +} + +export default PaymentSetting diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx new file mode 100644 index 00000000..720c5209 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx @@ -0,0 +1,188 @@ +import { applyGiftCard, mapGiftCardErrors, removeGiftCard } from "@commercelayer/core-components" +import type { PaymentSession } from "@commercelayer/sdk" +import { type JSX, type ReactNode, useCallback, useContext, useState } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { usePaymentsModel } from "#hooks/usePaymentsModel" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" + +/** + * What a consuming application needs to drive its own disclosure around the + * gift card controls — a "use a gift card" toggle, say. + * + * State only: applying and removing stay with the components, which already + * carry the rules about when they may be offered at all. + */ +export interface PaymentSettingGiftCardChildrenProps { + /** Gift cards applied to the order, oldest first. */ + giftCardSessions: PaymentSession[] + /** Sum of the applied gift cards, at face value. */ + giftCardAmountCents: number + /** What is still owed after the applied gift cards. */ + remainingAmountCents: number + /** True when the gift cards cover the order outright. */ + isCovered: boolean + /** Whether another gift card may be applied at all. */ + canAddGiftCard: boolean + /** A code is being applied right now. */ + isApplying: boolean + /** Gift card failures, kept apart from the payment method's. */ + errors: BaseError[] + readonly: boolean +} + +interface Props { + /** + * Markup, or a function receiving the gift card state. + * + * The function form is what an application uses to decide whether its own + * section is open or closed: this component deliberately holds no such state + * of its own, so nothing here fights an application's toggle. + */ + children?: ReactNode | ChildrenFunction + /** + * Show what was applied without letting anything change — a placed order, for + * instance. Hides the input and the remove controls. + */ + readonly?: boolean +} + +/** + * Gift cards on the `payment_sessions` model. + * + * A gift card is a **payment**, not a discount: spending one creates a Payment + * Session, the order total never changes, and what drops is the amount still + * owed. Zero or more may be applied, and whatever is left is paid by one other + * Payment Setting. + * + * Deliberately **outside** ``. Gift cards are additive rather + * than one of the alternatives the shopper picks between, so they do not belong + * in a radio group: several can be active at once, and putting them there would + * mean more than one selection in a group with room for exactly one. + * + * Renders nothing unless the order is on the `payment_sessions` model and has a + * gift card Payment Setting available, so it can be mounted unconditionally. + * + * Applying and removing are single domain operations that also delete the + * session paying the difference — its amount is fixed at creation, so once the + * remainder moves that session is not stale but wrong. + */ +export function PaymentSettingGiftCard({ children, readonly }: Props): JSX.Element | null { + const paymentsModel = usePaymentsModel() + const state = usePaymentSessionsState() + const { order, getOrder } = useContext(OrderContext) + const { accessToken, interceptors } = useContext(CommerceLayerContext) + const [errors, setErrors] = useState([]) + const [isApplying, setIsApplying] = useState(false) + const [code, setCode] = useState("") + + const apply = useCallback( + async (code: string): Promise => { + if (order == null || accessToken == null) return + setErrors([]) + setIsApplying(true) + try { + await applyGiftCard({ accessToken, interceptors, order, giftCardCode: code }) + setCode("") + await getOrder(order.id) + } catch (error) { + setErrors(toGiftCardErrors(error)) + } finally { + setIsApplying(false) + } + }, + [accessToken, interceptors, order, getOrder] + ) + + const remove = useCallback( + async (paymentSessionId: string): Promise => { + if (order == null || accessToken == null) return + setErrors([]) + try { + await removeGiftCard({ accessToken, interceptors, order, paymentSessionId }) + await getOrder(order.id) + } catch (error) { + setErrors(toGiftCardErrors(error)) + } + }, + [accessToken, interceptors, order, getOrder] + ) + + if (paymentsModel !== "payment_sessions" || state.giftCardSettingId == null) return null + + return ( + + {typeof children === "function" + ? children({ + giftCardSessions: state.giftCardSessions, + giftCardAmountCents: state.giftCardAmountCents, + remainingAmountCents: state.remainingAmountCents, + isCovered: state.isCovered, + canAddGiftCard: state.canAddGiftCard, + isApplying, + errors, + readonly: readonly === true, + }) + : children} + + ) +} + +/** + * Both a code we recognise and the message the API sent. + * + * The wording is dug out of the JSON:API `errors` array rather than read off + * `error.message`, which is empty on an SDK error — see `mapGiftCardErrors`, + * which also drops the `token - can't be blank` entry that rides along with + * every refusal. + * + * The API collapses four different causes — no such code, expired, empty, bound + * to another market — into one message, so we cannot tell the shopper *why*. + * A translated consumer keys off `code`; one that wants the detail shows + * `message`. `meta.error` carries the API's symbolic reason when there is one. + * + * The fallback is for a failure that never reached the API at all — a dropped + * connection, say — where there is no `errors` array to read. + */ +function toGiftCardErrors(error: unknown): BaseError[] { + const mapped = mapGiftCardErrors(error).map((giftCardError) => ({ + code: "INVALID_FIELD_VALUE" as const, + resource: "gift_cards" as const, + field: giftCardError.field, + message: giftCardError.message, + ...(giftCardError.meta != null ? { meta: giftCardError.meta } : {}), + })) + if (mapped.length > 0) return mapped + + return [ + { + code: "INVALID_FIELD_VALUE", + resource: "gift_cards", + field: "gift_card_code", + message: + error instanceof Error && error.message !== "" + ? error.message + : "This gift card code could not be applied to the order.", + }, + ] +} + +export default PaymentSettingGiftCard diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx new file mode 100644 index 00000000..6d6408bb --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx @@ -0,0 +1,53 @@ +import { type JSX, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + errors: BaseError[] +} + +interface Props extends Omit { + children?: ChildrenFunction +} + +/** + * Why applying or removing a gift card failed. + * + * Needed as a component of its own because these errors are **not** on the + * order: applying a card that does not exist is rejected before anything is + * written, so `` never sees them and the shopper gets + * a control that silently does nothing. Same reasoning as the `errors` render + * prop on ``, one family of errors up. + * + * The API collapses four causes — unknown code, expired, empty, bound to + * another market — into one message, so `code` is always + * `INVALID_FIELD_VALUE`. A translated consumer keys off that and writes its own + * text; one that wants the API's wording renders `message`. + * + * Renders nothing when there is nothing to report. + */ +export function PaymentSettingGiftCardErrors(props: Props): JSX.Element | null { + const { children, ...p } = props + const { errors } = useContext(PaymentSettingGiftCardContext) + const giftCardErrors = errors ?? [] + + if (giftCardErrors.length === 0) return null + + const parentProps = { ...props, errors: giftCardErrors } + + return children ? ( + {children} + ) : ( +
+ {giftCardErrors.map((error) => ( + + {error.message} + + ))} +
+ ) +} + +export default PaymentSettingGiftCardErrors diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx new file mode 100644 index 00000000..18d3cbf0 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx @@ -0,0 +1,57 @@ +import { type ChangeEvent, type JSX, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + value: string + handleChange: (event: ChangeEvent) => void + disabled: boolean +} + +type Props = { + children?: ChildrenFunction +} & Omit + +/** + * Where the shopper types a gift card code. + * + * Renders nothing when another card cannot be applied — the order is already + * covered, or something has been authorized — and nothing in readonly mode. + * That rule lives here rather than in the consuming application because + * applying a card that is not needed fails with a 422 the shopper cannot make + * sense of. + * + * Whether the field is on screen at all, on the other hand, is the + * application's business: a checkout that hides it behind a toggle simply does + * not mount this. Nothing here holds disclosure state that could fight that. + */ +export function PaymentSettingGiftCardInput(props: Props): JSX.Element | null { + const { children, ...p } = props + const { canAddGiftCard, isApplying, readonly, code, setCode } = useContext( + PaymentSettingGiftCardContext + ) + + if (readonly === true || canAddGiftCard !== true) return null + + const handleChange = (event: ChangeEvent): void => { + setCode?.(event.target.value) + } + const disabled = isApplying === true + const parentProps = { ...props, value: code ?? "", handleChange, disabled } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardInput diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx new file mode 100644 index 00000000..22e68598 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx @@ -0,0 +1,52 @@ +import { hasLiveAuthorization } from "@commercelayer/core-components" +import { type JSX, type ReactNode, useContext } from "react" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import PaymentSettingGiftCardItemContext from "#context/PaymentSettingGiftCardItemContext" + +interface Props { + children?: ReactNode +} + +/** + * Renders `children` once per applied gift card, oldest first. + * + * Shows only cards that are actually paying for something: one whose + * authorization failed, or that has been refunded, took no money, and listing + * it would tell the shopper a payment is in place when none is. + * + * Stays visible in readonly mode and when the order is fully covered — what the + * shopper applied is exactly what they need to see then. Only the controls that + * change things disappear. + */ +export function PaymentSettingGiftCardList({ children }: Props): JSX.Element | null { + const { giftCardSessions, removeGiftCard, readonly } = useContext(PaymentSettingGiftCardContext) + + if (giftCardSessions == null || giftCardSessions.length === 0) return null + + return ( + <> + {giftCardSessions.map((paymentSession) => ( + { + await removeGiftCard?.(paymentSession.id) + }, + }} + > + {children} + + ))} + + ) +} + +export default PaymentSettingGiftCardList diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx new file mode 100644 index 00000000..e486c54b --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx @@ -0,0 +1,59 @@ +import type { JSX } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardItemContext, { + type InitialPaymentSettingGiftCardItemContext, +} from "#context/PaymentSettingGiftCardItemContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps + extends Omit, + Omit {} + +interface Props extends Omit { + children?: ChildrenFunction +} + +/** + * One applied gift card. + * + * The code and the amount come through as render-prop arguments rather than as + * components of their own: `formattedAmount` is formatted by the API, so a + * component would only be re-wrapping a string, and consumers style these two + * fields differently anyway. + * + * Note the amount is what this card covers of the order, **not** the card's + * balance. The server caps a gift card session at whatever was still owed, and + * the balance is not served on a session at all. + */ +export function PaymentSettingGiftCardListItem(props: Props): JSX.Element { + const { children, ...p } = props + const { paymentSession, code, formattedAmount, amountCents, isRemovable, isRemoving } = + useCustomContext({ + context: PaymentSettingGiftCardItemContext, + contextComponentName: "PaymentSettingGiftCardList", + currentComponentName: "PaymentSettingGiftCardListItem", + key: "paymentSession", + }) + + const parentProps = { + ...props, + paymentSession, + code, + formattedAmount, + amountCents, + isRemovable, + isRemoving, + } + + return children ? ( + {children} + ) : ( +
+ {code} + {formattedAmount} +
+ ) +} + +export default PaymentSettingGiftCardListItem diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx new file mode 100644 index 00000000..fa5fcca8 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx @@ -0,0 +1,59 @@ +import { type JSX, type ReactNode, useContext, useState } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardItemContext from "#context/PaymentSettingGiftCardItemContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + handleClick: () => Promise + disabled: boolean +} + +type Props = { + children?: ChildrenFunction + label?: string | ReactNode +} & Omit + +/** + * Takes one gift card back off the order. + * + * Renders nothing once the card has been charged. Authorizing a gift card + * debits the balance straight away — the setting forces auto-capture — and from + * there only a refund could return it, which this iteration does not implement. + * The API would refuse the delete anyway, and surfaces that refusal as an + * unhandled 500, so offering the control would be offering a crash. + */ +export function PaymentSettingGiftCardRemoveButton(props: Props): JSX.Element | null { + const { children, label = "Remove", ...p } = props + const { isRemovable, removeGiftCard } = useContext(PaymentSettingGiftCardItemContext) + const [isRemoving, setIsRemoving] = useState(false) + + if (isRemovable !== true) return null + + const handleClick = async (): Promise => { + if (isRemoving) return + setIsRemoving(true) + try { + await removeGiftCard?.() + } finally { + setIsRemoving(false) + } + } + const parentProps = { ...props, handleClick, disabled: isRemoving } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardRemoveButton diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx new file mode 100644 index 00000000..57b3a504 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx @@ -0,0 +1,56 @@ +import { type JSX, type ReactNode, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + handleClick: () => Promise + disabled: boolean +} + +type Props = { + children?: ChildrenFunction + label?: string | ReactNode +} & Omit + +/** + * Applies the code that has been typed. + * + * Rendered and hidden on the same conditions as the input, so the pair never + * comes apart. Disabled while empty or while a request is in flight — an empty + * code fails a length validation server-side, which is not worth a round trip. + */ +export function PaymentSettingGiftCardSubmitButton(props: Props): JSX.Element | null { + const { children, label = "Apply", ...p } = props + const { canAddGiftCard, isApplying, readonly, code, applyGiftCard } = useContext( + PaymentSettingGiftCardContext + ) + + if (readonly === true || canAddGiftCard !== true) return null + + const trimmed = (code ?? "").trim() + const disabled = isApplying === true || trimmed === "" + + const handleClick = async (): Promise => { + if (disabled) return + await applyGiftCard?.(trimmed) + } + const parentProps = { ...props, handleClick, disabled } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardSubmitButton diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx new file mode 100644 index 00000000..98254bc3 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx @@ -0,0 +1,68 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import type { JSX, ReactNode } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps extends Omit { + /** Whether this setting is the shopper's current choice. */ + isSelected: boolean + /** Whether the Payment Session is being created right now. */ + isPending: boolean + /** + * The Payment Session backing the selection, once it exists. Carries + * `formatted_amount` and `expires_at` for display. + */ + currentPaymentSession?: PaymentSession + errors: BaseError[] +} + +interface Props { + children?: ChildrenFunction + /** + * Rendered when this setting is selected — payment instructions, for + * example the bank details for a wire transfer. + */ + instructions?: ReactNode +} + +/** + * The manual Payment Setting (`payment_setting_manuals`): paying out of band, + * for instance by bank transfer. + * + * There is no gateway UI and nothing to collect, so selecting the setting is + * the whole interaction — the Payment Session created by `` is + * all the API needs. Taking the money happens later, at place time, when the + * Payment Authorization is created: that keeps selection reversible, so a + * shopper changing their mind costs nothing. + * + * Renders `null` unless its setting is selected, so it can be dropped inside + * `` alongside other settings' components. + */ +export function PaymentSettingManualPayment(props: Props): JSX.Element | null { + const { children, instructions } = props + const { setting, currentPaymentSession, isSelected, isPending, errors } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingManualPayment", + key: "setting", + }) + + if (setting?.type !== "payment_setting_manuals") return null + + const parentProps = { + ...props, + isSelected: isSelected === true, + isPending: isPending === true, + currentPaymentSession, + errors: errors ?? [], + } + + if (children) return {children} + if (isSelected !== true) return null + return <>{instructions} +} + +export default PaymentSettingManualPayment diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingName.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingName.tsx new file mode 100644 index 00000000..15093d27 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingName.tsx @@ -0,0 +1,47 @@ +import type { JSX } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps extends Omit { + labelName: string +} + +interface Props extends Omit { + children?: ChildrenFunction +} + +/** Fallback labels for settings an organization has not named. */ +const DEFAULT_LABELS: Record = { + payment_setting_adyens: "Adyen", + payment_setting_braintrees: "Braintree", + payment_setting_externals: "External", + payment_setting_gift_cards: "Gift card", + payment_setting_manuals: "Manual payment", + payment_setting_stripes: "Stripe", +} + +export function PaymentSettingName(props: Props): JSX.Element { + const { setting } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingName", + key: "setting", + }) + // `name` is optional on every payment setting type, so fall back to the + // resource type rather than rendering an unlabelled radio. + const labelName = setting?.name ?? DEFAULT_LABELS[setting?.type ?? ""] ?? setting?.type ?? "" + const htmlFor = setting?.id + const parentProps = { htmlFor, labelName, ...props } + + return props.children ? ( + {props.children} + ) : ( + + ) +} + +export default PaymentSettingName diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx new file mode 100644 index 00000000..9ae6455c --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx @@ -0,0 +1,66 @@ +import type { ChangeEvent, JSX } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps extends Omit { + checked: boolean + handleOnChange: (event: ChangeEvent) => Promise +} + +type Props = { + children?: ChildrenFunction +} & JSX.IntrinsicElements["input"] + +/** + * Radio button selecting the Payment Setting of the surrounding + * ``. + * + * `checked` is derived from the order, never from local state: the selection + * *is* the Payment Session, so what the shopper sees always reflects what the + * API stored. The cost is that the radio does not light up on click — it lights + * up once the session exists. `disabled` covers that round trip. + */ +export function PaymentSettingRadioButton(props: Props): JSX.Element | null { + const { children, ...p } = props + const { setting, isSelected, isPending, selectSetting, readonly } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingRadioButton", + key: "setting", + }) + + // A recap has nothing to pick, and a disabled checked radio reads as a broken + // control rather than a statement. The setting's name is the recap. + if (readonly === true) return null + + const checked = isSelected === true + const id = setting?.id + + const handleOnChange = async (event: ChangeEvent): Promise => { + event.stopPropagation() + if (checked || isPending === true) return + await selectSetting?.() + } + + const parentProps = { handleOnChange, checked, id, disabled: isPending, ...props } + + return children ? ( + {children} + ) : ( + { + void handleOnChange(event) + }} + {...p} + /> + ) +} + +export default PaymentSettingRadioButton diff --git a/packages/react-components/src/context/PaymentSettingChildrenContext.ts b/packages/react-components/src/context/PaymentSettingChildrenContext.ts new file mode 100644 index 00000000..bfb40f8c --- /dev/null +++ b/packages/react-components/src/context/PaymentSettingChildrenContext.ts @@ -0,0 +1,33 @@ +import type { PaymentSession, PaymentSetting } from "@commercelayer/sdk" +import { createContext } from "react" +import type { BaseError } from "#typings/errors" + +export interface InitialPaymentSettingChildrenContext { + /** The Payment Setting this subtree renders, from `available_payment_settings`. */ + setting?: PaymentSetting + /** + * The Payment Session pointing at this setting, if any. This *is* the + * selection — the order carries no `payment_setting` relationship — so it + * survives a reload and always wins over anything held in the browser. + */ + currentPaymentSession?: PaymentSession + /** Whether this setting is the shopper's current choice. */ + isSelected?: boolean + /** + * Whether a session is being created for this setting right now. This is not + * the selection: the selection is derived from the order and only becomes + * true once the API has answered. + */ + isPending?: boolean + errors?: BaseError[] + /** Select this setting, creating or adopting its Payment Session. */ + selectSetting?: () => Promise + /** Nothing may change — the subtree is a recap, not a form. */ + readonly?: boolean +} + +const initial: InitialPaymentSettingChildrenContext = {} + +const PaymentSettingChildrenContext = createContext(initial) + +export default PaymentSettingChildrenContext diff --git a/packages/react-components/src/context/PaymentSettingGiftCardContext.ts b/packages/react-components/src/context/PaymentSettingGiftCardContext.ts new file mode 100644 index 00000000..da4394f5 --- /dev/null +++ b/packages/react-components/src/context/PaymentSettingGiftCardContext.ts @@ -0,0 +1,51 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { createContext } from "react" +import type { BaseError } from "#typings/errors" + +export interface InitialPaymentSettingGiftCardContext { + /** Gift cards applied to the order, oldest first. */ + giftCardSessions?: PaymentSession[] + /** Sum of the applied gift cards, at face value. */ + giftCardAmountCents?: number + /** What is still owed after the applied gift cards. */ + remainingAmountCents?: number + /** True when the gift cards cover the order outright. */ + isCovered?: boolean + /** + * Whether another gift card may be applied. False once the order is covered, + * and false once anything has been authorized — settling a partially-paid + * order is not implemented. + */ + canAddGiftCard?: boolean + /** A code is being applied right now. */ + isApplying?: boolean + /** + * The code being typed. + * + * Held here rather than inside the input because the submit control is a + * separate component and needs to read it. The alternative the older gift + * card form uses — a `
` reading the DOM on submit — is not available + * here: this subtree sits inside a payment step that may already be in a + * form, and nested forms are invalid. + */ + code?: string + setCode?: (code: string) => void + /** + * Gift card errors only. Kept apart from the method's errors: the two + * families have separate UIs, so a failure in one must never surface under + * the other. + */ + errors?: BaseError[] + /** Apply a gift card code to the order. */ + applyGiftCard?: (code: string) => Promise + /** Take one of the applied gift cards off the order. */ + removeGiftCard?: (paymentSessionId: string) => Promise + /** Nothing may be applied or removed — a placed order, for instance. */ + readonly?: boolean +} + +const initial: InitialPaymentSettingGiftCardContext = {} + +const PaymentSettingGiftCardContext = createContext(initial) + +export default PaymentSettingGiftCardContext diff --git a/packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts b/packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts new file mode 100644 index 00000000..28c11115 --- /dev/null +++ b/packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts @@ -0,0 +1,33 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { createContext } from "react" + +export interface InitialPaymentSettingGiftCardItemContext { + /** The Payment Session this row represents. */ + paymentSession?: PaymentSession + /** The code the shopper typed, as the API stores it on the session. */ + code?: string | null + /** + * How much of the order this card covers, already formatted by the API. Note + * this is *not* the card's balance: the server caps the session at whatever + * was still owed, and the balance is not served on a session at all. + */ + formattedAmount?: string | null + amountCents?: number | null + /** + * Whether this card can still be taken off the order. False once it has been + * charged: authorizing a gift card debits the balance immediately, and only a + * refund could give it back — which this iteration does not implement. + */ + isRemovable?: boolean + /** A removal is in flight for this row. */ + isRemoving?: boolean + /** Take this gift card off the order. */ + removeGiftCard?: () => Promise +} + +const initial: InitialPaymentSettingGiftCardItemContext = {} + +const PaymentSettingGiftCardItemContext = + createContext(initial) + +export default PaymentSettingGiftCardItemContext diff --git a/packages/react-components/src/hooks/useOrderState.ts b/packages/react-components/src/hooks/useOrderState.ts index 5b962811..d953d052 100644 --- a/packages/react-components/src/hooks/useOrderState.ts +++ b/packages/react-components/src/hooks/useOrderState.ts @@ -126,6 +126,30 @@ export function useOrderState({ } }, [attributes, state?.order, lock]) + // Ask for `available_payment_settings` on every order fetch, for every + // consumer. This is what makes the Payments Model derivable: an order that + // was never asked for the relationship looks exactly like an order on the + // older model, and `usePaymentsModel` would silently pick the wrong branch. + // + // The cost — one relationship on carts that will never show a payment method + // — is accepted deliberately, because the alternative is making every + // consumer opt in to a correctness requirement they cannot see. + // + // Note `withoutIncludes` is *not* a consumer opt-out to respect here: it + // starts `true` and means "nothing has asked for an include yet". + // Registering one is what flips it, which `addResourceToInclude` does — along + // with marking the resource loaded, so the two-phase idiom the containers use + // is belt-and-braces and one call is enough. + useEffect(() => { + if (state.include?.includes("available_payment_settings")) return + defaultOrderContext.addResourceToInclude({ + newResource: ["available_payment_settings"], + dispatch, + resourcesIncluded: state.include, + resourceIncludedLoaded: state.includeLoaded, + }) + }, [state.include, state.includeLoaded]) + // biome-ignore lint/correctness/useExhaustiveDependencies: complex dep array mirrors original OrderContainer — adding all deps causes fetch loops useEffect(() => { const localOrder = persistKey ? getLocalOrder(persistKey) : orderId diff --git a/packages/react-components/src/hooks/usePaymentSessionsState.ts b/packages/react-components/src/hooks/usePaymentSessionsState.ts new file mode 100644 index 00000000..5134bd61 --- /dev/null +++ b/packages/react-components/src/hooks/usePaymentSessionsState.ts @@ -0,0 +1,28 @@ +import { + derivePaymentSessionsState, + type PaymentSessionsState, +} from "@commercelayer/core-components" +import { useContext, useMemo } from "react" +import OrderContext from "#context/OrderContext" + +export type { PaymentSessionsState } + +/** + * Everything the payment UI needs to know about the order's Payment Sessions: + * the applied gift cards, what is still owed, whether anything is left to pay, + * and which session pays the difference. + * + * Exported because five different places need the same numbers — the gift card + * components, the method selector, ``, ``, and a + * consuming application deciding whether payment is still required. Deriving + * them separately is how those end up disagreeing. + * + * The rule lives in `derivePaymentSessionsState`, so an application's own data + * layer reaches the same answer without a hook. This is only the React binding. + */ +export function usePaymentSessionsState(): PaymentSessionsState { + const { order } = useContext(OrderContext) + return useMemo(() => derivePaymentSessionsState(order), [order]) +} + +export default usePaymentSessionsState diff --git a/packages/react-components/src/hooks/usePaymentsModel.ts b/packages/react-components/src/hooks/usePaymentsModel.ts new file mode 100644 index 00000000..1a850803 --- /dev/null +++ b/packages/react-components/src/hooks/usePaymentsModel.ts @@ -0,0 +1,25 @@ +import { getPaymentsModel, type PaymentsModel } from "@commercelayer/core-components" +import { useContext, useMemo } from "react" +import OrderContext from "#context/OrderContext" + +export type { PaymentsModel } + +/** + * Derive the Payments Model from the order in `OrderContext`. + * + * The rule itself — including the precedence of `available_payment_settings` + * over `available_payment_methods` — lives in `getPaymentsModel`, so an + * application's own data layer reaches the same answer without a hook. See + * that function for the reasoning; this is only the React binding. + * + * The derivation is pure: it never fetches and holds no state of its own, so it + * cannot drift from the order it describes. It relies on `` having + * registered `available_payment_settings` in the order include, which it does + * for every consumer. + */ +export function usePaymentsModel(): PaymentsModel { + const { order } = useContext(OrderContext) + return useMemo(() => getPaymentsModel(order), [order]) +} + +export default usePaymentsModel diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index a96b30ea..9a1ec5a3 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -66,6 +66,8 @@ export * from "#components/orders/OrderNumber" export * from "#components/orders/OrderStorage" export * from "#components/orders/PaymentMethodAmount" export * from "#components/orders/PlaceOrderButton" +export * from "#components/orders/PlaceOrderButtonPaymentSessions" +export * from "#components/orders/PlaceOrderButtonPaymentSource" export * from "#components/orders/PlaceOrderContainer" export * from "#components/orders/PrivacyAndTermsCheckbox" export * from "#components/orders/ShippingAmount" @@ -83,6 +85,17 @@ export * from "#components/payment_methods/PaymentMethodName" export * from "#components/payment_methods/PaymentMethodPrice" export * from "#components/payment_methods/PaymentMethodRadioButton" export * from "#components/payment_methods/PaymentMethodsContainer" +export * from "#components/payment_settings/PaymentSetting" +export * from "#components/payment_settings/PaymentSettingGiftCard" +export * from "#components/payment_settings/PaymentSettingGiftCardErrors" +export * from "#components/payment_settings/PaymentSettingGiftCardInput" +export * from "#components/payment_settings/PaymentSettingGiftCardList" +export * from "#components/payment_settings/PaymentSettingGiftCardListItem" +export * from "#components/payment_settings/PaymentSettingGiftCardRemoveButton" +export * from "#components/payment_settings/PaymentSettingGiftCardSubmitButton" +export * from "#components/payment_settings/PaymentSettingManualPayment" +export * from "#components/payment_settings/PaymentSettingName" +export * from "#components/payment_settings/PaymentSettingRadioButton" export * from "#components/payment_source/PaymentSource" export * from "#components/payment_source/PaymentSourceBrandIcon" export * from "#components/payment_source/PaymentSourceBrandName" @@ -116,5 +129,7 @@ export * from "#components/stock_transfers/StockTransferField" export * from "#hooks/useCommerceLayer" export * from "#hooks/useCustomerContainer" export * from "#hooks/useOrderContainer" +export * from "#hooks/usePaymentSessionsState" +export * from "#hooks/usePaymentsModel" export * from "#hooks/useTermsAndConditions" export * from "#typings/errors" diff --git a/packages/react-components/src/reducers/OrderReducer.ts b/packages/react-components/src/reducers/OrderReducer.ts index 2f48004c..8d2adec6 100644 --- a/packages/react-components/src/reducers/OrderReducer.ts +++ b/packages/react-components/src/reducers/OrderReducer.ts @@ -84,6 +84,14 @@ export type ResourceIncluded = | "payment_source" | "available_payment_methods" | "payment_method" + // `payment_sessions` model. `available_payment_settings` is registered by + // `` for every consumer: without it, an absent array cannot be told + // apart from one that was never asked for, and the Payments Model cannot be + // derived. The two nested ones are registered by `` only — + // they are the expensive part and nothing outside the payment UI needs them. + | "available_payment_settings" + | "payment_sessions.payment_setting" + | "payment_sessions.payment_authorization" type ResourceIncludedLoaded = Partial> diff --git a/packages/react-components/src/utils/organization.ts b/packages/react-components/src/utils/organization.ts index b0fc0a31..8ef88669 100644 --- a/packages/react-components/src/utils/organization.ts +++ b/packages/react-components/src/utils/organization.ts @@ -1,4 +1,5 @@ import { getSdk } from "@commercelayer/core-components" +import type { Organization } from "@commercelayer/sdk" import { type DefaultMfeConfig, getMfeConfig } from "@commercelayer/organization-config" import { useEffect, useState } from "react" import { jwt } from "./jwt" @@ -17,11 +18,36 @@ export async function getOrganizationConfig( ): Promise { const { market } = jwt(config.accessToken) const sdk = getSdk({ accessToken: config.accessToken }) - const organization = await sdk.organization.retrieve({ - fields: { - organizations: ["id", "config"], - }, - }) + + // A network failure here degrades to `null`, the value this function already + // returns when there is no config to give — and every caller reads the result + // optionally, falling back to a computed application link. + // + // Rejecting instead leaves eight call sites to catch the same thing, and none + // of them do: several are `useEffect` bodies and async click handlers, where + // the rejection goes unhandled and reaches the host application. Under + // `next dev` that raises the error overlay, which covers the page and absorbs + // every click — so one optional setting failing to load takes the whole + // checkout down. Only the request is guarded: a bad token or a malformed + // config is a fault to surface, not a blip to absorb. + let organization: Organization + try { + organization = await sdk.organization.retrieve({ + fields: { + organizations: ["id", "config"], + }, + }) + } catch (error) { + // `warn`, not `error`, and the level is load-bearing rather than a matter + // of taste: `next dev` promotes a `console.error` to an overlay issue whose + // dialog covers the page and absorbs every click. Reporting a condition we + // have just recovered from at that level takes the checkout down as surely + // as not catching it at all — verified by aborting this request and + // watching a click time out on an element that was plainly visible. + console.warn("Could not fetch the organization config, continuing without it:", error) + return null + } + return getMfeConfig({ jsonConfig: organization.config ?? {}, market: `market:id:${market.id.join(",")}`, diff --git a/packages/react-components/vitest.config.mts b/packages/react-components/vitest.config.mts index f590f48b..4e480298 100644 --- a/packages/react-components/vitest.config.mts +++ b/packages/react-components/vitest.config.mts @@ -11,7 +11,9 @@ export default defineConfig({ react: path.resolve(__dirname, "node_modules/react"), "react-dom": path.resolve(__dirname, "node_modules/react-dom"), swr: path.resolve(__dirname, "node_modules/swr"), - "@commercelayer/react-hooks-components": path.resolve("../react-hooks-components/src/index.ts"), + "@commercelayer/react-hooks-components": path.resolve( + "../react-hooks-components/src/index.ts" + ), "@commercelayer/core-components": path.resolve("../core-components/src/index.ts"), "#sdk": path.resolve(__dirname, "../core-components/src/sdk/index.ts"), "#types": path.resolve(__dirname, "../core-components/src/types/index.ts"), diff --git a/packages/react-hooks-components/package.json b/packages/react-hooks-components/package.json index fbdc782d..eceb8de7 100644 --- a/packages/react-hooks-components/package.json +++ b/packages/react-hooks-components/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@babel/core": "^8.0.1", - "@commercelayer/sdk": "8.0.0-beta.11", + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75", "@rolldown/plugin-babel": "^0.2.3", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96e80193..ed6c6327 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^8.0.0 version: 8.0.0 '@commercelayer/sdk': - specifier: 8.0.0-beta.11 - version: 8.0.0-beta.11 + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 @@ -100,12 +100,30 @@ importers: specifier: ^19.2.7 version: 19.2.8(react@19.2.8) devDependencies: + '@babel/core': + specifier: ^7.29.7 + version: 7.29.7(supports-color@7.2.0) + '@babel/preset-env': + specifier: ^7.29.7 + version: 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@chromatic-com/storybook': specifier: ^5.2.1 version: 5.3.0(storybook@10.5.7(@types/react@19.2.18)(react@19.2.8)) '@commercelayer/js-auth': specifier: ^8.0.0 version: 8.0.0 + '@commercelayer/sdk': + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 + '@mdx-js/react': + specifier: ^3.1.1 + version: 3.1.1(@types/react@19.2.18)(react@19.2.8) + '@storybook/addon-actions': + specifier: ^9.0.8 + version: 9.0.8 + '@storybook/addon-backgrounds': + specifier: ^9.0.8 + version: 9.0.8 '@storybook/addon-docs': specifier: ^10.5.3 version: 10.5.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(storybook@10.5.7(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(yaml@2.9.0)) @@ -173,8 +191,8 @@ importers: specifier: workspace:* version: link:../react-hooks-components '@commercelayer/sdk': - specifier: 8.0.0-beta.11 - version: 8.0.0-beta.11 + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 '@iframe-resizer/parent': specifier: ^5.5.9 version: 5.5.9 @@ -298,8 +316,8 @@ importers: specifier: ^8.0.1 version: 8.0.1 '@commercelayer/sdk': - specifier: 8.0.0-beta.11 - version: 8.0.0-beta.11 + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(yaml@2.9.0)) @@ -400,6 +418,10 @@ packages: resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} @@ -408,6 +430,23 @@ packages: resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -416,16 +455,60 @@ packages: resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@8.0.1': + resolution: {integrity: sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -450,6 +533,10 @@ packages: resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.7': resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} @@ -468,6 +555,383 @@ packages: engines: {node: ^22.18.0 || >=24.11.0} hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@8.0.1': + resolution: {integrity: sha512-0NEHanXmnFEnfT2dLKTXnu7m8GXFsnxRgteBC2aH21hYMBwAgxu5dcTdi/Eg+ToI1HbZe0CHwz4XRLgRNQhYoQ==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.8': + resolution: {integrity: sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.8': + resolution: {integrity: sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -616,8 +1080,9 @@ packages: resolution: {integrity: sha512-ZlgIhQx7vu89ZD3eOCSkCe7kNb5LgEGy0p4Gayi75sxfHfJRXKkoX2NC+nkp2pFosi4SBYhlQFcd/EtzStAQGw==} engines: {node: '>=18', pnpm: '>=7'} - '@commercelayer/sdk@8.0.0-beta.11': - resolution: {integrity: sha512-EhbpwW8GOsty1H3/YiEDzPZDKuvoXW9nMbrnkFQ5+sSgEiSkFXDM/tFxImkwagX7PbHKyHHaEBn5SoAPiNBwtw==} + '@commercelayer/sdk@https://pkg.pr.new/@commercelayer/sdk@c923f75': + resolution: {integrity: sha512-34a6up4pJ+8aqI4U5tvpF88BiQ0B+EQ8SGZwC9LwEq9njtmLcAN6B8j2M78C3LFF+Tiq0FoT8NvhvIPWMksReQ==, tarball: https://pkg.pr.new/@commercelayer/sdk@c923f75} + version: 8.0.0-beta.10 engines: {node: '>=20'} '@conventional-changelog/git-client@3.1.2': @@ -1742,6 +2207,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-actions@9.0.8': + resolution: {integrity: sha512-LFePu7PPnWN0Il/uoUpmA5T0J0C7d6haJIbg0pXrjxW2MQVSYXE4S4LSUz8fOImltBDV3xAl6tLPYHFj6VcrOA==} + + '@storybook/addon-backgrounds@9.0.8': + resolution: {integrity: sha512-4Vvr4wYHtiZ8UVWdCahK0XEMU4zNgInnNcVQ31YkUg41MVSY+aoZqtNuxOuRbFzUtjL9/aVsbY0Sg9Lp1/EJ4g==} + '@storybook/addon-docs@10.5.7': resolution: {integrity: sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==} peerDependencies: @@ -2333,6 +2804,21 @@ packages: axios@1.19.0: resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -2582,6 +3068,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -3390,6 +3880,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -4067,6 +4560,24 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4539,10 +5050,26 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4983,6 +5510,10 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -4999,10 +5530,48 @@ snapshots: lru-cache: 11.5.2 semver: 7.8.5 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@7.2.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.29.7': {} '@babel/helper-globals@8.0.0': {} + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.8(supports-color@7.2.0) @@ -5010,6 +5579,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@8.0.0': + dependencies: + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -5019,6 +5593,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@8.0.1(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@babel/traverse': 8.0.4 + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-plugin-utils@8.0.1(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@8.0.0': {} @@ -5031,6 +5647,14 @@ snapshots: '@babel/helper-validator-option@8.0.0': {} + '@babel/helper-wrap-function@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 @@ -5049,6 +5673,483 @@ snapshots: dependencies: '@babel/types': 8.0.4 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@8.0.1(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 8.0.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-validator-identifier': 8.0.4 + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-systemjs': 8.0.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-spread': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + core-js-compat: 3.50.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.8 + esutils: 2.0.3 + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -5184,7 +6285,7 @@ snapshots: '@commercelayer/js-auth': 7.4.2 merge-anything: 5.1.7 - '@commercelayer/sdk@8.0.0-beta.11': {} + '@commercelayer/sdk@https://pkg.pr.new/@commercelayer/sdk@c923f75': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.0)': dependencies: @@ -6115,6 +7216,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@storybook/addon-actions@9.0.8': {} + + '@storybook/addon-backgrounds@9.0.8': {} + '@storybook/addon-docs@10.5.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(storybook@10.5.7(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) @@ -6679,6 +7784,30 @@ snapshots: - debug - supports-color + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + core-js-compat: 3.50.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.8 @@ -6934,6 +8063,10 @@ snapshots: cookie@1.1.1: {} + core-js-compat@3.50.0: + dependencies: + browserslist: 4.28.8 + cosmiconfig@9.0.0(typescript@6.0.3): dependencies: env-paths: 2.2.1 @@ -7726,6 +8859,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.debounce@4.0.8: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -8824,6 +9959,27 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 @@ -9261,8 +10417,19 @@ snapshots: undici@8.10.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} + unicode-emoji-modifier-base@1.0.0: {} + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3