create-cartbase 0.0.1 → 0.1.1

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