# Checkout

This page is the full checkout knowledge transfer: every listing, the
orchestrated Buy-click sequence, the amount-sync matrix, dead-PI recovery,
and the completion contract. Amounts are EUR decimal major units and the
**server totals engine is the only amount authority** — the client never
supplies an amount anywhere in this flow.

**Auth for every endpoint on this page:** anon `x-client-id` (checkout is
guest-capable; the one exception is the store setting
`accounts_mode='required'` — see [complete](#post-apistorecartsidcomplete--place-the-order)).

SDK module: `@cartbase/storefront/api/checkout` (+ `carts.completeCart` from
`@cartbase/storefront/api/carts`).

## The two checkout paths

**Orchestrated (recommended — what production storefronts
run):**

```
listShippingOptions(cart_id) ─┐  (render pickers)
listPaymentProviders(cart_id) ┘
        │  Buy click
        ▼
prepareCheckout(cart, {address, shipping_method_id, carrier_metadata,
                       payment_provider XOR payment_method_id})
        │                         ONE atomic call, compensated on failure
        ├─ pp_stripe → stripe.confirmPayment(client_secret) ─┐
        └─ a payment method (Bank transfer, COD…) ───────────┤
                                                             ▼
                                            completeCart(cart) → {type:"order", order}
```

While checkout stays mounted: `syncPaymentAmount()` after anything that
changes the total; `refreshPaymentIfTerminal()` from Stripe Elements
`loaderror` (never proactively).

**Manual (step-by-step, for custom flows):** `updateCart` (address+email) →
`addShippingMethod` → `createPaymentCollection` → `initiatePaymentSession`
→ `completeCart`. Both paths are executed against the live server below.

---

## GET /api/store/shipping-options — list (rule-filtered)

- **Purpose** — render the shipping picker. Pass `cart_id` — it prices the
  options in the cart currency AND gives the checkout-rules engine its
  evaluation context.
- **Auth** — anon `x-client-id`.
- **Request** — `GET ?cart_id=cart_…` (optional; without it `amount` is
  null and cart-dependent hide rules cannot match).
- **Response** — list envelope; `count`/`limit` = the full filtered list
  (no pagination):

```jsonc
{
  "shipping_options": [{
    "id": "so_…", "name": "Standard",
    "provider_id": null,          // the carrier, when the merchant bound one
    "service_zone_id": "sz_…", "shipping_profile_id": "sp_…",
    "data": null,
    "type": { "label": "Express" }, // the option's display label, or null
    "amount": 5,                 // the price the cart pays; null without cart_id
    "price_type": "flat"         // calculated-rate carriers not wired yet
  }],
  "count": 1, "offset": 0, "limit": 1
}
```

- **Eligibility** — four gates run server-side before an option lists:
  the option's shipping profile must be among the cart products' profiles;
  its zone must cover the shipping address; its visibility rules must
  pass; and a price must exist for the cart. Conditional rates (tiers by
  order amount, cart weight, or cart volume) resolve here — `amount` is
  always the tier the cart actually satisfies.
- **Errors** — 404 `cart_not_found` (bad `cart_id`).
- **SDK** — `checkout.listShippingOptions(client, {cart_id})`.
- **Components** — shipping picker, carrier/locker pickers (carrier
  metadata is collected client-side and handed to `prepareCheckout`).
- **Settings** — checkout rules (`target_type=shipping_option`) hide
  options server-side; `checkout_method_order` orders them; fail-open (a
  broken rule never bricks the listing). Hidden-method enforcement is at
  complete (`checkout_method_hidden`), not only here.

```bash
# Payable cart for the whole page (seeded Linen Shirt M + address + email).
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"checkout-doc-'"$RUN"'@example.test","currency_code":"eur",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$CART_ID" && test -n "$REGION_ID"

OPTS_JSON=$(curl -sf "$BASE/api/store/shipping-options?cart_id=$CART_ID" \
  -H "x-client-id: $CLIENT_ID")
echo "$OPTS_JSON" | grep -q '"price_type":"flat"'
# Pick a PRICED option — an option without a price row for the cart currency
# lists amount: null and cannot be calculated or prepared. (The listing is
# shared dev-tenant state; never grab blindly the first row.)
SO_ID=$(echo "$OPTS_JSON" | grep -o '"id":"so_[^"]*","name":"Flat Rate (Bulgaria)"' \
  | head -1 | cut -d'"' -f4)
test -n "$SO_ID"
```

---

## POST /api/store/shipping-options/:id/calculate — price one option

- **Purpose** — price a single option for a cart (kept for API parity /
  future calculated-rate carriers; the listing already returns `amount`).
- **Auth** — anon `x-client-id`.
- **Request** — `{cart_id, data?}` — `data` is provider-specific input,
  accepted and currently **ignored** (flat prices only).
- **Response** — `200 {shipping_option}` with `amount` in the cart
  currency.
- **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 400
  `shipping_price_missing` (no price row in the cart currency) |
  `validation_failed`.
- **SDK** — `checkout.calculateShippingOption(client, id, {cart_id})`.

```bash
curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"cart_id":"'"$CART_ID"'"}' | grep -q '"amount"'
```

---

## GET /api/store/payment-providers — list (rule-filtered)

- **Purpose** — render the payment-method picker.
- **Auth** — anon `x-client-id`.
- **Request** — `GET ?region_id=…&cart_id=…`, both optional. Without
  `region_id`: the tenant's enabled providers (global catalog ∩ tenant
  enablement). With it: providers linked to that region. `cart_id` feeds
  the rules engine — storefronts SHOULD pass it during checkout.
- **Response** — list envelope, full filtered list. Two entry shapes share
  the array: connected PROCESSORS (`{id}` — pp_stripe) and merchant
  payment METHODS (`{payment_method_id, name, kind, instructions,
  fee_amount, fee_label}` — "Bank transfer", the COD method). Methods
  carry no provider id anywhere; a fresh store with nothing configured
  gets an honestly empty list. With `region_id`, BOTH families filter by
  the region's claims (`region_payment_provider` /
  `region_payment_method` — availability is a per-region merchant
  choice):

