create-cartbase 0.1.1 → 0.1.3

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/package.json +1 -1
  2. package/template/app/docs/BUILD-A-STOREFRONT.md +1 -1
  3. package/template/app/docs/README.md +1 -1
  4. package/template/app/docs/auth.md +1 -1
  5. package/template/app/docs/carts.md +3 -3
  6. package/template/app/docs/categories.md +1 -1
  7. package/template/app/docs/checkout.md +135 -78
  8. package/template/app/docs/collections.md +1 -1
  9. package/template/app/docs/components.md +53 -55
  10. package/template/app/docs/consent.md +1 -1
  11. package/template/app/docs/content.md +1 -1
  12. package/template/app/docs/customers.md +1 -1
  13. package/template/app/docs/deploy.md +1 -1
  14. package/template/app/docs/gift-cards.md +1 -1
  15. package/template/app/docs/integrations.md +1 -1
  16. package/template/app/docs/menus.md +1 -1
  17. package/template/app/docs/metaobjects.md +1 -1
  18. package/template/app/docs/orders.md +1 -1
  19. package/template/app/docs/platform.md +1 -1
  20. package/template/app/docs/products.md +1 -1
  21. package/template/app/docs/redirects.md +1 -1
  22. package/template/app/docs/regions.md +1 -1
  23. package/template/app/docs/reviews.md +1 -1
  24. package/template/app/docs/search.md +1 -1
  25. package/template/app/docs/subscriptions.md +1 -1
  26. package/template/app/docs/variables.md +315 -0
  27. package/template/app/next-env.d.ts +6 -0
  28. package/template/app/package.json +1 -1
  29. package/template/app/smoke.mjs +1 -1
  30. package/template/app/src/app/checkout/checkout-page-client.tsx +26 -10
  31. package/template/app/src/app/checkout/mypos-demo-tab.tsx +101 -0
  32. package/template/app/src/app/checkout/page.tsx +48 -51
  33. package/template/app/src/app/order/[id]/confirmed/page.tsx +5 -4
  34. package/template/app/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cartbase",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Scaffold a Cartbase storefront: npm create cartbase my-store",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- # Build a storefront — the runbook
1
+ # Build a storefront
2
2
 
3
3
  **This runbook takes you from a blank Next.js app to a completed checkout
4
4
  against your Cartbase store.** It is written to be followed by a developer
@@ -1,4 +1,4 @@
1
- # docs/storefront/ — the bulletproof storefront docs
1
+ # Storefront docs
2
2
 
3
3
  **Audience: an AGENT building a storefront from a blank Next.js app.** These
4
4
  docs are the entire knowledge transfer — every call shape, every curl, every
@@ -1,4 +1,4 @@
1
- # Auth — passwordless code login + session discipline
1
+ # Auth
2
2
 
3
3
  Passwordless email-code login (customer-accounts card, Shopify Customer
4
4
  Account API direction). Two calls: **request** emails a 6-digit code,
@@ -1,4 +1,4 @@
1
- # Carts — lifecycle + line items
1
+ # Carts
2
2
 
3
3
  The cart is the storefront's working document: created anonymously, mutated
