create-cartbase 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +24 -24
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +376 -376
  7. package/template/app/docs/categories.md +194 -194
  8. package/template/app/docs/checkout.md +714 -714
  9. package/template/app/docs/components.md +44 -11
  10. package/template/app/docs/consent.md +91 -91
  11. package/template/app/docs/deploy.md +197 -197
  12. package/template/app/docs/gift-cards.md +153 -153
  13. package/template/app/docs/metaobjects.md +126 -126
  14. package/template/app/docs/orders.md +221 -221
  15. package/template/app/docs/products.md +51 -2
  16. package/template/app/docs/regions.md +269 -269
  17. package/template/app/docs/reviews.md +223 -223
  18. package/template/app/docs/search.md +227 -227
  19. package/template/app/docs/store.md +47 -47
  20. package/template/app/docs/subscriptions.md +148 -148
  21. package/template/app/docs/variables.md +315 -315
  22. package/template/app/package.json +1 -1
  23. package/template/app/postcss.config.cjs +11 -11
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
  25. package/template/app/src/app/checkout/page.tsx +48 -48
  26. package/template/app/src/app/globals.css +26 -26
  27. package/template/app/src/app/page.tsx +28 -28
  28. package/template/app/src/app/products/[handle]/page.tsx +87 -87
  29. package/template/app/src/app/providers.tsx +64 -64
  30. package/template/app/src/app/search/page.tsx +23 -23
  31. package/template/app/src/lib/browser-client.ts +35 -35
  32. package/template/app/src/lib/config.ts +41 -41
  33. package/template/app/src/lib/server-client.ts +25 -25
  34. package/template/app/src/lib/cart-actions.ts +0 -47