```jsonc
{
  "payment_providers": [
    { "id": "pp_stripe", "is_enabled": true, "created_at": "…" },
    // one entry PER enabled, region-claimed merchant method:
    { "payment_method_id": "pm_…", "name": "Bank transfer",
      "kind": "manual", "instructions": "IBAN BG…",
      "fee_amount": null, "fee_label": null },
    { "payment_method_id": "pm_…", "name": "Cash on delivery",
      "kind": "cod", "instructions": null,
      "fee_amount": 4.99, "fee_label": "COD fee" }
    // pp_giftcard is INTERNAL tender and is never listed
  ],
  "count": 3, "offset": 0, "limit": 3
}
```

- **Selecting a method** — initiate the session with the method id alone:
  `POST …/payment-sessions { payment_method_id: "pm_…" }`. The server
  validates it (tenant's, enabled, claimed for the cart's region — 400
  otherwise), the session lands with `provider_id` NULL, and the method's
  identity is snapshotted into `session.data`
  (`payment_method_id/name/kind`) so payment surfaces say "Bank
  transfer" without a join. Render `instructions` to the shopper after
  selection and on the confirmation screen. `fee_amount`/`fee_label` let
  the checkout PREDICT the fee before the session exists — the authority
  stays the server totals (`payment_method_fee_total`).
- **Errors** — none beyond the standard envelope (empty list when nothing
  is enabled); initiate 400s on unknown/disabled/foreign method ids and
  `payment_method_not_in_region` for an unclaimed region.