4
4
  through line-item and update calls, completed into an order (see
@@ -118,8 +118,8 @@ test -n "$CART_ID" && test -n "$REGION_ID"
118
118
  "item_total": 54, "item_subtotal": 45, "item_tax_total": 9,
119
119
  "original_total": 54, "original_subtotal": 45, "original_tax_total": 9,
120
120
  "credit_line_total": 0,
121
- "cod_fee_total": 0, // non-zero only with a live pp_cod session + enabled COD integration
122
- "cod_fee_label": null,
121
+ "payment_method_fee_total": 0, // the selected method's own fee — non-zero only with a live method session
122
+ "payment_method_fee_label": null,
123
123
  // gift-card tender decoration (totals above NEVER move):
124
124
  "gift_cards": [], // [{id, last4, amount}] in apply order
125
125
  "gift_card_total": 0, // Σ applied-card coverage
@@ -1,4 +1,4 @@
1
- # Categories, tags, types
1
+ # Categories
2
2
 
3
3
  Taxonomy reads for navigation trees and filter UIs. Categories are
4
4
  hierarchical (`parent_category_id`) with optional ancestor/descendant tree
@@ -1,4 +1,4 @@
1
- # Checkout — shipping options, payment, the Buy click, complete
1
+ # Checkout
2
2
 
3
3
  This page is the full checkout knowledge transfer: every listing, the
4
4
  orchestrated Buy-click sequence, the amount-sync matrix, dead-PI recovery,
@@ -23,10 +23,11 @@ listShippingOptions(cart_id) ─┐ (render pickers)
23
23
  listPaymentProviders(cart_id) ┘
24
24
  │ Buy click
25
25
 
26
- prepareCheckout(cart, {address, shipping_method_id, carrier_metadata, payment_provider})
26
+ prepareCheckout(cart, {address, shipping_method_id, carrier_metadata,
27
+ payment_provider XOR payment_method_id})
27
28
  │ ONE atomic call, compensated on failure
28
29
  ├─ pp_stripe → stripe.confirmPayment(client_secret) ─┐
29
- └─ pp_cod / pp_manual ───────────────────────────────┤
30
+ └─ a payment method (Bank transfer, COD…) ───────────┤
30
31
 
31
32
  completeCart(cart) → {type:"order", order}
32
33
  ```
@@ -56,17 +57,23 @@ changes the total; `refreshPaymentIfTerminal()` from Stripe Elements
56
57
  {
57
58
  "shipping_options": [{
58
59
  "id": "so_…", "name": "Standard",
59
- "provider_id": "fp_manual", "service_zone_id": "sz_…",
60
- "shipping_option_type_id": "sotype_…", "shipping_profile_id": "sp_…",
60
+ "provider_id": null, // the carrier, when the merchant bound one
61
+ "service_zone_id": "sz_…", "shipping_profile_id": "sp_…",
61
62
  "data": null,
62
- "type": { "": "shipping_option_types row (label/code/description)" },
63
- "amount": 5, // flat price in the cart currency; null without cart_id
63
+ "type": { "label": "Express" }, // the option's display label, or null
64
+ "amount": 5, // the price the cart pays; null without cart_id
64
65
  "price_type": "flat" // calculated-rate carriers not wired yet
65
66
  }],
66
67
  "count": 1, "offset": 0, "limit": 1
67
68
  }
68
69
  ```
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.
70
77
  - **Errors** — 404 `cart_not_found` (bad `cart_id`).
71
78
  - **SDK** — `checkout.listShippingOptions(client, {cart_id})`.
72
79
  - **Components** — shipping picker, carrier/locker pickers (carrier
@@ -129,33 +136,64 @@ curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
129
136
  `region_id`: the tenant's enabled providers (global catalog ∩ tenant
130
137
  enablement). With it: providers linked to that region. `cart_id` feeds
131
138
  the rules engine — storefronts SHOULD pass it during checkout.
132
- - **Response** — list envelope, full filtered list:
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):
133
148
 
134
149
  ```jsonc
135
150
  {
136
151
  "payment_providers": [
137
- { "id": "pp_manual", "is_enabled": true, "created_at": "…" }
138
- // pp_stripe / pp_cod appear when their integrations are enabled;
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" }
139
160
  // pp_giftcard is INTERNAL tender and is never listed
140
161
  ],
141
- "count": 1, "offset": 0, "limit": 1
162
+ "count": 3, "offset": 0, "limit": 3
142
163
  }
143
164
  ```
144
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`).
145
176
  - **Errors** — none beyond the standard envelope (empty list when nothing
146
- is enabled).
177
+ is enabled); initiate 400s on unknown/disabled/foreign method ids and
178
+ `payment_method_not_in_region` for an unclaimed region.
147
179
  - **SDK** — `checkout.listPaymentProviders(client, {region_id, cart_id})`.
148
180
  - **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`.
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`.
153
187
 
154
188
  ```bash