@@ -1,714 +1,714 @@
1
- # Checkout
2
-
3
- This page is the full checkout knowledge transfer: every listing, the
4
- orchestrated Buy-click sequence, the amount-sync matrix, dead-PI recovery,
5
- and the completion contract. Amounts are EUR decimal major units and the
6
- **server totals engine is the only amount authority** — the client never
7
- supplies an amount anywhere in this flow.
8
-
9
- **Auth for every endpoint on this page:** anon `x-client-id` (checkout is
10
- guest-capable; the one exception is the store setting
11
- `accounts_mode='required'` — see [complete](#post-apistorecartsidcomplete--place-the-order)).
12
-
13
- SDK module: `@cartbase/storefront/api/checkout` (+ `carts.completeCart` from
14
- `@cartbase/storefront/api/carts`).
15
-
16
- ## The two checkout paths
17
-
18
- **Orchestrated (recommended — what production storefronts
19
- run):**
20
-
21
- ```
22
- listShippingOptions(cart_id) ─┐ (render pickers)
23
- listPaymentProviders(cart_id) ┘
24
- │ Buy click
25
-
26
- prepareCheckout(cart, {address, shipping_method_id, carrier_metadata,
27
- payment_provider XOR payment_method_id})
28
- │ ONE atomic call, compensated on failure
29
- ├─ pp_stripe → stripe.confirmPayment(client_secret) ─┐
30
- └─ a payment method (Bank transfer, COD…) ───────────┤
31
-
32
- completeCart(cart) → {type:"order", order}
33
- ```
34
-
35
- While checkout stays mounted: `syncPaymentAmount()` after anything that
36
- changes the total; `refreshPaymentIfTerminal()` from Stripe Elements
37
- `loaderror` (never proactively).
38
-
39
- **Manual (step-by-step, for custom flows):** `updateCart` (address+email) →
40
- `addShippingMethod` → `createPaymentCollection` → `initiatePaymentSession`
41
- → `completeCart`. Both paths are executed against the live server below.
42
-
43
- ---
44
-
45
- ## GET /api/store/shipping-options — list (rule-filtered)
46
-
47
- - **Purpose** — render the shipping picker. Pass `cart_id` — it prices the
48
- options in the cart currency AND gives the checkout-rules engine its
49
- evaluation context.
50
- - **Auth** — anon `x-client-id`.
51
- - **Request** — `GET ?cart_id=cart_…` (optional; without it `amount` is
52
- null and cart-dependent hide rules cannot match).
53
- - **Response** — list envelope; `count`/`limit` = the full filtered list
54
- (no pagination):
55
-
56
- ```jsonc
57
- {
58
- "shipping_options": [{
59
- "id": "so_…", "name": "Standard",
60
- "provider_id": null, // the carrier, when the merchant bound one
61
- "service_zone_id": "sz_…", "shipping_profile_id": "sp_…",
62
- "data": null,
63
- "type": { "label": "Express" }, // the option's display label, or null
64
- "amount": 5, // the price the cart pays; null without cart_id
65
- "price_type": "flat" // calculated-rate carriers not wired yet
66
- }],
67
- "count": 1, "offset": 0, "limit": 1
68
- }
69
- ```
70
-
71
- - **Eligibility** — four gates run server-side before an option lists:
72
- the option's shipping profile must be among the cart products' profiles;
73
- its zone must cover the shipping address; its visibility rules must
74
- pass; and a price must exist for the cart. Conditional rates (tiers by
75
- order amount, cart weight, or cart volume) resolve here — `amount` is
76
- always the tier the cart actually satisfies.
77
- - **Errors** — 404 `cart_not_found` (bad `cart_id`).
78
- - **SDK** — `checkout.listShippingOptions(client, {cart_id})`.
79
- - **Components** — shipping picker, carrier/locker pickers (carrier
80
- metadata is collected client-side and handed to `prepareCheckout`).
81
- - **Settings** — checkout rules (`target_type=shipping_option`) hide
82
- options server-side; `checkout_method_order` orders them; fail-open (a
83
- broken rule never bricks the listing). Hidden-method enforcement is at
84
- complete (`checkout_method_hidden`), not only here.
85
-
86
- ```bash
87
- # Payable cart for the whole page (seeded Linen Shirt M + address + email).
88
- CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
89
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
90
- -d '{"email":"checkout-doc-'"$RUN"'@example.test","currency_code":"eur",
91
- "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
92
- CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
93
- REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
94
- test -n "$CART_ID" && test -n "$REGION_ID"
95
-
96
- OPTS_JSON=$(curl -sf "$BASE/api/store/shipping-options?cart_id=$CART_ID" \
97
- -H "x-client-id: $CLIENT_ID")
98
- echo "$OPTS_JSON" | grep -q '"price_type":"flat"'
99
- # Pick a PRICED option — an option without a price row for the cart currency
100
- # lists amount: null and cannot be calculated or prepared. (The listing is
101
- # shared dev-tenant state; never grab blindly the first row.)
102
- SO_ID=$(echo "$OPTS_JSON" | grep -o '"id":"so_[^"]*","name":"Flat Rate (Bulgaria)"' \
103
- | head -1 | cut -d'"' -f4)
104
- test -n "$SO_ID"
105
- ```
106
-
107
- ---
108
-
109
- ## POST /api/store/shipping-options/:id/calculate — price one option
110
-
111
- - **Purpose** — price a single option for a cart (kept for API parity /
112
- future calculated-rate carriers; the listing already returns `amount`).
113
- - **Auth** — anon `x-client-id`.
114
- - **Request** — `{cart_id, data?}` — `data` is provider-specific input,
115
- accepted and currently **ignored** (flat prices only).
116
- - **Response** — `200 {shipping_option}` with `amount` in the cart
117
- currency.
118
- - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 400
119
- `shipping_price_missing` (no price row in the cart currency) |
120
- `validation_failed`.
121
- - **SDK** — `checkout.calculateShippingOption(client, id, {cart_id})`.
122
-
123
- ```bash
124
- curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
125
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
126
- -d '{"cart_id":"'"$CART_ID"'"}' | grep -q '"amount"'
127
- ```
128
-
129
- ---
130
-
131
- ## GET /api/store/payment-providers — list (rule-filtered)
132
-
133
- - **Purpose** — render the payment-method picker.
134
- - **Auth** — anon `x-client-id`.
135
- - **Request** — `GET ?region_id=…&cart_id=…`, both optional. Without
136
- `region_id`: the tenant's enabled providers (global catalog ∩ tenant
137
- enablement). With it: providers linked to that region. `cart_id` feeds
138
- the rules engine — storefronts SHOULD pass it during checkout.
139
- - **Response** — list envelope, full filtered list. Two entry shapes share
140
- the array: connected PROCESSORS (`{id}` — pp_stripe) and merchant
141
- payment METHODS (`{payment_method_id, name, kind, instructions,
142
- fee_amount, fee_label}` — "Bank transfer", the COD method). Methods
143
- carry no provider id anywhere; a fresh store with nothing configured
144
- gets an honestly empty list. With `region_id`, BOTH families filter by
145
- the region's claims (`region_payment_provider` /
146
- `region_payment_method` — availability is a per-region merchant
147
- choice):
148
-
149
- ```jsonc
150
- {
151
- "payment_providers": [
152
- { "id": "pp_stripe", "is_enabled": true, "created_at": "…" },
153
- // one entry PER enabled, region-claimed merchant method:
154
- { "payment_method_id": "pm_…", "name": "Bank transfer",
155
- "kind": "manual", "instructions": "IBAN BG…",
156
- "fee_amount": null, "fee_label": null },
157
- { "payment_method_id": "pm_…", "name": "Cash on delivery",
158
- "kind": "cod", "instructions": null,
159
- "fee_amount": 4.99, "fee_label": "COD fee" }
160
- // pp_giftcard is INTERNAL tender and is never listed
161
- ],
162
- "count": 3, "offset": 0, "limit": 3
163
- }
164
- ```
165
-
166
- - **Selecting a method** — initiate the session with the method id alone:
167
- `POST …/payment-sessions { payment_method_id: "pm_…" }`. The server
168
- validates it (tenant's, enabled, claimed for the cart's region — 400
169
- otherwise), the session lands with `provider_id` NULL, and the method's
170
- identity is snapshotted into `session.data`
171
- (`payment_method_id/name/kind`) so payment surfaces say "Bank
172
- transfer" without a join. Render `instructions` to the shopper after
173
- selection and on the confirmation screen. `fee_amount`/`fee_label` let
174
- the checkout PREDICT the fee before the session exists — the authority
175
- stays the server totals (`payment_method_fee_total`).
176
- - **Errors** — none beyond the standard envelope (empty list when nothing
177
- is enabled); initiate 400s on unknown/disabled/foreign method ids and
178
- `payment_method_not_in_region` for an unclaimed region.
179
- - **SDK** — `checkout.listPaymentProviders(client, {region_id, cart_id})`.
180
- - **Components** — payment picker.
181
- - **Settings** — Stripe connects in Settings, Payments (provisions
182
- `pp_stripe` + credentials); payment methods live in the same screen
183
- (the COD switch + named manual methods, each with optional
184
- `fee_amount`/`fee_label` and per-region availability); checkout rules
185
- (`target_type=payment_method`, method entries participate under their
186
- `payment_method_id`) + `checkout_method_order`.
187
-
188
- ```bash
189
- curl -sf "$BASE/api/store/payment-providers?cart_id=$CART_ID" \
190
- -H "x-client-id: $CLIENT_ID" | grep -q '"payment_method_id"'
191
- PAY_JSON=$(curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
192
- -H "x-client-id: $CLIENT_ID")
193
- echo "$PAY_JSON" | grep -q '"payment_method_id"'
194
- # The seeded Bank transfer method carries the rest of this page.
195
- PM_ID=$(echo "$PAY_JSON" | grep -o '"payment_method_id":"pm_[^"]*"' | head -1 | cut -d'"' -f4)
196
- test -n "$PM_ID"
197
- ```
198
-
199
- ---
200
-
201
- ## POST /api/store/carts/:id/prepare-checkout — the atomic Buy click
202
-
203
- ONE call writes everything the customer toggled on /checkout, in the only
204
- safe order: **address first** (option pricing reads the destination) →
205
- **shipping method** → **payment collection** at the shipped total →
206
- **payment session LAST** at the FINAL amount (real Stripe PaymentIntent,
207
- idempotency key = session id; a plain NULL-provider row for a payment
208
- method). A method's fee only applies once its session exists, so amounts
209
- are re-synced after it. Fully compensated: any failure rolls back session
210
- → collection → shipping method → addresses/metadata to the pre-call
211
- snapshot (recorded in the execution ledger, workflow `prepare-checkout`,
212
- states done/reverted; failures also land in `checkout_error_logs`, step
213
- `prepare-checkout`).
214
-
215
- - **Auth** — anon `x-client-id`.
216
- - **Request** (`.strict()`; DTO verbatim from
217
- `src/lib/checkout-orchestration/prepare.ts`) — all address fields
218
- required except `address_2`/`company`/`province`; `phone` is required
219
- (courier recovery channel). The tender is exactly ONE of
220
- `payment_provider` (a connected processor — `pp_stripe`, never
221
- `pp_giftcard`) or `payment_method_id` (a merchant method from the
222
- listing):
223
-
224
- ```jsonc
225
- {
226
- "shipping_address": {
227
- "first_name": "Jane", "last_name": "Dow",
228
- "address_1": "Vitosha 1", "address_2": "",
229
- "company": "", "province": "",
230
- "city": "Sofia", "postal_code": "1000",
231
- "country_code": "bg", "phone": "+359888123456"
232
- },
233
- "shipping_method_id": "so_…", // a shipping-option id
234
- "shipping_method_data": {}, // optional, stored on the method row
235
- "carrier_metadata": { // optional, opaque per-carrier keys
236
- "office_code": "X1", "office_name": "Center"
237
- },
238
- "payment_method_id": "pm_…", // XOR payment_provider: "pp_stripe"
239
- "save_payment_method": false // optional — subscription carts only;
240
- // same semantics + consent duty as on
241
- // payment-sessions below
242
- }
243
- ```
244
-
245
- Billing mirrors shipping (own row). `carrier_metadata` merges into
246
- `cart.metadata`; keys written by the PREVIOUS prepare call are removed
247
- first (tracked under the reserved `_prepared_carrier_keys` marker) —
248
- switching carriers can never leak the old carrier's fields into the
249
- order.
250
-
251
- - **Response** (verbatim `PrepareCheckoutResult`):
252
-
253
- ```jsonc
254
- {
255
- "cart_id": "cart_…",
256
- "payment_collection_id": "pc_…",
257
- "client_secret": "pi_…_secret_…", // Stripe only; null for method sessions AND zero-remainder carts
258
- "provider_id": null, // the processor, when one was chosen
259
- "payment_method_id": "pm_…" // the method, when one was chosen
260
- }
261
- ```
262
-
263
- - **Zero-remainder gift path** — when applied gift cards cover the whole
264
- total, the tender session is skipped entirely (`client_secret`,
265
- `provider_id` and `payment_method_id` come back null) and the cart
266
- completes on the gift session alone — no Stripe involved
267
- ([gift-cards.md](gift-cards.md)).
268
- - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
269
- `cart_completed`; 400 `validation_failed` | `invalid_provider`
270
- (pp_giftcard) | `shipping_price_missing` | `stripe_not_configured`.
271
- - **SDK** — `checkout.prepareCheckout(client, cartId, input)`.
272
- - **Components** — the checkout form's Buy button; carrier/locker pickers
273
- feed `carrier_metadata`.
274
- - **Settings** — Stripe credentials (admin integrations), COD fee, gift
275
- cards; checkout rules are enforced at the listings and at complete, not
276
- here.
277
-
278
- ```bash
279
- PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
280
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
281
- -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
282
- "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
283
- "country_code":"bg","phone":"+359888123456"},
284
- "shipping_method_id":"'"$SO_ID"'",
285
- "carrier_metadata":{"office_code":"X1"},
286
- "payment_method_id":"'"$PM_ID"'"}')
287
- echo "$PREP_JSON" | grep -q '"payment_method_id":"'"$PM_ID"'"'
288
- echo "$PREP_JSON" | grep -q '"client_secret":null'
289
- PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
290
- test -n "$PC_ID"
291
-
292
- # Error contract: pp_giftcard is never a selectable provider.
293
- GC_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
294
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
295
- -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
296
- "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
297
- "country_code":"bg","phone":"+359888123456"},
298
- "shipping_method_id":"'"$SO_ID"'",
299
- "payment_provider":"pp_giftcard"}')
300
- echo "$GC_RES" | grep -q '"code":"invalid_provider"'
301
- ```
302
-
303
- ---
304
-
305
- ## POST /api/store/carts/:id/sync-payment-amount — align amounts in place
306
-
307
- Aligns the pending provider session with the cart's CURRENT total, in
308
- place when possible — the happy path returns the **same** `client_secret`
309
- so `<Elements>` never remounts (the fix for the "InitiateCheckout fires
310
- four times when I change shipping" bug class). Call after anything that
311
- changes the total while checkout is mounted (quantity change, gift card
312
- applied/removed, shipping switch).
313
-
314
- - **Auth** — anon `x-client-id`.
315
- - **Request** — `{provider_id? | payment_method_id?}` (`.strict()`; empty
316
- object fine, never both). Passing a DIFFERENT tender than the pending
317
- session's forces rotation to it.
318
- - **Response matrix** (verbatim `src/lib/checkout-orchestration/sync.ts`):
319
-
320
- | state | response |
321
- |---|---|
322
- | completed cart | `{"synced":false,"reason":"cart-completed"}` |
323
- | no payment collection | `{"synced":false,"reason":"no_payment_collection"}` |
324
- | no pending provider session (gift sessions excluded) | `{"synced":false,"reason":"no_pending_session"}` |
325
- | tender matches, amount current | `{"synced":true,"rotated":false,"client_secret":…,"provider_id":…,"payment_method_id":…}` (no-op) |
326
- | tender matches, amount drifted | in-place update (Stripe `paymentIntents.update`; plain field for method sessions) → `{"synced":true,"rotated":false,…}` — same secret |
327
- | 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,…}` |
328
-
329
- A session's tender identity is its `provider_id` for processors and its
330
- snapshot `payment_method_id` for NULL-provider method sessions. Rotation
331
- retires the old session BEFORE recomputing so session-dependent totals
332
- (the method fee) settle for the NEW tender; a final resync pass aligns
333
- collection + session + PI. `client_secret` is null for method sessions.
334
-
335
- - **Errors** — 404 `cart_not_found`; 400 `validation_failed` |
336
- `stripe_not_configured`. Failures land in `checkout_error_logs`
337
- (step `sync-payment-amount`).
338
- - **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id?,
339
- payment_method_id?})`.
340
- - **Components** — checkout totals watcher (debounced), payment-method
341
- switcher (pass the new tender id).
342
-
343
- ```bash
344
- # No-drift no-op on the prepared method cart: same-session, not rotated.
345
- SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
346
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
347
- echo "$SYNC_JSON" | grep -q '"synced":true'
348
- echo "$SYNC_JSON" | grep -q '"rotated":false'
349
- echo "$SYNC_JSON" | grep -q '"payment_method_id":"pm_'
350
- ```
351
-
352
- ---
353
-
354
- ## POST /api/store/carts/:id/refresh-payment-if-terminal — dead-PI recovery
355
-
356
- A Stripe PaymentIntent can die out-of-band (canceled in the Dashboard,
357
- Stripe's 24h auto-cancel, captured externally) while the local session
358
- stays `pending`; mounting Elements on the dead `client_secret` fails with
359
- "PaymentIntent is in a terminal state". This route reconciles against
360
- Stripe's ACTUAL PI and rotates a fresh session/PI only when the intent is
361
- truly dead. **Call it reactively** — Elements `loaderror` / page mount for
362
- aged carts — never proactively per render (the proactive variant caused a
363
- production reload loop).
364
-
365
- - **Auth** — anon `x-client-id`. No body.
366
- - **Response** (verbatim `src/lib/checkout-orchestration/refresh.ts`) —
367
- rotated: `{"rotated":true,"reason":"pi-terminal"|"pi-missing",
368
- "previous_status":…}` (terminal = `succeeded`/`canceled`/
369
- `requires_capture`, or a `resource_missing` PI). Not rotated:
370
- `cart-completed` | `no-stripe-session` (also when the pending session is
371
- non-Stripe) | `no-pi-id` | `stripe-not-configured` | `still-usable`
372
- (+ `status`) | `stripe-error` (+ `error`) — **any transient Stripe error
373
- refuses to rotate** (rotating on transient failures was the loop bug).
374
- Every rotation writes an audit row (`checkout_error_logs` step
375
- `refresh-payment`, code `rotated`).
376
- - **Errors** — 404 `cart_not_found`.
377
- - **SDK** — `checkout.refreshPaymentIfTerminal(client, cartId)`.
378
- - **Components** — Stripe Elements mount error handler.
379
-
380
- ```bash
381
- # Method session ⇒ documented no-op reason (Stripe-specific rotation
382
- # needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
383
- curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
384
- -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'
385
- ```
386
-
387
- ---
388
-
389
- ## POST /api/store/carts/:id/complete — place the order
390
-
391
- The last call of every checkout. Sequence server-side: idempotency check →
392
- CAS lock → validation → **checkout-rules completion guard** → inventory
393
- reservation (kit-aware) → order creation (rows copied cart→order) →
394
- **subscription contracts** (carts with plan lines: one contract per plan,
395
- cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date;
396
- guests are refused with 400 `customer_required`) → **payment authorization
397
- LAST** (gift tender redeemed atomically first; real Stripe authorize for
398
- `pp_stripe`; best-effort stub for method sessions) → `order.placed`
399
- (+ `subscription.created` per contract) on the durable bus. Any failure
400
- before authorize compensates fully (order deleted, contracts deleted,
401
- inventory released, gift tender reversed, cart unlocked) — the cart stays
402
- open and retryable. Note: subscription carts auto-save the card at the
403
- payment-session step (see `save_payment_method` above) — by complete time
404
- the mandate already exists.
405
-
406
- - **Auth** — anon `x-client-id` (guest checkout). Store setting
407
- `accounts_mode='required'` → guest carts (no attached customer) get
408
- **403 `account_required`**; `disabled`/`optional` leave guests untouched.
409
- - **Request** — `POST`, empty body.
410
- - **Response** — `200 {"type":"order","order":{…}}` — the order with
411
- `summary` and flattened `items`. Idempotent: re-calling returns the SAME
412
- order; concurrent completes are serialized by the CAS lock (the loser
413
- returns the winner's order or 409 `cart_locked`).
414
- > Contract note (code wins over store-api.md): the documented
415
- > `{type:"cart", cart, error}` failure union is never returned — failures
416
- > throw the standard error envelope.
417
- - **Errors** —
418
- - 400 validation: `cart_email_required` | `cart_empty` |
419
- `shipping_address_required` | `shipping_method_required` (only when a
420
- line `requires_shipping` — digital-only carts, e.g. digital gift
421
- cards, skip it) | `payment_collection_required` |
422
- `payment_session_required` | `insufficient_inventory` |
423
- `customer_required` (plan lines on a guest cart — subscribing needs an
424
- account; normally already refused at the payment-session step).
425
- - 400 `checkout_method_hidden` — **the checkout-rules security
426
- boundary**: every live payment session's chosen tender (the method's
427
- `payment_method_id` for NULL-provider sessions, else the provider id;
428
- internal `pp_giftcard` exempt) and every chosen shipping option is
429
- re-validated against the live rules with the full cart context. A
430
- stale session
431
- that picked a method before a rule started matching, or a hostile
432
- client that skipped the filtered listings, is rejected here and the
433
- rejection is recorded in `checkout_error_logs` (step `complete`).
434
- - 402 payment family: `requires_action` (3DS — `details.client_secret`
435
- carries the intent to confirm) | `payment_not_authorized` |
436
- `payment_not_initiated` (Stripe session without a PI — re-initiate) |
437
- `payment_incomplete` (gift tender no longer covers a session-less
438
- total) | `gift_card_insufficient_balance` (lost double-spend race) |
439
- `gift_card_not_redeemable`.
440
- - 403 `account_required`; 409 `cart_locked`.
441
- - **SDK** — `carts.completeCart(client, cartId)` (module
442
- `@cartbase/storefront/api/carts`).
443
- - **Components** — Buy button (orchestrated path), order-confirmation
444
- page.
445
- - **Settings** — checkout rules; `accounts_mode`; the payment method fee
446
- (**timing**: a method's fee exists only while ITS live session does — it
447
- appears on the cart at prepare as `payment_method_fee_total`, rides
448
- `cart.total`, and is carried onto the order via
449
- `order_summaries.totals`, where waybill COD amounts read it); gift-card
450
- tender (zero-remainder carts complete on the gift session alone).
451
-
452
- ```bash
453
- # Complete the prepared method cart → a real order, no Stripe env needed.
454
- ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
455
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
456
- echo "$ORDER_JSON" | grep -q '"type":"order"'
457
- ORDER_ID=$(echo "$ORDER_JSON" | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
458
- test -n "$ORDER_ID"
459
-
460
- # Idempotency: completing again returns the SAME order.
461
- ORDER_ID2=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
462
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
463
- | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
464
- test "$ORDER_ID" = "$ORDER_ID2"
465
-
466
- # Post-completion contracts: mutations 409, sync/refresh report the reason.
467
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
468
- "$BASE/api/store/carts/$CART_ID/line-items" \
469
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
470
- -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
471
- test "$STATUS" = 409
472
- curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
473
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
474
- | grep -q '"reason":"cart-completed"'
475
- curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
476
- -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"cart-completed"'
477
- ```
478
-
479
- ---
480
-
481
- ## POST /api/store/checkout-errors — browser-side error reporting
482
-
483
- The browser half of checkout error capture. The server logs every money-path
484
- failure into the merchant's checkout error log on its own; failures that
485
- happen only in the customer's browser — a Stripe.js confirm error, a 3DS
486
- return that comes back not-succeeded, a place-order rejection — are reported
487
- through this endpoint. `useCheckoutOrchestration` reports them **by
488
- default**; you only call this yourself if you replaced the hook's `logError`
489
- and still want the platform log.
490
-
491
- Body (`.strict()`): `error_type` (≤64 chars), `message` (≤2000),
492
- optional `cart_id`, optional `context` object (redacted values only — ids,
493
- codes, flags; **never card data, never addresses**; oversized context is
494
- stored as `{truncated: true}`). Answers `204` always on accepted input;
495
- `400` malformed; `429` past 60 reports/store/minute. Fire-and-forget: the
496
- SDK's `reportCheckoutError()` swallows every failure — reporting an error
497
- must never take a checkout down.
498
-
499
- ## Modifying checkout — the laws
500
-
501
- Checkout is **locked space** (LOCK_BOUNDARIES): critical-to-function code is
502
- closed; customize through props, slots and tokens, never by editing the
503
- package's files. What that means in practice:
504
-
505
- - **Fork the layout, never the logic.** `useCheckoutOrchestration` is the
506
- one brain — session guard, amount sync, dead-PI recovery, 3DS return,
507
- compensation-aware completion. A fork of the hook once dropped the
508
- session guard and produced zombie Stripe sessions; the hook exists so
509
- that class of bug is structurally impossible. Build your own screens on
510
- top of the hook; do not reimplement it.
511
- - **The server owns every amount.** The client never sends an amount;
512
- sessions charge `total − gift_card_total` computed server-side. Any
513
- checkout change that puts a number in a request body is wrong by
514
- construction.
515
- - **Logging is not optional.** Money failures must reach the merchant: the
516
- hook's default sink does this. If you override `logError`, either call
517
- `reportCheckoutError()` yourself or accept that browser failures vanish —
518
- and that is a defect, not a preference.
519
- - **Debug output is opt-in.** `useCheckoutOrchestration({ debug: true })`
520
- turns on verbose `[buy-click]` console output for local work. It prints
521
- the full prepare payload (name, phone, email, address), so it must never
522
- ship enabled.
523
- - **Test against the real wire.** Every request/response shape on this page
524
- is executable against a store; a checkout change ships with its route
525
- driven end to end, error branches included.
526
-
527
- ## Manual path — collections + sessions (step-by-step)
528
-
529
- ### POST /api/store/payment-collections
530
-
531
- - **Purpose** — ensure the cart's payment collection (ONE per cart,
532
- idempotent; the amount is refreshed to the CURRENT decorated total on
533
- every call).
534
- - **Auth** — anon `x-client-id`.
535
- - **Request** — `{cart_id}`.
536
- > Contract note (code wins over store-api.md): the contract's
537
- > `provider_id`/`data` fields are ignored — the provider is chosen when
538
- > initiating the session.
539
- - **Response** — `201 {payment_collection}` when created, `200` when the
540
- existing one was refreshed. `{id, amount, currency_code,
541
- status:"not_paid", payment_sessions:[…]}`. The moment a collection
542
- exists, applied gift-card tender is composed as an internal
543
- `pp_giftcard` session.
544
- - **Errors** — 404 `cart_not_found`; 400 `validation_failed`.
545
- - **SDK** — `checkout.createPaymentCollection(client, {cart_id})`.
546
-
547
- ### POST /api/store/payment-collections/:id/payment-sessions
548
-
549
- - **Purpose** — mint (or repair) the tender session — idempotent per
550
- tender. For Stripe the PaymentIntent is minted FIRST (idempotency key
551
- = session id) so a Stripe session row can never exist without its
552
- intent; amount drift syncs the PI in place; terminal PIs self-heal by
553
- rotation. Method sessions share ONE NULL-provider row per collection —
554
- switching methods updates its snapshot in place.
555
- - **Auth** — anon `x-client-id`.
556
- - **Request** — `{provider_id? XOR payment_method_id?, data?,
557
- save_payment_method?}` — exactly one tender. Keys the server owns
558
- (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
559
- `setup_future_usage`, and the method snapshot
560
- `payment_method_id/name/kind`) are stripped from `data` — they cannot
561
- be forged from the client.
562
- - **`save_payment_method`** — saves the card for future off-session
563
- renewal charges: the server resolves the CART'S customer (never a
564
- client-supplied id), ensures a Stripe Customer for them, and mints the
565
- PaymentIntent with `setup_future_usage: "off_session"`. **Automatic for
566
- subscription carts**: when the cart carries plan lines the server
567
- applies the mandate even without the flag (a subscription cannot renew
568
- without it — the server owns the decision). Requires a logged-in
569
- customer — a guest cart is a 400 `customer_required` (subscribing needs
570
- an account), raised HERE, before any payment. **Consent**: the mandate
571
- moment — the storefront MUST render the saved-card consent text with
572
- the plan selection / next to the payment element (e.g. "Your card will
573
- be saved for future subscription charges"). Re-initiating an existing
574
- session with the flag (or after a plan line appears) upgrades the live
575
- intent in place (same `client_secret`). No card to save on method
576
- sessions (COD/manual subscriptions renew offline) — the account gate
577
- still applies. Never set the flag on ordinary checkouts.
578
- - **Response** — `201 {payment_session}` when created, `200` when the
579
- existing one was returned/repaired: `{id, provider_id, amount,
580
- currency_code, status:"pending", authorized_at:null, data}` — for
581
- Stripe, `data` carries `payment_intent_id` + `client_secret` (mount
582
- Elements with it); with `save_payment_method` it also carries
583
- `setup_future_usage: "off_session"` + `stripe_customer_id`. For a
584
- method, `provider_id` is null and `data` carries the snapshot
585
- (`payment_method_id`, `payment_method_name`, `payment_method_kind`).
586
- The session `amount` is `collection.amount − gift_card_total` — the
587
- remainder.
588
- - **Errors** — 404 `payment_collection_not_found`; 400 `invalid_provider`
589
- (pp_giftcard, or an id the catalog doesn't know — the dead pp_ ids land
590
- here) | `payment_provider_disabled` | `payment_provider_not_in_region` |
591
- `payment_method_not_in_region` | `stripe_not_configured` |
592
- `customer_required` | `validation_failed`.
593
- - **SDK** — `checkout.initiatePaymentSession(client, pcId, {provider_id?,
594
- payment_method_id?, save_payment_method?})`.
595
-
596
- ### POST /api/store/carts/:id/shipping-methods
597
-
598
- - **Purpose** — set the cart's shipping method manually (single-method
599
- model: the previous method rows are replaced).
600
- - **Auth** — anon `x-client-id`.
601
- - **Request** — `{option_id, data?}` (`.strict()`).
602
- - **Response** — `200 {cart}` (decorated; `shipping_total` now non-zero).
603
- - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
604
- `cart_completed`; 400 `shipping_price_missing` | `validation_failed`.
605
- - **SDK** — `checkout.addShippingMethod(client, cartId, {option_id})`.
606
-
607
- ```bash
608
- # The whole manual path, executable: cart → method → collection → session → order.
609
- CART2_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
610
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
611
- -d '{"email":"checkout-doc-manual-'"$RUN"'@example.test",
612
- "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}],
613
- "shipping_address":{"first_name":"Doc","last_name":"Manual",
614
- "address_1":"Vitosha 2","city":"Sofia","postal_code":"1000",
615
- "country_code":"bg","phone":"+359888123457"}}')
616
- CART2_ID=$(echo "$CART2_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
617
-
618
- curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/shipping-methods" \
619
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
620
- -d '{"option_id":"'"$SO_ID"'"}' \
621
- | grep -q '"shipping_option_id":"'"$SO_ID"'"'
622
-
623
- # Fresh cart, no collection yet → sync reports why it can't sync.
624
- curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/sync-payment-amount" \
625
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
626
- | grep -q '"reason":"no_payment_collection"'
627
-
628
- PC2_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
629
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
630
- -d '{"cart_id":"'"$CART2_ID"'"}')
631
- echo "$PC2_JSON" | grep -q '"status":"not_paid"'
632
- PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
633
-
634
- SES_JSON=$(curl -sf -X POST \
635
- "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
636
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
637
- -d '{"payment_method_id":"'"$PM_ID"'"}')
638
- echo "$SES_JSON" | grep -q '"provider_id":null'
639
- echo "$SES_JSON" | grep -q '"payment_method_name"'
640
- echo "$SES_JSON" | grep -q '"status":"pending"'
641
-
642
- # Error contract: the internal gift tender is not initiable.
643
- GCS_RES=$(curl -s -X POST \
644
- "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
645
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
646
- -d '{"provider_id":"pp_giftcard"}')
647
- echo "$GCS_RES" | grep -q '"code":"invalid_provider"'
648
-
649
- curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/complete" \
650
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
651
- | grep -q '"type":"order"'
652
- ```
653
-
654
- ```bash
655
- # save_payment_method on a GUEST cart is refused — subscribing needs an
656
- # account (provider-independent: fires before any Stripe call).
657
- CART3_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
658
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
659
- -d '{"email":"checkout-doc-guest-sub-'"$RUN"'@example.test",
660
- "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
661
- CART3_ID=$(echo "$CART3_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
662
-
663
- PC3_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
664
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
665
- -d '{"cart_id":"'"$CART3_ID"'"}')
666
- PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
667
-
668
- curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
669
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
670
- -d '{"payment_method_id":"'"$PM_ID"'","save_payment_method":true}' \
671
- | grep -q '"code":"customer_required"'
672
- ```
673
-
674
- ---
675
-
676
- ## Stripe specifics (needs STRIPE credentials — not executable here)
677
-
678
- ```bash
679
- # doc-noexec — requires the store's Stripe integration (admin-configured
680
- # credentials); the mocked equivalents run in
681
- # tests/store/checkout-orchestration.test.ts + checkout-stripe.test.ts.
682
- PREP=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
683
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
684
- -d '{"shipping_address":{…},"shipping_method_id":"so_…","payment_provider":"pp_stripe"}')
685
- # → {"cart_id":…,"payment_collection_id":"pc_…","client_secret":"pi_…_secret_…","provider_id":"pp_stripe"}
686
- # Storefront: stripe.confirmPayment({clientSecret}) → POST …/complete.
687
- # 3DS never-returned / redirect flows: the payment_intent.succeeded webhook
688
- # (POST /api/webhooks/payment/complete-on-success, configured in Stripe)
689
- # completes the cart server-side through the SAME complete flow.
690
- ```
691
-
692
- Sync/refresh behavior with Stripe follows the matrices above: in-place
693
- `paymentIntents.update` keeps the secret stable; provider mismatch or a
694
- terminal PI rotates (old PI voided best-effort); `refreshPaymentIfTerminal`
695
- rotates only on `succeeded`/`canceled`/`requires_capture`/missing.
696
-
697
- ## Admin-config-dependent contracts (documented, proven by the suite)
698
-
699
- - **`checkout_method_hidden` (400)** — requires an admin checkout rule;
700
- exercised by `tests/store/checkout-rules.test.ts`.
701
- - **`account_required` (403)** — requires `accounts_mode='required'` on
702
- the store; exercised by `tests/store/customer-accounts-policy.test.ts`.
703
- - **Payment method fee** — any method may carry `fee_amount`/`fee_label`
704
- (the COD method included); exercised by
705
- `tests/store/payment-method-fee.test.ts` (method-switch switches the
706
- fee) and `tests/store/checkout-orchestration.test.ts` (fee-inclusive
707
- session on prepare with the COD method).
708
-
709
- ## Cleanup / accretion note
710
-
711
- This page creates two carts and completes two method-tender orders on the
712
- shared dev tenant — the same inert accretion the checkout test suites
713
- produce (no store-facing delete exists for either; suites always create
714
- their own carts/orders and never re-read foreign ones).
1
+ # Checkout
2
+
3
+ This page is the full checkout knowledge transfer: every listing, the
4
+ orchestrated Buy-click sequence, the amount-sync matrix, dead-PI recovery,
5
+ and the completion contract. Amounts are EUR decimal major units and the
6
+ **server totals engine is the only amount authority** — the client never
7
+ supplies an amount anywhere in this flow.
8
+
9
+ **Auth for every endpoint on this page:** anon `x-client-id` (checkout is
10
+ guest-capable; the one exception is the store setting
11
+ `accounts_mode='required'` — see [complete](#post-apistorecartsidcomplete--place-the-order)).
12
+
13
+ SDK module: `@cartbase/storefront/api/checkout` (+ `carts.completeCart` from
14
+ `@cartbase/storefront/api/carts`).
15
+
16
+ ## The two checkout paths
17
+
18
+ **Orchestrated (recommended — what production storefronts
19
+ run):**
20
+
21
+ ```
22
+ listShippingOptions(cart_id) ─┐ (render pickers)
23
+ listPaymentProviders(cart_id) ┘
24
+ │ Buy click
25
+
26
+ prepareCheckout(cart, {address, shipping_method_id, carrier_metadata,
27
+ payment_provider XOR payment_method_id})
28
+ │ ONE atomic call, compensated on failure
29
+ ├─ pp_stripe → stripe.confirmPayment(client_secret) ─┐
30
+ └─ a payment method (Bank transfer, COD…) ───────────┤
31
+
32
+ completeCart(cart) → {type:"order", order}
33
+ ```
34
+
35
+ While checkout stays mounted: `syncPaymentAmount()` after anything that
36
+ changes the total; `refreshPaymentIfTerminal()` from Stripe Elements
37
+ `loaderror` (never proactively).
38
+
39
+ **Manual (step-by-step, for custom flows):** `updateCart` (address+email) →
40
+ `addShippingMethod` → `createPaymentCollection` → `initiatePaymentSession`
41
+ → `completeCart`. Both paths are executed against the live server below.
42
+
43
+ ---
44
+
45
+ ## GET /api/store/shipping-options — list (rule-filtered)
46
+
47
+ - **Purpose** — render the shipping picker. Pass `cart_id` — it prices the
48
+ options in the cart currency AND gives the checkout-rules engine its
49
+ evaluation context.
50
+ - **Auth** — anon `x-client-id`.
51
+ - **Request** — `GET ?cart_id=cart_…` (optional; without it `amount` is
52
+ null and cart-dependent hide rules cannot match).
53
+ - **Response** — list envelope; `count`/`limit` = the full filtered list
54
+ (no pagination):
55
+
56
+ ```jsonc
57
+ {
58
+ "shipping_options": [{
59
+ "id": "so_…", "name": "Standard",
60
+ "provider_id": null, // the carrier, when the merchant bound one
61
+ "service_zone_id": "sz_…", "shipping_profile_id": "sp_…",
62
+ "data": null,
63
+ "type": { "label": "Express" }, // the option's display label, or null
64
+ "amount": 5, // the price the cart pays; null without cart_id
65
+ "price_type": "flat" // calculated-rate carriers not wired yet
66
+ }],
67
+ "count": 1, "offset": 0, "limit": 1
68
+ }
69
+ ```
70
+
71
+ - **Eligibility** — four gates run server-side before an option lists:
72
+ the option's shipping profile must be among the cart products' profiles;
73
+ its zone must cover the shipping address; its visibility rules must
74
+ pass; and a price must exist for the cart. Conditional rates (tiers by
75
+ order amount, cart weight, or cart volume) resolve here — `amount` is
76
+ always the tier the cart actually satisfies.
77
+ - **Errors** — 404 `cart_not_found` (bad `cart_id`).
78
+ - **SDK** — `checkout.listShippingOptions(client, {cart_id})`.
79
+ - **Components** — shipping picker, carrier/locker pickers (carrier
80
+ metadata is collected client-side and handed to `prepareCheckout`).
81
+ - **Settings** — checkout rules (`target_type=shipping_option`) hide
82
+ options server-side; `checkout_method_order` orders them; fail-open (a
83
+ broken rule never bricks the listing). Hidden-method enforcement is at
84
+ complete (`checkout_method_hidden`), not only here.
85
+
86
+ ```bash
87
+ # Payable cart for the whole page (seeded Linen Shirt M + address + email).
88
+ CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
89
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
90
+ -d '{"email":"checkout-doc-'"$RUN"'@example.test","currency_code":"eur",
91
+ "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
92
+ CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
93
+ REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
94
+ test -n "$CART_ID" && test -n "$REGION_ID"
95
+
96
+ OPTS_JSON=$(curl -sf "$BASE/api/store/shipping-options?cart_id=$CART_ID" \
97
+ -H "x-client-id: $CLIENT_ID")
98
+ echo "$OPTS_JSON" | grep -q '"price_type":"flat"'
99
+ # Pick a PRICED option — an option without a price row for the cart currency
100
+ # lists amount: null and cannot be calculated or prepared. (The listing is
101
+ # shared dev-tenant state; never grab blindly the first row.)
102
+ SO_ID=$(echo "$OPTS_JSON" | grep -o '"id":"so_[^"]*","name":"Flat Rate (Bulgaria)"' \
103
+ | head -1 | cut -d'"' -f4)
104
+ test -n "$SO_ID"
105
+ ```
106
+
107
+ ---
108
+
109
+ ## POST /api/store/shipping-options/:id/calculate — price one option
110
+
111
+ - **Purpose** — price a single option for a cart (kept for API parity /
112
+ future calculated-rate carriers; the listing already returns `amount`).
113
+ - **Auth** — anon `x-client-id`.
114
+ - **Request** — `{cart_id, data?}` — `data` is provider-specific input,
115
+ accepted and currently **ignored** (flat prices only).
116
+ - **Response** — `200 {shipping_option}` with `amount` in the cart
117
+ currency.
118
+ - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 400
119
+ `shipping_price_missing` (no price row in the cart currency) |
120
+ `validation_failed`.
121
+ - **SDK** — `checkout.calculateShippingOption(client, id, {cart_id})`.
122
+
123
+ ```bash
124
+ curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
125
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
126
+ -d '{"cart_id":"'"$CART_ID"'"}' | grep -q '"amount"'
127
+ ```
128
+
129
+ ---
130
+
131
+ ## GET /api/store/payment-providers — list (rule-filtered)
132
+
133
+ - **Purpose** — render the payment-method picker.
134
+ - **Auth** — anon `x-client-id`.
135
+ - **Request** — `GET ?region_id=…&cart_id=…`, both optional. Without
136
+ `region_id`: the tenant's enabled providers (global catalog ∩ tenant
137
+ enablement). With it: providers linked to that region. `cart_id` feeds
138
+ the rules engine — storefronts SHOULD pass it during checkout.
139
+ - **Response** — list envelope, full filtered list. Two entry shapes share
140
+ the array: connected PROCESSORS (`{id}` — pp_stripe) and merchant
141
+ payment METHODS (`{payment_method_id, name, kind, instructions,
142
+ fee_amount, fee_label}` — "Bank transfer", the COD method). Methods
143
+ carry no provider id anywhere; a fresh store with nothing configured
144
+ gets an honestly empty list. With `region_id`, BOTH families filter by
145
+ the region's claims (`region_payment_provider` /
146
+ `region_payment_method` — availability is a per-region merchant
147
+ choice):
148
+
149
+ ```jsonc
150
+ {
151
+ "payment_providers": [
152
+ { "id": "pp_stripe", "is_enabled": true, "created_at": "…" },
153
+ // one entry PER enabled, region-claimed merchant method:
154
+ { "payment_method_id": "pm_…", "name": "Bank transfer",
155
+ "kind": "manual", "instructions": "IBAN BG…",
156
+ "fee_amount": null, "fee_label": null },
157
+ { "payment_method_id": "pm_…", "name": "Cash on delivery",
158
+ "kind": "cod", "instructions": null,
159
+ "fee_amount": 4.99, "fee_label": "COD fee" }
160
+ // pp_giftcard is INTERNAL tender and is never listed
161
+ ],
162
+ "count": 3, "offset": 0, "limit": 3
163
+ }
164
+ ```
165
+
166
+ - **Selecting a method** — initiate the session with the method id alone:
167
+ `POST …/payment-sessions { payment_method_id: "pm_…" }`. The server
168
+ validates it (tenant's, enabled, claimed for the cart's region — 400
169
+ otherwise), the session lands with `provider_id` NULL, and the method's
170
+ identity is snapshotted into `session.data`
171
+ (`payment_method_id/name/kind`) so payment surfaces say "Bank
172
+ transfer" without a join. Render `instructions` to the shopper after
173
+ selection and on the confirmation screen. `fee_amount`/`fee_label` let
174
+ the checkout PREDICT the fee before the session exists — the authority
175
+ stays the server totals (`payment_method_fee_total`).
176
+ - **Errors** — none beyond the standard envelope (empty list when nothing
177
+ is enabled); initiate 400s on unknown/disabled/foreign method ids and
178
+ `payment_method_not_in_region` for an unclaimed region.
179
+ - **SDK** — `checkout.listPaymentProviders(client, {region_id, cart_id})`.
180
+ - **Components** — payment picker.
181
+ - **Settings** — Stripe connects in Settings, Payments (provisions
182
+ `pp_stripe` + credentials); payment methods live in the same screen
183
+ (the COD switch + named manual methods, each with optional
184
+ `fee_amount`/`fee_label` and per-region availability); checkout rules
185
+ (`target_type=payment_method`, method entries participate under their
186
+ `payment_method_id`) + `checkout_method_order`.
187
+
188
+ ```bash
189
+ curl -sf "$BASE/api/store/payment-providers?cart_id=$CART_ID" \
190
+ -H "x-client-id: $CLIENT_ID" | grep -q '"payment_method_id"'
191
+ PAY_JSON=$(curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
192
+ -H "x-client-id: $CLIENT_ID")
193
+ echo "$PAY_JSON" | grep -q '"payment_method_id"'
194
+ # The seeded Bank transfer method carries the rest of this page.
195
+ PM_ID=$(echo "$PAY_JSON" | grep -o '"payment_method_id":"pm_[^"]*"' | head -1 | cut -d'"' -f4)
196
+ test -n "$PM_ID"
197
+ ```
198
+
199
+ ---
200
+
201
+ ## POST /api/store/carts/:id/prepare-checkout — the atomic Buy click
202
+
203
+ ONE call writes everything the customer toggled on /checkout, in the only
204
+ safe order: **address first** (option pricing reads the destination) →
205
+ **shipping method** → **payment collection** at the shipped total →
206
+ **payment session LAST** at the FINAL amount (real Stripe PaymentIntent,
207
+ idempotency key = session id; a plain NULL-provider row for a payment
208
+ method). A method's fee only applies once its session exists, so amounts
209
+ are re-synced after it. Fully compensated: any failure rolls back session
210
+ → collection → shipping method → addresses/metadata to the pre-call
211
+ snapshot (recorded in the execution ledger, workflow `prepare-checkout`,
212
+ states done/reverted; failures also land in `checkout_error_logs`, step
213
+ `prepare-checkout`).
214
+
215
+ - **Auth** — anon `x-client-id`.
216
+ - **Request** (`.strict()`; DTO verbatim from
217
+ `src/lib/checkout-orchestration/prepare.ts`) — all address fields
218
+ required except `address_2`/`company`/`province`; `phone` is required
219
+ (courier recovery channel). The tender is exactly ONE of
220
+ `payment_provider` (a connected processor — `pp_stripe`, never
221
+ `pp_giftcard`) or `payment_method_id` (a merchant method from the
222
+ listing):
223
+
224
+ ```jsonc
225
+ {
226
+ "shipping_address": {
227
+ "first_name": "Jane", "last_name": "Dow",
228
+ "address_1": "Vitosha 1", "address_2": "",
229
+ "company": "", "province": "",
230
+ "city": "Sofia", "postal_code": "1000",
231
+ "country_code": "bg", "phone": "+359888123456"
232
+ },
233
+ "shipping_method_id": "so_…", // a shipping-option id
234
+ "shipping_method_data": {}, // optional, stored on the method row
235
+ "carrier_metadata": { // optional, opaque per-carrier keys
236
+ "office_code": "X1", "office_name": "Center"
237
+ },
238
+ "payment_method_id": "pm_…", // XOR payment_provider: "pp_stripe"
239
+ "save_payment_method": false // optional — subscription carts only;
240
+ // same semantics + consent duty as on
241
+ // payment-sessions below
242
+ }
243
+ ```
244
+
245
+ Billing mirrors shipping (own row). `carrier_metadata` merges into
246
+ `cart.metadata`; keys written by the PREVIOUS prepare call are removed
247
+ first (tracked under the reserved `_prepared_carrier_keys` marker) —
248
+ switching carriers can never leak the old carrier's fields into the
249
+ order.
250
+
251
+ - **Response** (verbatim `PrepareCheckoutResult`):
252
+
253
+ ```jsonc
254
+ {
255
+ "cart_id": "cart_…",
256
+ "payment_collection_id": "pc_…",
257
+ "client_secret": "pi_…_secret_…", // Stripe only; null for method sessions AND zero-remainder carts
258
+ "provider_id": null, // the processor, when one was chosen
259
+ "payment_method_id": "pm_…" // the method, when one was chosen
260
+ }
261
+ ```
262
+
263
+ - **Zero-remainder gift path** — when applied gift cards cover the whole
264
+ total, the tender session is skipped entirely (`client_secret`,
265
+ `provider_id` and `payment_method_id` come back null) and the cart
266
+ completes on the gift session alone — no Stripe involved
267
+ ([gift-cards.md](gift-cards.md)).
268
+ - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
269
+ `cart_completed`; 400 `validation_failed` | `invalid_provider`
270
+ (pp_giftcard) | `shipping_price_missing` | `stripe_not_configured`.
271
+ - **SDK** — `checkout.prepareCheckout(client, cartId, input)`.
272
+ - **Components** — the checkout form's Buy button; carrier/locker pickers
273
+ feed `carrier_metadata`.
274
+ - **Settings** — Stripe credentials (admin integrations), COD fee, gift
275
+ cards; checkout rules are enforced at the listings and at complete, not
276
+ here.
277
+
278
+ ```bash
279
+ PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
280
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
281
+ -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
282
+ "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
283
+ "country_code":"bg","phone":"+359888123456"},
284
+ "shipping_method_id":"'"$SO_ID"'",
285
+ "carrier_metadata":{"office_code":"X1"},
286
+ "payment_method_id":"'"$PM_ID"'"}')
287
+ echo "$PREP_JSON" | grep -q '"payment_method_id":"'"$PM_ID"'"'
288
+ echo "$PREP_JSON" | grep -q '"client_secret":null'
289
+ PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
290
+ test -n "$PC_ID"
291
+
292
+ # Error contract: pp_giftcard is never a selectable provider.
293
+ GC_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
294
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
295
+ -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
296
+ "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
297
+ "country_code":"bg","phone":"+359888123456"},
298
+ "shipping_method_id":"'"$SO_ID"'",
299
+ "payment_provider":"pp_giftcard"}')
300
+ echo "$GC_RES" | grep -q '"code":"invalid_provider"'
301
+ ```
302
+
303
+ ---
304
+
305
+ ## POST /api/store/carts/:id/sync-payment-amount — align amounts in place
306
+
307
+ Aligns the pending provider session with the cart's CURRENT total, in
308
+ place when possible — the happy path returns the **same** `client_secret`
309
+ so `<Elements>` never remounts (the fix for the "InitiateCheckout fires
310
+ four times when I change shipping" bug class). Call after anything that
311
+ changes the total while checkout is mounted (quantity change, gift card
312
+ applied/removed, shipping switch).
313
+
314
+ - **Auth** — anon `x-client-id`.
315
+ - **Request** — `{provider_id? | payment_method_id?}` (`.strict()`; empty
316
+ object fine, never both). Passing a DIFFERENT tender than the pending
317
+ session's forces rotation to it.
318
+ - **Response matrix** (verbatim `src/lib/checkout-orchestration/sync.ts`):
319
+
320
+ | state | response |
321
+ |---|---|
322
+ | completed cart | `{"synced":false,"reason":"cart-completed"}` |
323
+ | no payment collection | `{"synced":false,"reason":"no_payment_collection"}` |
324
+ | no pending provider session (gift sessions excluded) | `{"synced":false,"reason":"no_pending_session"}` |
325
+ | tender matches, amount current | `{"synced":true,"rotated":false,"client_secret":…,"provider_id":…,"payment_method_id":…}` (no-op) |
326
+ | tender matches, amount drifted | in-place update (Stripe `paymentIntents.update`; plain field for method sessions) → `{"synced":true,"rotated":false,…}` — same secret |
327
+ | 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,…}` |
328
+
329
+ A session's tender identity is its `provider_id` for processors and its
330
+ snapshot `payment_method_id` for NULL-provider method sessions. Rotation
331
+ retires the old session BEFORE recomputing so session-dependent totals
332
+ (the method fee) settle for the NEW tender; a final resync pass aligns
333
+ collection + session + PI. `client_secret` is null for method sessions.
334
+
335
+ - **Errors** — 404 `cart_not_found`; 400 `validation_failed` |
336
+ `stripe_not_configured`. Failures land in `checkout_error_logs`
337
+ (step `sync-payment-amount`).
338
+ - **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id?,
339
+ payment_method_id?})`.
340
+ - **Components** — checkout totals watcher (debounced), payment-method
341
+ switcher (pass the new tender id).
342
+
343
+ ```bash
344
+ # No-drift no-op on the prepared method cart: same-session, not rotated.
345
+ SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
346
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
347
+ echo "$SYNC_JSON" | grep -q '"synced":true'
348
+ echo "$SYNC_JSON" | grep -q '"rotated":false'
349
+ echo "$SYNC_JSON" | grep -q '"payment_method_id":"pm_'
350
+ ```
351
+
352
+ ---
353
+
354
+ ## POST /api/store/carts/:id/refresh-payment-if-terminal — dead-PI recovery
355
+
356
+ A Stripe PaymentIntent can die out-of-band (canceled in the Dashboard,
357
+ Stripe's 24h auto-cancel, captured externally) while the local session
358
+ stays `pending`; mounting Elements on the dead `client_secret` fails with
359
+ "PaymentIntent is in a terminal state". This route reconciles against
360
+ Stripe's ACTUAL PI and rotates a fresh session/PI only when the intent is
361
+ truly dead. **Call it reactively** — Elements `loaderror` / page mount for
362
+ aged carts — never proactively per render (the proactive variant caused a
363
+ production reload loop).
364
+
365
+ - **Auth** — anon `x-client-id`. No body.
366
+ - **Response** (verbatim `src/lib/checkout-orchestration/refresh.ts`) —
367
+ rotated: `{"rotated":true,"reason":"pi-terminal"|"pi-missing",
368
+ "previous_status":…}` (terminal = `succeeded`/`canceled`/
369
+ `requires_capture`, or a `resource_missing` PI). Not rotated:
370
+ `cart-completed` | `no-stripe-session` (also when the pending session is
371
+ non-Stripe) | `no-pi-id` | `stripe-not-configured` | `still-usable`
372
+ (+ `status`) | `stripe-error` (+ `error`) — **any transient Stripe error
373
+ refuses to rotate** (rotating on transient failures was the loop bug).
374
+ Every rotation writes an audit row (`checkout_error_logs` step
375
+ `refresh-payment`, code `rotated`).
376
+ - **Errors** — 404 `cart_not_found`.
377
+ - **SDK** — `checkout.refreshPaymentIfTerminal(client, cartId)`.
378
+ - **Components** — Stripe Elements mount error handler.
379
+
380
+ ```bash
381
+ # Method session ⇒ documented no-op reason (Stripe-specific rotation
382
+ # needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
383
+ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
384
+ -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'
385
+ ```
386
+
387
+ ---
388
+
389
+ ## POST /api/store/carts/:id/complete — place the order
390
+
391
+ The last call of every checkout. Sequence server-side: idempotency check →
392
+ CAS lock → validation → **checkout-rules completion guard** → inventory
393
+ reservation (kit-aware) → order creation (rows copied cart→order) →
394
+ **subscription contracts** (carts with plan lines: one contract per plan,
395
+ cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date;
396
+ guests are refused with 400 `customer_required`) → **payment authorization
397
+ LAST** (gift tender redeemed atomically first; real Stripe authorize for
398
+ `pp_stripe`; best-effort stub for method sessions) → `order.placed`
399
+ (+ `subscription.created` per contract) on the durable bus. Any failure
400
+ before authorize compensates fully (order deleted, contracts deleted,
401
+ inventory released, gift tender reversed, cart unlocked) — the cart stays
402
+ open and retryable. Note: subscription carts auto-save the card at the
403
+ payment-session step (see `save_payment_method` above) — by complete time
404
+ the mandate already exists.
405
+
406
+ - **Auth** — anon `x-client-id` (guest checkout). Store setting
407
+ `accounts_mode='required'` → guest carts (no attached customer) get
408
+ **403 `account_required`**; `disabled`/`optional` leave guests untouched.
409
+ - **Request** — `POST`, empty body.
410
+ - **Response** — `200 {"type":"order","order":{…}}` — the order with
411
+ `summary` and flattened `items`. Idempotent: re-calling returns the SAME
412
+ order; concurrent completes are serialized by the CAS lock (the loser
413
+ returns the winner's order or 409 `cart_locked`).
414
+ > Contract note (code wins over store-api.md): the documented
415
+ > `{type:"cart", cart, error}` failure union is never returned — failures
416
+ > throw the standard error envelope.
417
+ - **Errors** —
418
+ - 400 validation: `cart_email_required` | `cart_empty` |
419
+ `shipping_address_required` | `shipping_method_required` (only when a
420
+ line `requires_shipping` — digital-only carts, e.g. digital gift
421
+ cards, skip it) | `payment_collection_required` |
422
+ `payment_session_required` | `insufficient_inventory` |
423
+ `customer_required` (plan lines on a guest cart — subscribing needs an
424
+ account; normally already refused at the payment-session step).
425
+ - 400 `checkout_method_hidden` — **the checkout-rules security
426
+ boundary**: every live payment session's chosen tender (the method's
427
+ `payment_method_id` for NULL-provider sessions, else the provider id;
428
+ internal `pp_giftcard` exempt) and every chosen shipping option is
429
+ re-validated against the live rules with the full cart context. A
430
+ stale session
431
+ that picked a method before a rule started matching, or a hostile
432
+ client that skipped the filtered listings, is rejected here and the
433
+ rejection is recorded in `checkout_error_logs` (step `complete`).
434
+ - 402 payment family: `requires_action` (3DS — `details.client_secret`
435
+ carries the intent to confirm) | `payment_not_authorized` |
436
+ `payment_not_initiated` (Stripe session without a PI — re-initiate) |
437
+ `payment_incomplete` (gift tender no longer covers a session-less
438
+ total) | `gift_card_insufficient_balance` (lost double-spend race) |
439
+ `gift_card_not_redeemable`.
440
+ - 403 `account_required`; 409 `cart_locked`.
441
+ - **SDK** — `carts.completeCart(client, cartId)` (module
442
+ `@cartbase/storefront/api/carts`).
443
+ - **Components** — Buy button (orchestrated path), order-confirmation
444
+ page.
445
+ - **Settings** — checkout rules; `accounts_mode`; the payment method fee
446
+ (**timing**: a method's fee exists only while ITS live session does — it
447
+ appears on the cart at prepare as `payment_method_fee_total`, rides
448
+ `cart.total`, and is carried onto the order via
449
+ `order_summaries.totals`, where waybill COD amounts read it); gift-card
450
+ tender (zero-remainder carts complete on the gift session alone).
451
+
452
+ ```bash
453
+ # Complete the prepared method cart → a real order, no Stripe env needed.
454
+ ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
455
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
456
+ echo "$ORDER_JSON" | grep -q '"type":"order"'
457
+ ORDER_ID=$(echo "$ORDER_JSON" | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
458
+ test -n "$ORDER_ID"
459
+
460
+ # Idempotency: completing again returns the SAME order.
461
+ ORDER_ID2=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
462
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
463
+ | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
464
+ test "$ORDER_ID" = "$ORDER_ID2"
465
+
466
+ # Post-completion contracts: mutations 409, sync/refresh report the reason.
467
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
468
+ "$BASE/api/store/carts/$CART_ID/line-items" \
469
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
470
+ -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
471
+ test "$STATUS" = 409
472
+ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
473
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
474
+ | grep -q '"reason":"cart-completed"'
475
+ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
476
+ -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"cart-completed"'
477
+ ```
478
+
479
+ ---
480
+
481
+ ## POST /api/store/checkout-errors — browser-side error reporting
482
+
483
+ The browser half of checkout error capture. The server logs every money-path
484
+ failure into the merchant's checkout error log on its own; failures that
485
+ happen only in the customer's browser — a Stripe.js confirm error, a 3DS
486
+ return that comes back not-succeeded, a place-order rejection — are reported
487
+ through this endpoint. `useCheckoutOrchestration` reports them **by
488
+ default**; you only call this yourself if you replaced the hook's `logError`
489
+ and still want the platform log.
490
+
491
+ Body (`.strict()`): `error_type` (≤64 chars), `message` (≤2000),
492
+ optional `cart_id`, optional `context` object (redacted values only — ids,
493
+ codes, flags; **never card data, never addresses**; oversized context is
494
+ stored as `{truncated: true}`). Answers `204` always on accepted input;
495
+ `400` malformed; `429` past 60 reports/store/minute. Fire-and-forget: the
496
+ SDK's `reportCheckoutError()` swallows every failure — reporting an error
497
+ must never take a checkout down.
498
+
499
+ ## Modifying checkout — the laws
500
+
501
+ Checkout is **locked space** (LOCK_BOUNDARIES): critical-to-function code is
502
+ closed; customize through props, slots and tokens, never by editing the
503
+ package's files. What that means in practice:
504
+
505
+ - **Fork the layout, never the logic.** `useCheckoutOrchestration` is the
506
+ one brain — session guard, amount sync, dead-PI recovery, 3DS return,
507
+ compensation-aware completion. A fork of the hook once dropped the
508
+ session guard and produced zombie Stripe sessions; the hook exists so
509
+ that class of bug is structurally impossible. Build your own screens on
510
+ top of the hook; do not reimplement it.
511
+ - **The server owns every amount.** The client never sends an amount;
512
+ sessions charge `total − gift_card_total` computed server-side. Any
513
+ checkout change that puts a number in a request body is wrong by
514
+ construction.
515
+ - **Logging is not optional.** Money failures must reach the merchant: the
516
+ hook's default sink does this. If you override `logError`, either call
517
+ `reportCheckoutError()` yourself or accept that browser failures vanish —
518
+ and that is a defect, not a preference.
519
+ - **Debug output is opt-in.** `useCheckoutOrchestration({ debug: true })`
520
+ turns on verbose `[buy-click]` console output for local work. It prints
521
+ the full prepare payload (name, phone, email, address), so it must never
522
+ ship enabled.
523
+ - **Test against the real wire.** Every request/response shape on this page
524
+ is executable against a store; a checkout change ships with its route
525
+ driven end to end, error branches included.
526
+
527
+ ## Manual path — collections + sessions (step-by-step)
528
+
529
+ ### POST /api/store/payment-collections
530
+
531
+ - **Purpose** — ensure the cart's payment collection (ONE per cart,
532
+ idempotent; the amount is refreshed to the CURRENT decorated total on
533
+ every call).
534
+ - **Auth** — anon `x-client-id`.
535
+ - **Request** — `{cart_id}`.
536
+ > Contract note (code wins over store-api.md): the contract's
537
+ > `provider_id`/`data` fields are ignored — the provider is chosen when
538
+ > initiating the session.
539
+ - **Response** — `201 {payment_collection}` when created, `200` when the
540
+ existing one was refreshed. `{id, amount, currency_code,
541
+ status:"not_paid", payment_sessions:[…]}`. The moment a collection
542
+ exists, applied gift-card tender is composed as an internal
543
+ `pp_giftcard` session.
544
+ - **Errors** — 404 `cart_not_found`; 400 `validation_failed`.
545
+ - **SDK** — `checkout.createPaymentCollection(client, {cart_id})`.
546
+
547
+ ### POST /api/store/payment-collections/:id/payment-sessions
548
+
549
+ - **Purpose** — mint (or repair) the tender session — idempotent per
550
+ tender. For Stripe the PaymentIntent is minted FIRST (idempotency key
551
+ = session id) so a Stripe session row can never exist without its
552
+ intent; amount drift syncs the PI in place; terminal PIs self-heal by
553
+ rotation. Method sessions share ONE NULL-provider row per collection —
554
+ switching methods updates its snapshot in place.
555
+ - **Auth** — anon `x-client-id`.
556
+ - **Request** — `{provider_id? XOR payment_method_id?, data?,
557
+ save_payment_method?}` — exactly one tender. Keys the server owns
558
+ (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
559
+ `setup_future_usage`, and the method snapshot
560
+ `payment_method_id/name/kind`) are stripped from `data` — they cannot
561
+ be forged from the client.
562
+ - **`save_payment_method`** — saves the card for future off-session
563
+ renewal charges: the server resolves the CART'S customer (never a
564
+ client-supplied id), ensures a Stripe Customer for them, and mints the
565
+ PaymentIntent with `setup_future_usage: "off_session"`. **Automatic for
566
+ subscription carts**: when the cart carries plan lines the server
567
+ applies the mandate even without the flag (a subscription cannot renew
568
+ without it — the server owns the decision). Requires a logged-in
569
+ customer — a guest cart is a 400 `customer_required` (subscribing needs
570
+ an account), raised HERE, before any payment. **Consent**: the mandate
571
+ moment — the storefront MUST render the saved-card consent text with
572
+ the plan selection / next to the payment element (e.g. "Your card will
573
+ be saved for future subscription charges"). Re-initiating an existing
574
+ session with the flag (or after a plan line appears) upgrades the live
575
+ intent in place (same `client_secret`). No card to save on method
576
+ sessions (COD/manual subscriptions renew offline) — the account gate
577
+ still applies. Never set the flag on ordinary checkouts.
578
+ - **Response** — `201 {payment_session}` when created, `200` when the
579
+ existing one was returned/repaired: `{id, provider_id, amount,
580
+ currency_code, status:"pending", authorized_at:null, data}` — for
581
+ Stripe, `data` carries `payment_intent_id` + `client_secret` (mount
582
+ Elements with it); with `save_payment_method` it also carries
583
+ `setup_future_usage: "off_session"` + `stripe_customer_id`. For a
584
+ method, `provider_id` is null and `data` carries the snapshot
585
+ (`payment_method_id`, `payment_method_name`, `payment_method_kind`).
586
+ The session `amount` is `collection.amount − gift_card_total` — the
587
+ remainder.
588
+ - **Errors** — 404 `payment_collection_not_found`; 400 `invalid_provider`
589
+ (pp_giftcard, or an id the catalog doesn't know — the dead pp_ ids land
590
+ here) | `payment_provider_disabled` | `payment_provider_not_in_region` |
591
+ `payment_method_not_in_region` | `stripe_not_configured` |
592
+ `customer_required` | `validation_failed`.
593
+ - **SDK** — `checkout.initiatePaymentSession(client, pcId, {provider_id?,
594
+ payment_method_id?, save_payment_method?})`.
595
+
596
+ ### POST /api/store/carts/:id/shipping-methods
597
+
598
+ - **Purpose** — set the cart's shipping method manually (single-method
599
+ model: the previous method rows are replaced).
600
+ - **Auth** — anon `x-client-id`.
601
+ - **Request** — `{option_id, data?}` (`.strict()`).
602
+ - **Response** — `200 {cart}` (decorated; `shipping_total` now non-zero).
603
+ - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
604
+ `cart_completed`; 400 `shipping_price_missing` | `validation_failed`.
605
+ - **SDK** — `checkout.addShippingMethod(client, cartId, {option_id})`.
606
+
607
+ ```bash
608
+ # The whole manual path, executable: cart → method → collection → session → order.
609
+ CART2_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
610
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
611
+ -d '{"email":"checkout-doc-manual-'"$RUN"'@example.test",
612
+ "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}],
613
+ "shipping_address":{"first_name":"Doc","last_name":"Manual",
614
+ "address_1":"Vitosha 2","city":"Sofia","postal_code":"1000",
615
+ "country_code":"bg","phone":"+359888123457"}}')
616
+ CART2_ID=$(echo "$CART2_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
617
+
618
+ curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/shipping-methods" \
619
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
620
+ -d '{"option_id":"'"$SO_ID"'"}' \
621
+ | grep -q '"shipping_option_id":"'"$SO_ID"'"'
622
+
623
+ # Fresh cart, no collection yet → sync reports why it can't sync.
624
+ curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/sync-payment-amount" \
625
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
626
+ | grep -q '"reason":"no_payment_collection"'
627
+
628
+ PC2_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
629
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
630
+ -d '{"cart_id":"'"$CART2_ID"'"}')
631
+ echo "$PC2_JSON" | grep -q '"status":"not_paid"'
632
+ PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
633
+
634
+ SES_JSON=$(curl -sf -X POST \
635
+ "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
636
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
637
+ -d '{"payment_method_id":"'"$PM_ID"'"}')
638
+ echo "$SES_JSON" | grep -q '"provider_id":null'
639
+ echo "$SES_JSON" | grep -q '"payment_method_name"'
640
+ echo "$SES_JSON" | grep -q '"status":"pending"'
641
+
642
+ # Error contract: the internal gift tender is not initiable.
643
+ GCS_RES=$(curl -s -X POST \
644
+ "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
645
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
646
+ -d '{"provider_id":"pp_giftcard"}')
647
+ echo "$GCS_RES" | grep -q '"code":"invalid_provider"'
648
+
649
+ curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/complete" \
650
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
651
+ | grep -q '"type":"order"'
652
+ ```
653
+
654
+ ```bash
655
+ # save_payment_method on a GUEST cart is refused — subscribing needs an
656
+ # account (provider-independent: fires before any Stripe call).
657
+ CART3_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
658
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
659
+ -d '{"email":"checkout-doc-guest-sub-'"$RUN"'@example.test",
660
+ "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
661
+ CART3_ID=$(echo "$CART3_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
662
+
663
+ PC3_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
664
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
665
+ -d '{"cart_id":"'"$CART3_ID"'"}')
666
+ PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
667
+
668
+ curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
669
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
670
+ -d '{"payment_method_id":"'"$PM_ID"'","save_payment_method":true}' \
671
+ | grep -q '"code":"customer_required"'
672
+ ```
673
+
674
+ ---
675
+
676
+ ## Stripe specifics (needs STRIPE credentials — not executable here)
677
+
678
+ ```bash
679
+ # doc-noexec — requires the store's Stripe integration (admin-configured
680
+ # credentials); the mocked equivalents run in
681
+ # tests/store/checkout-orchestration.test.ts + checkout-stripe.test.ts.
682
+ PREP=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
683
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
684
+ -d '{"shipping_address":{…},"shipping_method_id":"so_…","payment_provider":"pp_stripe"}')
685
+ # → {"cart_id":…,"payment_collection_id":"pc_…","client_secret":"pi_…_secret_…","provider_id":"pp_stripe"}
686
+ # Storefront: stripe.confirmPayment({clientSecret}) → POST …/complete.
687
+ # 3DS never-returned / redirect flows: the payment_intent.succeeded webhook
688
+ # (POST /api/webhooks/payment/complete-on-success, configured in Stripe)
689
+ # completes the cart server-side through the SAME complete flow.
690
+ ```
691
+
692
+ Sync/refresh behavior with Stripe follows the matrices above: in-place
693
+ `paymentIntents.update` keeps the secret stable; provider mismatch or a
694
+ terminal PI rotates (old PI voided best-effort); `refreshPaymentIfTerminal`
695
+ rotates only on `succeeded`/`canceled`/`requires_capture`/missing.
696
+
697
+ ## Admin-config-dependent contracts (documented, proven by the suite)
698
+
699
+ - **`checkout_method_hidden` (400)** — requires an admin checkout rule;
700
+ exercised by `tests/store/checkout-rules.test.ts`.
701
+ - **`account_required` (403)** — requires `accounts_mode='required'` on
702
+ the store; exercised by `tests/store/customer-accounts-policy.test.ts`.
703
+ - **Payment method fee** — any method may carry `fee_amount`/`fee_label`
704
+ (the COD method included); exercised by
705
+ `tests/store/payment-method-fee.test.ts` (method-switch switches the
706
+ fee) and `tests/store/checkout-orchestration.test.ts` (fee-inclusive
707
+ session on prepare with the COD method).
708
+
709
+ ## Cleanup / accretion note
710
+
711
+ This page creates two carts and completes two method-tender orders on the
712
+ shared dev tenant — the same inert accretion the checkout test suites
713
+ produce (no store-facing delete exists for either; suites always create
714
+ their own carts/orders and never re-read foreign ones).