- **SDK** — `checkout.listPaymentProviders(client, {region_id, cart_id})`.
- **Components** — payment picker.
- **Settings** — Stripe connects in Settings, Payments (provisions
  `pp_stripe` + credentials); payment methods live in the same screen
  (the COD switch + named manual methods, each with optional
  `fee_amount`/`fee_label` and per-region availability); checkout rules
  (`target_type=payment_method`, method entries participate under their
  `payment_method_id`) + `checkout_method_order`.

```bash
curl -sf "$BASE/api/store/payment-providers?cart_id=$CART_ID" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"payment_method_id"'
PAY_JSON=$(curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
  -H "x-client-id: $CLIENT_ID")
echo "$PAY_JSON" | grep -q '"payment_method_id"'
# The seeded Bank transfer method carries the rest of this page.
PM_ID=$(echo "$PAY_JSON" | grep -o '"payment_method_id":"pm_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$PM_ID"
```

---

## POST /api/store/carts/:id/prepare-checkout — the atomic Buy click

ONE call writes everything the customer toggled on /checkout, in the only
safe order: **address first** (option pricing reads the destination) →
**shipping method** → **payment collection** at the shipped total →
**payment session LAST** at the FINAL amount (real Stripe PaymentIntent,
idempotency key = session id; a plain NULL-provider row for a payment
method). A method's fee only applies once its session exists, so amounts
are re-synced after it. Fully compensated: any failure rolls back session
→ collection → shipping method → addresses/metadata to the pre-call
snapshot (recorded in the execution ledger, workflow `prepare-checkout`,
states done/reverted; failures also land in `checkout_error_logs`, step
`prepare-checkout`).

- **Auth** — anon `x-client-id`.
- **Request** (`.strict()`; DTO verbatim from
  `src/lib/checkout-orchestration/prepare.ts`) — all address fields
  required except `address_2`/`company`/`province`; `phone` is required
  (courier recovery channel). The tender is exactly ONE of
  `payment_provider` (a connected processor — `pp_stripe`, never
  `pp_giftcard`) or `payment_method_id` (a merchant method from the
  listing):

```jsonc
{
  "shipping_address": {
    "first_name": "Jane", "last_name": "Dow",
    "address_1": "Vitosha 1", "address_2": "",
    "company": "", "province": "",
    "city": "Sofia", "postal_code": "1000",
    "country_code": "bg", "phone": "+359888123456"
  },
  "shipping_method_id": "so_…",          // a shipping-option id
  "shipping_method_data": {},            // optional, stored on the method row
  "carrier_metadata": {                  // optional, opaque per-carrier keys
    "office_code": "X1", "office_name": "Center"
  },
  "payment_method_id": "pm_…",           // XOR payment_provider: "pp_stripe"
  "save_payment_method": false           // optional — subscription carts only;
                                         // same semantics + consent duty as on
                                         // payment-sessions below
}
```

  Billing mirrors shipping (own row). `carrier_metadata` merges into
  `cart.metadata`; keys written by the PREVIOUS prepare call are removed
  first (tracked under the reserved `_prepared_carrier_keys` marker) —
  switching carriers can never leak the old carrier's fields into the
  order.

- **Response** (verbatim `PrepareCheckoutResult`):

```jsonc
{
  "cart_id": "cart_…",
  "payment_collection_id": "pc_…",
  "client_secret": "pi_…_secret_…",   // Stripe only; null for method sessions AND zero-remainder carts
  "provider_id": null,                // the processor, when one was chosen
  "payment_method_id": "pm_…"         // the method, when one was chosen
}
```

- **Zero-remainder gift path** — when applied gift cards cover the whole
  total, the tender session is skipped entirely (`client_secret`,
  `provider_id` and `payment_method_id` come back null) and the cart
  completes on the gift session alone — no Stripe involved
  ([gift-cards.md](gift-cards.md)).
- **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
  `cart_completed`; 400 `validation_failed` | `invalid_provider`
  (pp_giftcard) | `shipping_price_missing` | `stripe_not_configured`.
- **SDK** — `checkout.prepareCheckout(client, cartId, input)`.
- **Components** — the checkout form's Buy button; carrier/locker pickers
  feed `carrier_metadata`.