155
189
  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"'
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"
159
197
  ```
160
198
 
161
199
  ---
@@ -166,8 +204,8 @@ ONE call writes everything the customer toggled on /checkout, in the only
166
204
  safe order: **address first** (option pricing reads the destination) →
167
205
  **shipping method** → **payment collection** at the shipped total →
168
206
  **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
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
171
209
  are re-synced after it. Fully compensated: any failure rolls back session
172
210
  → collection → shipping method → addresses/metadata to the pre-call
173
211
  snapshot (recorded in the execution ledger, workflow `prepare-checkout`,
@@ -178,8 +216,10 @@ states done/reverted; failures also land in `checkout_error_logs`, step
178
216
  - **Request** (`.strict()`; DTO verbatim from
179
217
  `src/lib/checkout-orchestration/prepare.ts`) — all address fields
180
218
  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`:
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):
183
223
 
184
224
  ```jsonc
185
225
  {
@@ -195,7 +235,7 @@ states done/reverted; failures also land in `checkout_error_logs`, step
195
235
  "carrier_metadata": { // optional, opaque per-carrier keys
196
236
  "office_code": "X1", "office_name": "Center"
197
237
  },
198
- "payment_provider": "pp_manual",
238
+ "payment_method_id": "pm_…", // XOR payment_provider: "pp_stripe"
199
239
  "save_payment_method": false // optional — subscription carts only;
200
240
  // same semantics + consent duty as on
201
241
  // payment-sessions below
@@ -214,15 +254,17 @@ states done/reverted; failures also land in `checkout_error_logs`, step
214
254
  {
215
255
  "cart_id": "cart_…",
216
256
  "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
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
219
260
  }
220
261
  ```
221
262
 
222
263
  - **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)).
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)).
226
268
  - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
227
269
  `cart_completed`; 400 `validation_failed` | `invalid_provider`
228
270
  (pp_giftcard) | `shipping_price_missing` | `stripe_not_configured`.
@@ -241,8 +283,8 @@ PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
241
283
  "country_code":"bg","phone":"+359888123456"},
242
284
  "shipping_method_id":"'"$SO_ID"'",
243
285
  "carrier_metadata":{"office_code":"X1"},
244
- "payment_provider":"pp_manual"}')
245
- echo "$PREP_JSON" | grep -q '"provider_id":"pp_manual"'
286
+ "payment_method_id":"'"$PM_ID"'"}')
287
+ echo "$PREP_JSON" | grep -q '"payment_method_id":"'"$PM_ID"'"'
246
288
  echo "$PREP_JSON" | grep -q '"client_secret":null'
247
289
  PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
248
290
  test -n "$PC_ID"
@@ -270,8 +312,9 @@ changes the total while checkout is mounted (quantity change, gift card
270
312
  applied/removed, shipping switch).
271
313
 
272
314
  - **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.
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.
275
318
  - **Response matrix** (verbatim `src/lib/checkout-orchestration/sync.ts`):
276
319
 
277
320
  | state | response |
@@ -279,29 +322,31 @@ applied/removed, shipping switch).
279
322
  | completed cart | `{"synced":false,"reason":"cart-completed"}` |
280
323
  | no payment collection | `{"synced":false,"reason":"no_payment_collection"}` |
281
324
  | 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":…}` |
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,…}` |
285
328
 
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.
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.
290
334
 
291
335
  - **Errors** — 404 `cart_not_found`; 400 `validation_failed` |
292
336
  `stripe_not_configured`. Failures land in `checkout_error_logs`
293
337
  (step `sync-payment-amount`).
294
- - **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id})`.
338
+ - **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id?,
339
+ payment_method_id?})`.
295
340
  - **Components** — checkout totals watcher (debounced), payment-method
296
- switcher (pass the new `provider_id`).
341
+ switcher (pass the new tender id).
297
342
 
298
343
  ```bash