- **Settings** — Stripe credentials (admin integrations), COD fee, gift
  cards; checkout rules are enforced at the listings and at complete, not
  here.

```bash
PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
        "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
        "country_code":"bg","phone":"+359888123456"},
       "shipping_method_id":"'"$SO_ID"'",
       "carrier_metadata":{"office_code":"X1"},
       "payment_method_id":"'"$PM_ID"'"}')
echo "$PREP_JSON" | grep -q '"payment_method_id":"'"$PM_ID"'"'
echo "$PREP_JSON" | grep -q '"client_secret":null'
PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
test -n "$PC_ID"

# Error contract: pp_giftcard is never a selectable provider.
GC_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
        "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
        "country_code":"bg","phone":"+359888123456"},
       "shipping_method_id":"'"$SO_ID"'",
       "payment_provider":"pp_giftcard"}')
echo "$GC_RES" | grep -q '"code":"invalid_provider"'
```

---

## POST /api/store/carts/:id/sync-payment-amount — align amounts in place

Aligns the pending provider session with the cart's CURRENT total, in
place when possible — the happy path returns the **same** `client_secret`
so `<Elements>` never remounts (the fix for the "InitiateCheckout fires
four times when I change shipping" bug class). Call after anything that
changes the total while checkout is mounted (quantity change, gift card
applied/removed, shipping switch).

- **Auth** — anon `x-client-id`.
- **Request** — `{provider_id? | payment_method_id?}` (`.strict()`; empty
  object fine, never both). Passing a DIFFERENT tender than the pending
  session's forces rotation to it.
- **Response matrix** (verbatim `src/lib/checkout-orchestration/sync.ts`):

| state | response |
|---|---|
| completed cart | `{"synced":false,"reason":"cart-completed"}` |
| no payment collection | `{"synced":false,"reason":"no_payment_collection"}` |
| no pending provider session (gift sessions excluded) | `{"synced":false,"reason":"no_pending_session"}` |
| tender matches, amount current | `{"synced":true,"rotated":false,"client_secret":…,"provider_id":…,"payment_method_id":…}` (no-op) |
| tender matches, amount drifted | in-place update (Stripe `paymentIntents.update`; plain field for method sessions) → `{"synced":true,"rotated":false,…}` — same secret |
| tender mismatch OR update refused (terminal PI) | rotation: old session retired (+ PI voided best-effort), fresh session at the new remainder → `{"synced":true,"rotated":true,…}` |

  A session's tender identity is its `provider_id` for processors and its
  snapshot `payment_method_id` for NULL-provider method sessions. Rotation
  retires the old session BEFORE recomputing so session-dependent totals
  (the method fee) settle for the NEW tender; a final resync pass aligns
  collection + session + PI. `client_secret` is null for method sessions.

- **Errors** — 404 `cart_not_found`; 400 `validation_failed` |
  `stripe_not_configured`. Failures land in `checkout_error_logs`
  (step `sync-payment-amount`).
- **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id?,
  payment_method_id?})`.
- **Components** — checkout totals watcher (debounced), payment-method
  switcher (pass the new tender id).

```bash
# No-drift no-op on the prepared method cart: same-session, not rotated.
SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$SYNC_JSON" | grep -q '"synced":true'
echo "$SYNC_JSON" | grep -q '"rotated":false'
echo "$SYNC_JSON" | grep -q '"payment_method_id":"pm_'
```

---

## POST /api/store/carts/:id/refresh-payment-if-terminal — dead-PI recovery

A Stripe PaymentIntent can die out-of-band (canceled in the Dashboard,
Stripe's 24h auto-cancel, captured externally) while the local session
stays `pending`; mounting Elements on the dead `client_secret` fails with
"PaymentIntent is in a terminal state". This route reconciles against
Stripe's ACTUAL PI and rotates a fresh session/PI only when the intent is
truly dead. **Call it reactively** — Elements `loaderror` / page mount for
aged carts — never proactively per render (the proactive variant caused a
production reload loop).

- **Auth** — anon `x-client-id`. No body.
- **Response** (verbatim `src/lib/checkout-orchestration/refresh.ts`) —
  rotated: `{"rotated":true,"reason":"pi-terminal"|"pi-missing",
  "previous_status":…}` (terminal = `succeeded`/`canceled`/
  `requires_capture`, or a `resource_missing` PI). Not rotated:
  `cart-completed` | `no-stripe-session` (also when the pending session is
  non-Stripe) | `no-pi-id` | `stripe-not-configured` | `still-usable`
  (+ `status`) | `stripe-error` (+ `error`) — **any transient Stripe error
  refuses to rotate** (rotating on transient failures was the loop bug).
  Every rotation writes an audit row (`checkout_error_logs` step
  `refresh-payment`, code `rotated`).
- **Errors** — 404 `cart_not_found`.
- **SDK** — `checkout.refreshPaymentIfTerminal(client, cartId)`.
- **Components** — Stripe Elements mount error handler.

```bash
# Method session ⇒ documented no-op reason (Stripe-specific rotation
# needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'
```

---

## POST /api/store/carts/:id/complete — place the order

The last call of every checkout. Sequence server-side: idempotency check →
CAS lock → validation → **checkout-rules completion guard** → inventory
reservation (kit-aware) → order creation (rows copied cart→order) →
**subscription contracts** (carts with plan lines: one contract per plan,
cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date;
guests are refused with 400 `customer_required`) → **payment authorization
LAST** (gift tender redeemed atomically first; real Stripe authorize for
`pp_stripe`; best-effort stub for method sessions) → `order.placed`
(+ `subscription.created` per contract) on the durable bus. Any failure
before authorize compensates fully (order deleted, contracts deleted,
inventory released, gift tender reversed, cart unlocked) — the cart stays
open and retryable. Note: subscription carts auto-save the card at the
payment-session step (see `save_payment_method` above) — by complete time
the mandate already exists.

- **Auth** — anon `x-client-id` (guest checkout). Store setting
  `accounts_mode='required'` → guest carts (no attached customer) get
  **403 `account_required`**; `disabled`/`optional` leave guests untouched.
- **Request** — `POST`, empty body.
- **Response** — `200 {"type":"order","order":{…}}` — the order with
  `summary` and flattened `items`. Idempotent: re-calling returns the SAME
  order; concurrent completes are serialized by the CAS lock (the loser
  returns the winner's order or 409 `cart_locked`).
  > Contract note (code wins over store-api.md): the documented
  > `{type:"cart", cart, error}` failure union is never returned — failures
  > throw the standard error envelope.
- **Errors** —
  - 400 validation: `cart_email_required` | `cart_empty` |
    `shipping_address_required` | `shipping_method_required` (only when a
    line `requires_shipping` — digital-only carts, e.g. digital gift
    cards, skip it) | `payment_collection_required` |
    `payment_session_required` | `insufficient_inventory` |
    `customer_required` (plan lines on a guest cart — subscribing needs an
    account; normally already refused at the payment-session step).
  - 400 `checkout_method_hidden` — **the checkout-rules security
    boundary**: every live payment session's chosen tender (the method's
    `payment_method_id` for NULL-provider sessions, else the provider id;
    internal `pp_giftcard` exempt) and every chosen shipping option is
    re-validated against the live rules with the full cart context. A
    stale session
    that picked a method before a rule started matching, or a hostile
    client that skipped the filtered listings, is rejected here and the
    rejection is recorded in `checkout_error_logs` (step `complete`).
  - 402 payment family: `requires_action` (3DS — `details.client_secret`
    carries the intent to confirm) | `payment_not_authorized` |
    `payment_not_initiated` (Stripe session without a PI — re-initiate) |
    `payment_incomplete` (gift tender no longer covers a session-less
    total) | `gift_card_insufficient_balance` (lost double-spend race) |
    `gift_card_not_redeemable`.
  - 403 `account_required`; 409 `cart_locked`.
- **SDK** — `carts.completeCart(client, cartId)` (module
  `@cartbase/storefront/api/carts`).
- **Components** — Buy button (orchestrated path), order-confirmation
  page.
- **Settings** — checkout rules; `accounts_mode`; the payment method fee
  (**timing**: a method's fee exists only while ITS live session does — it
  appears on the cart at prepare as `payment_method_fee_total`, rides
  `cart.total`, and is carried onto the order via
  `order_summaries.totals`, where waybill COD amounts read it); gift-card
  tender (zero-remainder carts complete on the gift session alone).

```bash
# Complete the prepared method cart → a real order, no Stripe env needed.
ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$ORDER_JSON" | grep -q '"type":"order"'
ORDER_ID=$(echo "$ORDER_JSON" | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$ORDER_ID"

# Idempotency: completing again returns the SAME order.
ORDER_ID2=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
test "$ORDER_ID" = "$ORDER_ID2"

# Post-completion contracts: mutations 409, sync/refresh report the reason.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/line-items" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
test "$STATUS" = 409
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -q '"reason":"cart-completed"'
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"cart-completed"'
```

---

## POST /api/store/checkout-errors — browser-side error reporting

The browser half of checkout error capture. The server logs every money-path
failure into the merchant's checkout error log on its own; failures that
happen only in the customer's browser — a Stripe.js confirm error, a 3DS
return that comes back not-succeeded, a place-order rejection — are reported
through this endpoint. `useCheckoutOrchestration` reports them **by
default**; you only call this yourself if you replaced the hook's `logError`
and still want the platform log.

Body (`.strict()`): `error_type` (≤64 chars), `message` (≤2000),
optional `cart_id`, optional `context` object (redacted values only — ids,
codes, flags; **never card data, never addresses**; oversized context is
stored as `{truncated: true}`). Answers `204` always on accepted input;
`400` malformed; `429` past 60 reports/store/minute. Fire-and-forget: the
SDK's `reportCheckoutError()` swallows every failure — reporting an error
must never take a checkout down.

## Modifying checkout — the laws

Checkout is **locked space** (LOCK_BOUNDARIES): critical-to-function code is
closed; customize through props, slots and tokens, never by editing the
package's files. What that means in practice:

- **Fork the layout, never the logic.** `useCheckoutOrchestration` is the
  one brain — session guard, amount sync, dead-PI recovery, 3DS return,
  compensation-aware completion. A fork of the hook once dropped the
  session guard and produced zombie Stripe sessions; the hook exists so
  that class of bug is structurally impossible. Build your own screens on
  top of the hook; do not reimplement it.
- **The server owns every amount.** The client never sends an amount;
  sessions charge `total − gift_card_total` computed server-side. Any
  checkout change that puts a number in a request body is wrong by
  construction.
- **Logging is not optional.** Money failures must reach the merchant: the
  hook's default sink does this. If you override `logError`, either call
  `reportCheckoutError()` yourself or accept that browser failures vanish —
  and that is a defect, not a preference.
- **Debug output is opt-in.** `useCheckoutOrchestration({ debug: true })`
  turns on verbose `[buy-click]` console output for local work. It prints
  the full prepare payload (name, phone, email, address), so it must never
  ship enabled.
- **Test against the real wire.** Every request/response shape on this page
  is executable against a store; a checkout change ships with its route
  driven end to end, error branches included.

## Manual path — collections + sessions (step-by-step)

### POST /api/store/payment-collections

- **Purpose** — ensure the cart's payment collection (ONE per cart,
  idempotent; the amount is refreshed to the CURRENT decorated total on
  every call).
- **Auth** — anon `x-client-id`.
- **Request** — `{cart_id}`.
  > Contract note (code wins over store-api.md): the contract's
  > `provider_id`/`data` fields are ignored — the provider is chosen when
  > initiating the session.
- **Response** — `201 {payment_collection}` when created, `200` when the
  existing one was refreshed. `{id, amount, currency_code,
  status:"not_paid", payment_sessions:[…]}`. The moment a collection
  exists, applied gift-card tender is composed as an internal
  `pp_giftcard` session.
- **Errors** — 404 `cart_not_found`; 400 `validation_failed`.
- **SDK** — `checkout.createPaymentCollection(client, {cart_id})`.

### POST /api/store/payment-collections/:id/payment-sessions

- **Purpose** — mint (or repair) the tender session — idempotent per
  tender. For Stripe the PaymentIntent is minted FIRST (idempotency key
  = session id) so a Stripe session row can never exist without its
  intent; amount drift syncs the PI in place; terminal PIs self-heal by
  rotation. Method sessions share ONE NULL-provider row per collection —
  switching methods updates its snapshot in place.
- **Auth** — anon `x-client-id`.
- **Request** — `{provider_id? XOR payment_method_id?, data?,
  save_payment_method?}` — exactly one tender. Keys the server owns
  (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
  `setup_future_usage`, and the method snapshot
  `payment_method_id/name/kind`) are stripped from `data` — they cannot
  be forged from the client.
- **`save_payment_method`** — saves the card for future off-session
  renewal charges: the server resolves the CART'S customer (never a
  client-supplied id), ensures a Stripe Customer for them, and mints the
  PaymentIntent with `setup_future_usage: "off_session"`. **Automatic for
  subscription carts**: when the cart carries plan lines the server
  applies the mandate even without the flag (a subscription cannot renew
  without it — the server owns the decision). Requires a logged-in
  customer — a guest cart is a 400 `customer_required` (subscribing needs
  an account), raised HERE, before any payment. **Consent**: the mandate
  moment — the storefront MUST render the saved-card consent text with
  the plan selection / next to the payment element (e.g. "Your card will
  be saved for future subscription charges"). Re-initiating an existing
  session with the flag (or after a plan line appears) upgrades the live
  intent in place (same `client_secret`). No card to save on method
  sessions (COD/manual subscriptions renew offline) — the account gate
  still applies. Never set the flag on ordinary checkouts.
- **Response** — `201 {payment_session}` when created, `200` when the
  existing one was returned/repaired: `{id, provider_id, amount,
  currency_code, status:"pending", authorized_at:null, data}` — for
  Stripe, `data` carries `payment_intent_id` + `client_secret` (mount
  Elements with it); with `save_payment_method` it also carries
  `setup_future_usage: "off_session"` + `stripe_customer_id`. For a
  method, `provider_id` is null and `data` carries the snapshot
  (`payment_method_id`, `payment_method_name`, `payment_method_kind`).
  The session `amount` is `collection.amount − gift_card_total` — the
  remainder.
- **Errors** — 404 `payment_collection_not_found`; 400 `invalid_provider`
  (pp_giftcard, or an id the catalog doesn't know — the dead pp_ ids land
  here) | `payment_provider_disabled` | `payment_provider_not_in_region` |
  `payment_method_not_in_region` | `stripe_not_configured` |
  `customer_required` | `validation_failed`.
- **SDK** — `checkout.initiatePaymentSession(client, pcId, {provider_id?,
  payment_method_id?, save_payment_method?})`.

### POST /api/store/carts/:id/shipping-methods

- **Purpose** — set the cart's shipping method manually (single-method
  model: the previous method rows are replaced).
- **Auth** — anon `x-client-id`.
- **Request** — `{option_id, data?}` (`.strict()`).
- **Response** — `200 {cart}` (decorated; `shipping_total` now non-zero).
- **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
  `cart_completed`; 400 `shipping_price_missing` | `validation_failed`.
- **SDK** — `checkout.addShippingMethod(client, cartId, {option_id})`.

```bash
# The whole manual path, executable: cart → method → collection → session → order.
CART2_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"checkout-doc-manual-'"$RUN"'@example.test",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}],
       "shipping_address":{"first_name":"Doc","last_name":"Manual",
         "address_1":"Vitosha 2","city":"Sofia","postal_code":"1000",
         "country_code":"bg","phone":"+359888123457"}}')
CART2_ID=$(echo "$CART2_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)

curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/shipping-methods" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"option_id":"'"$SO_ID"'"}' \
  | grep -q '"shipping_option_id":"'"$SO_ID"'"'

# Fresh cart, no collection yet → sync reports why it can't sync.
curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/sync-payment-amount" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -q '"reason":"no_payment_collection"'

PC2_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"cart_id":"'"$CART2_ID"'"}')
echo "$PC2_JSON" | grep -q '"status":"not_paid"'
PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)

SES_JSON=$(curl -sf -X POST \
  "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"payment_method_id":"'"$PM_ID"'"}')
echo "$SES_JSON" | grep -q '"provider_id":null'
echo "$SES_JSON" | grep -q '"payment_method_name"'
echo "$SES_JSON" | grep -q '"status":"pending"'

# Error contract: the internal gift tender is not initiable.
GCS_RES=$(curl -s -X POST \
  "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"provider_id":"pp_giftcard"}')
echo "$GCS_RES" | grep -q '"code":"invalid_provider"'

curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -q '"type":"order"'
```

```bash
# save_payment_method on a GUEST cart is refused — subscribing needs an
# account (provider-independent: fires before any Stripe call).
CART3_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"checkout-doc-guest-sub-'"$RUN"'@example.test",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART3_ID=$(echo "$CART3_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)

PC3_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"cart_id":"'"$CART3_ID"'"}')
PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)

curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"payment_method_id":"'"$PM_ID"'","save_payment_method":true}' \
  | grep -q '"code":"customer_required"'