299
- # No-drift no-op on the prepared pp_manual cart: same-session, not rotated.
344
+ # No-drift no-op on the prepared method cart: same-session, not rotated.
300
345
  SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
301
346
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
302
347
  echo "$SYNC_JSON" | grep -q '"synced":true'
303
348
  echo "$SYNC_JSON" | grep -q '"rotated":false'
304
- echo "$SYNC_JSON" | grep -q '"provider_id":"pp_manual"'
349
+ echo "$SYNC_JSON" | grep -q '"payment_method_id":"pm_'
305
350
  ```
306
351
 
307
352
  ---
@@ -333,7 +378,7 @@ production reload loop).
333
378
  - **Components** — Stripe Elements mount error handler.
334
379
 
335
380
  ```bash
336
- # pp_manual session ⇒ documented no-op reason (Stripe-specific rotation
381
+ # Method session ⇒ documented no-op reason (Stripe-specific rotation
337
382
  # needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
338
383
  curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
339
384
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'
@@ -350,7 +395,7 @@ reservation (kit-aware) → order creation (rows copied cart→order) →
350
395
  cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date;
351
396
  guests are refused with 400 `customer_required`) → **payment authorization
352
397
  LAST** (gift tender redeemed atomically first; real Stripe authorize for
353
- `pp_stripe`; best-effort stub for `pp_cod`/`pp_manual`) → `order.placed`
398
+ `pp_stripe`; best-effort stub for method sessions) → `order.placed`
354
399
  (+ `subscription.created` per contract) on the durable bus. Any failure
355
400
  before authorize compensates fully (order deleted, contracts deleted,
356
401
  inventory released, gift tender reversed, cart unlocked) — the cart stays
@@ -378,9 +423,11 @@ the mandate already exists.
378
423
  `customer_required` (plan lines on a guest cart — subscribing needs an
379
424
  account; normally already refused at the payment-session step).
380
425
  - 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
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
384
431
  that picked a method before a rule started matching, or a hostile
385
432
  client that skipped the filtered listings, is rejected here and the
386
433
  rejection is recorded in `checkout_error_logs` (step `complete`).
@@ -395,14 +442,15 @@ the mandate already exists.
395
442
  `@cartbase/storefront/api/carts`).
396
443
  - **Components** — Buy button (orchestrated path), order-confirmation
397
444
  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
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
401
449
  `order_summaries.totals`, where waybill COD amounts read it); gift-card
402
450
  tender (zero-remainder carts complete on the gift session alone).
403
451
 
404
452
  ```bash
405
- # Complete the prepared pp_manual cart → a real order, no Stripe env needed.
453
+ # Complete the prepared method cart → a real order, no Stripe env needed.
406
454
  ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
407
455
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
408
456
  echo "$ORDER_JSON" | grep -q '"type":"order"'
@@ -452,16 +500,19 @@ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
452
500
 
453
501
  ### POST /api/store/payment-collections/:id/payment-sessions
454
502
 
455
- - **Purpose** — mint (or repair) the provider session — idempotent per
456
- provider. For Stripe the PaymentIntent is minted FIRST (idempotency key
503
+ - **Purpose** — mint (or repair) the tender session — idempotent per
504
+ tender. For Stripe the PaymentIntent is minted FIRST (idempotency key
457
505
  = session id) so a Stripe session row can never exist without its
458
506
  intent; amount drift syncs the PI in place; terminal PIs self-heal by
459
- rotation.
507
+ rotation. Method sessions share ONE NULL-provider row per collection —
508
+ switching methods updates its snapshot in place.
460
509
  - **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.
510
+ - **Request** — `{provider_id? XOR payment_method_id?, data?,
511
+ save_payment_method?}` exactly one tender. Keys the server owns
512
+ (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
513
+ `setup_future_usage`, and the method snapshot
514
+ `payment_method_id/name/kind`) are stripped from `data` — they cannot
515
+ be forged from the client.
465
516
  - **`save_payment_method`** — saves the card for future off-session
466
517
  renewal charges: the server resolves the CART'S customer (never a
467
518
  client-supplied id), ensures a Stripe Customer for them, and mints the
@@ -475,21 +526,26 @@ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
475
526
  the plan selection / next to the payment element (e.g. "Your card will
476
527
  be saved for future subscription charges"). Re-initiating an existing
477
528
  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.
529
+ intent in place (same `client_secret`). No card to save on method
530
+ sessions (COD/manual subscriptions renew offline) the account gate
531
+ still applies. Never set the flag on ordinary checkouts.
481
532
  - **Response** — `201 {payment_session}` when created, `200` when the
482
533
  existing one was returned/repaired: `{id, provider_id, amount,
483
534
  currency_code, status:"pending", authorized_at:null, data}` — for
484
535
  Stripe, `data` carries `payment_intent_id` + `client_secret` (mount
485
536
  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.
537
+ `setup_future_usage: "off_session"` + `stripe_customer_id`. For a
538
+ method, `provider_id` is null and `data` carries the snapshot
539
+ (`payment_method_id`, `payment_method_name`, `payment_method_kind`).
540
+ The session `amount` is `collection.amount − gift_card_total` — the
541
+ remainder.
488
542
  - **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?})`.
543
+ (pp_giftcard, or an id the catalog doesn't know — the dead pp_ ids land
544
+ here) | `payment_provider_disabled` | `payment_provider_not_in_region` |
545
+ `payment_method_not_in_region` | `stripe_not_configured` |
546
+ `customer_required` | `validation_failed`.
547
+ - **SDK** — `checkout.initiatePaymentSession(client, pcId, {provider_id?,
548
+ payment_method_id?, save_payment_method?})`.
493
549
 
494
550
  ### POST /api/store/carts/:id/shipping-methods
495
551
 
@@ -532,8 +588,9 @@ PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
532
588
  SES_JSON=$(curl -sf -X POST \
533
589
  "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
534
590
  -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"'
591
+ -d '{"payment_method_id":"'"$PM_ID"'"}')
592
+ echo "$SES_JSON" | grep -q '"provider_id":null'
593
+ echo "$SES_JSON" | grep -q '"payment_method_name"'
537
594
  echo "$SES_JSON" | grep -q '"status":"pending"'
538
595
 
539
596
  # Error contract: the internal gift tender is not initiable.
@@ -564,7 +621,7 @@ PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
564
621
 
565
622
  curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
566
623
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
567
- -d '{"provider_id":"pp_manual","save_payment_method":true}' \
624
+ -d '{"payment_method_id":"'"$PM_ID"'","save_payment_method":true}' \
568
625
  | grep -q '"code":"customer_required"'
569
626
  ```
570
627
 
@@ -597,15 +654,15 @@ rotates only on `succeeded`/`canceled`/`requires_capture`/missing.
597
654
  exercised by `tests/store/checkout-rules.test.ts`.
598
655
  - **`account_required` (403)** — requires `accounts_mode='required'` on
599
656
  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`).
657
+ - **Payment method fee** — any method may carry `fee_amount`/`fee_label`
658
+ (the COD method included); exercised by
659
+ `tests/store/payment-method-fee.test.ts` (method-switch switches the
660
+ fee) and `tests/store/checkout-orchestration.test.ts` (fee-inclusive
661
+ session on prepare with the COD method).
605
662
 
606
663
  ## Cleanup / accretion note
607
664
 
608
- This page creates two carts and completes two `pp_manual` orders on the
665
+ This page creates two carts and completes two method-tender orders on the
609
666
  shared dev tenant — the same inert accretion the checkout test suites
610
667
  produce (no store-facing delete exists for either; suites always create
611
668
  their own carts/orders and never re-read foreign ones).
@@ -1,4 +1,4 @@
1
- # Collections & membership listings
1
+ # Collections
2
2
 
3
3
  Curated product groupings (manual or smart). The membership listing —
4
4
  `/collections/:id/products` — is the collection page's data source: it reads