```

---

## Stripe specifics (needs STRIPE credentials — not executable here)

```bash
# doc-noexec — requires the store's Stripe integration (admin-configured
# credentials); the mocked equivalents run in
# tests/store/checkout-orchestration.test.ts + checkout-stripe.test.ts.
PREP=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"shipping_address":{…},"shipping_method_id":"so_…","payment_provider":"pp_stripe"}')
# → {"cart_id":…,"payment_collection_id":"pc_…","client_secret":"pi_…_secret_…","provider_id":"pp_stripe"}
# Storefront: stripe.confirmPayment({clientSecret}) → POST …/complete.
# 3DS never-returned / redirect flows: the payment_intent.succeeded webhook
# (POST /api/webhooks/payment/complete-on-success, configured in Stripe)
# completes the cart server-side through the SAME complete flow.
```

Sync/refresh behavior with Stripe follows the matrices above: in-place
`paymentIntents.update` keeps the secret stable; provider mismatch or a
terminal PI rotates (old PI voided best-effort); `refreshPaymentIfTerminal`
rotates only on `succeeded`/`canceled`/`requires_capture`/missing.

## Admin-config-dependent contracts (documented, proven by the suite)

- **`checkout_method_hidden` (400)** — requires an admin checkout rule;
  exercised by `tests/store/checkout-rules.test.ts`.
- **`account_required` (403)** — requires `accounts_mode='required'` on
  the store; exercised by `tests/store/customer-accounts-policy.test.ts`.
- **Payment method fee** — any method may carry `fee_amount`/`fee_label`
  (the COD method included); exercised by
  `tests/store/payment-method-fee.test.ts` (method-switch switches the
  fee) and `tests/store/checkout-orchestration.test.ts` (fee-inclusive
  session on prepare with the COD method).

## Cleanup / accretion note

This page creates two carts and completes two method-tender orders on the
shared dev tenant — the same inert accretion the checkout test suites
produce (no store-facing delete exists for either; suites always create
their own carts/orders and never re-read foreign ones).
