create-cartbase 0.1.1 → 0.1.2

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 +125 -74
  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.2",
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
  ```
@@ -129,33 +130,64 @@ curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
129
130
  `region_id`: the tenant's enabled providers (global catalog ∩ tenant
130
131
  enablement). With it: providers linked to that region. `cart_id` feeds
131
132
  the rules engine — storefronts SHOULD pass it during checkout.
132
- - **Response** — list envelope, full filtered list:
133
+ - **Response** — list envelope, full filtered list. Two entry shapes share
134
+ the array: connected PROCESSORS (`{id}` — pp_stripe) and merchant
135
+ payment METHODS (`{payment_method_id, name, kind, instructions,
136
+ fee_amount, fee_label}` — "Bank transfer", the COD method). Methods
137
+ carry no provider id anywhere; a fresh store with nothing configured
138
+ gets an honestly empty list. With `region_id`, BOTH families filter by
139
+ the region's claims (`region_payment_provider` /
140
+ `region_payment_method` — availability is a per-region merchant
141
+ choice):
133
142
 
134
143
  ```jsonc
135
144
  {
136
145
  "payment_providers": [
137
- { "id": "pp_manual", "is_enabled": true, "created_at": "…" }
138
- // pp_stripe / pp_cod appear when their integrations are enabled;
146
+ { "id": "pp_stripe", "is_enabled": true, "created_at": "…" },
147
+ // one entry PER enabled, region-claimed merchant method:
148
+ { "payment_method_id": "pm_…", "name": "Bank transfer",
149
+ "kind": "manual", "instructions": "IBAN BG…",
150
+ "fee_amount": null, "fee_label": null },
151
+ { "payment_method_id": "pm_…", "name": "Cash on delivery",
152
+ "kind": "cod", "instructions": null,
153
+ "fee_amount": 4.99, "fee_label": "COD fee" }
139
154
  // pp_giftcard is INTERNAL tender and is never listed
140
155
  ],
141
- "count": 1, "offset": 0, "limit": 1
156
+ "count": 3, "offset": 0, "limit": 3
142
157
  }
143
158
  ```
144
159
 
160
+ - **Selecting a method** — initiate the session with the method id alone:
161
+ `POST …/payment-sessions { payment_method_id: "pm_…" }`. The server
162
+ validates it (tenant's, enabled, claimed for the cart's region — 400
163
+ otherwise), the session lands with `provider_id` NULL, and the method's
164
+ identity is snapshotted into `session.data`
165
+ (`payment_method_id/name/kind`) so payment surfaces say "Bank
166
+ transfer" without a join. Render `instructions` to the shopper after
167
+ selection and on the confirmation screen. `fee_amount`/`fee_label` let
168
+ the checkout PREDICT the fee before the session exists — the authority
169
+ stays the server totals (`payment_method_fee_total`).
145
170
  - **Errors** — none beyond the standard envelope (empty list when nothing
146
- is enabled).
171
+ is enabled); initiate 400s on unknown/disabled/foreign method ids and
172
+ `payment_method_not_in_region` for an unclaimed region.
147
173
  - **SDK** — `checkout.listPaymentProviders(client, {region_id, cart_id})`.
148
174
  - **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`.
175
+ - **Settings** — Stripe connects in Settings, Payments (provisions
176
+ `pp_stripe` + credentials); payment methods live in the same screen
177
+ (the COD switch + named manual methods, each with optional
178
+ `fee_amount`/`fee_label` and per-region availability); checkout rules
179
+ (`target_type=payment_method`, method entries participate under their
180
+ `payment_method_id`) + `checkout_method_order`.
153
181
 
154
182
  ```bash
155
183
  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"'
184
+ -H "x-client-id: $CLIENT_ID" | grep -q '"payment_method_id"'
185
+ PAY_JSON=$(curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
186
+ -H "x-client-id: $CLIENT_ID")
187
+ echo "$PAY_JSON" | grep -q '"payment_method_id"'
188
+ # The seeded Bank transfer method carries the rest of this page.
189
+ PM_ID=$(echo "$PAY_JSON" | grep -o '"payment_method_id":"pm_[^"]*"' | head -1 | cut -d'"' -f4)
190
+ test -n "$PM_ID"
159
191
  ```
160
192
 
161
193
  ---
@@ -166,8 +198,8 @@ ONE call writes everything the customer toggled on /checkout, in the only
166
198
  safe order: **address first** (option pricing reads the destination) →
167
199
  **shipping method** → **payment collection** at the shipped total →
168
200
  **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
201
+ idempotency key = session id; a plain NULL-provider row for a payment
202
+ method). A method's fee only applies once its session exists, so amounts
171
203
  are re-synced after it. Fully compensated: any failure rolls back session
172
204
  → collection → shipping method → addresses/metadata to the pre-call
173
205
  snapshot (recorded in the execution ledger, workflow `prepare-checkout`,
@@ -178,8 +210,10 @@ states done/reverted; failures also land in `checkout_error_logs`, step
178
210
  - **Request** (`.strict()`; DTO verbatim from
179
211
  `src/lib/checkout-orchestration/prepare.ts`) — all address fields
180
212
  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`:
213
+ (courier recovery channel). The tender is exactly ONE of
214
+ `payment_provider` (a connected processor — `pp_stripe`, never
215
+ `pp_giftcard`) or `payment_method_id` (a merchant method from the
216
+ listing):
183
217
 
184
218
  ```jsonc
185
219
  {
@@ -195,7 +229,7 @@ states done/reverted; failures also land in `checkout_error_logs`, step
195
229
  "carrier_metadata": { // optional, opaque per-carrier keys
196
230
  "office_code": "X1", "office_name": "Center"
197
231
  },
198
- "payment_provider": "pp_manual",
232
+ "payment_method_id": "pm_…", // XOR payment_provider: "pp_stripe"
199
233
  "save_payment_method": false // optional — subscription carts only;
200
234
  // same semantics + consent duty as on
201
235
  // payment-sessions below
@@ -214,15 +248,17 @@ states done/reverted; failures also land in `checkout_error_logs`, step
214
248
  {
215
249
  "cart_id": "cart_…",
216
250
  "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
251
+ "client_secret": "pi_…_secret_…", // Stripe only; null for method sessions AND zero-remainder carts
252
+ "provider_id": null, // the processor, when one was chosen
253
+ "payment_method_id": "pm_…" // the method, when one was chosen
219
254
  }
220
255
  ```
221
256
 
222
257
  - **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)).
258
+ total, the tender session is skipped entirely (`client_secret`,
259
+ `provider_id` and `payment_method_id` come back null) and the cart
260
+ completes on the gift session alone — no Stripe involved
261
+ ([gift-cards.md](gift-cards.md)).
226
262
  - **Errors** — 404 `cart_not_found` | `shipping_option_not_found`; 409
227
263
  `cart_completed`; 400 `validation_failed` | `invalid_provider`
228
264
  (pp_giftcard) | `shipping_price_missing` | `stripe_not_configured`.
@@ -241,8 +277,8 @@ PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
241
277
  "country_code":"bg","phone":"+359888123456"},
242
278
  "shipping_method_id":"'"$SO_ID"'",
243
279
  "carrier_metadata":{"office_code":"X1"},
244
- "payment_provider":"pp_manual"}')
245
- echo "$PREP_JSON" | grep -q '"provider_id":"pp_manual"'
280
+ "payment_method_id":"'"$PM_ID"'"}')
281
+ echo "$PREP_JSON" | grep -q '"payment_method_id":"'"$PM_ID"'"'
246
282
  echo "$PREP_JSON" | grep -q '"client_secret":null'
247
283
  PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
248
284
  test -n "$PC_ID"
@@ -270,8 +306,9 @@ changes the total while checkout is mounted (quantity change, gift card
270
306
  applied/removed, shipping switch).
271
307
 
272
308
  - **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.
309
+ - **Request** — `{provider_id? | payment_method_id?}` (`.strict()`; empty
310
+ object fine, never both). Passing a DIFFERENT tender than the pending
311
+ session's forces rotation to it.
275
312
  - **Response matrix** (verbatim `src/lib/checkout-orchestration/sync.ts`):
276
313
 
277
314
  | state | response |
@@ -279,29 +316,31 @@ applied/removed, shipping switch).
279
316
  | completed cart | `{"synced":false,"reason":"cart-completed"}` |
280
317
  | no payment collection | `{"synced":false,"reason":"no_payment_collection"}` |
281
318
  | 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":…}` |
319
+ | tender matches, amount current | `{"synced":true,"rotated":false,"client_secret":…,"provider_id":…,"payment_method_id":…}` (no-op) |
320
+ | tender matches, amount drifted | in-place update (Stripe `paymentIntents.update`; plain field for method sessions) → `{"synced":true,"rotated":false,…}` — same secret |
321
+ | 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
322
 
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.
323
+ A session's tender identity is its `provider_id` for processors and its
324
+ snapshot `payment_method_id` for NULL-provider method sessions. Rotation
325
+ retires the old session BEFORE recomputing so session-dependent totals
326
+ (the method fee) settle for the NEW tender; a final resync pass aligns
327
+ collection + session + PI. `client_secret` is null for method sessions.
290
328
 
291
329
  - **Errors** — 404 `cart_not_found`; 400 `validation_failed` |
292
330
  `stripe_not_configured`. Failures land in `checkout_error_logs`
293
331
  (step `sync-payment-amount`).
294
- - **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id})`.
332
+ - **SDK** — `checkout.syncPaymentAmount(client, cartId, {provider_id?,
333
+ payment_method_id?})`.
295
334
  - **Components** — checkout totals watcher (debounced), payment-method
296
- switcher (pass the new `provider_id`).
335
+ switcher (pass the new tender id).
297
336
 
298
337
  ```bash
299
- # No-drift no-op on the prepared pp_manual cart: same-session, not rotated.
338
+ # No-drift no-op on the prepared method cart: same-session, not rotated.
300
339
  SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
301
340
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
302
341
  echo "$SYNC_JSON" | grep -q '"synced":true'
303
342
  echo "$SYNC_JSON" | grep -q '"rotated":false'
304
- echo "$SYNC_JSON" | grep -q '"provider_id":"pp_manual"'
343
+ echo "$SYNC_JSON" | grep -q '"payment_method_id":"pm_'
305
344
  ```
306
345
 
307
346
  ---
@@ -333,7 +372,7 @@ production reload loop).
333
372
  - **Components** — Stripe Elements mount error handler.
334
373
 
335
374
  ```bash
336
- # pp_manual session ⇒ documented no-op reason (Stripe-specific rotation
375
+ # Method session ⇒ documented no-op reason (Stripe-specific rotation
337
376
  # needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
338
377
  curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
339
378
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'
@@ -350,7 +389,7 @@ reservation (kit-aware) → order creation (rows copied cart→order) →
350
389
  cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date;
351
390
  guests are refused with 400 `customer_required`) → **payment authorization
352
391
  LAST** (gift tender redeemed atomically first; real Stripe authorize for
353
- `pp_stripe`; best-effort stub for `pp_cod`/`pp_manual`) → `order.placed`
392
+ `pp_stripe`; best-effort stub for method sessions) → `order.placed`
354
393
  (+ `subscription.created` per contract) on the durable bus. Any failure
355
394
  before authorize compensates fully (order deleted, contracts deleted,
356
395
  inventory released, gift tender reversed, cart unlocked) — the cart stays
@@ -378,9 +417,11 @@ the mandate already exists.
378
417
  `customer_required` (plan lines on a guest cart — subscribing needs an
379
418
  account; normally already refused at the payment-session step).
380
419
  - 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
420
+ boundary**: every live payment session's chosen tender (the method's
421
+ `payment_method_id` for NULL-provider sessions, else the provider id;
422
+ internal `pp_giftcard` exempt) and every chosen shipping option is
423
+ re-validated against the live rules with the full cart context. A
424
+ stale session
384
425
  that picked a method before a rule started matching, or a hostile
385
426
  client that skipped the filtered listings, is rejected here and the
386
427
  rejection is recorded in `checkout_error_logs` (step `complete`).
@@ -395,14 +436,15 @@ the mandate already exists.
395
436
  `@cartbase/storefront/api/carts`).
396
437
  - **Components** — Buy button (orchestrated path), order-confirmation
397
438
  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
439
+ - **Settings** — checkout rules; `accounts_mode`; the payment method fee
440
+ (**timing**: a method's fee exists only while ITS live session does — it
441
+ appears on the cart at prepare as `payment_method_fee_total`, rides
442
+ `cart.total`, and is carried onto the order via
401
443
  `order_summaries.totals`, where waybill COD amounts read it); gift-card
402
444
  tender (zero-remainder carts complete on the gift session alone).
403
445
 
404
446
  ```bash
405
- # Complete the prepared pp_manual cart → a real order, no Stripe env needed.
447
+ # Complete the prepared method cart → a real order, no Stripe env needed.
406
448
  ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
407
449
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
408
450
  echo "$ORDER_JSON" | grep -q '"type":"order"'
@@ -452,16 +494,19 @@ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
452
494
 
453
495
  ### POST /api/store/payment-collections/:id/payment-sessions
454
496
 
455
- - **Purpose** — mint (or repair) the provider session — idempotent per
456
- provider. For Stripe the PaymentIntent is minted FIRST (idempotency key
497
+ - **Purpose** — mint (or repair) the tender session — idempotent per
498
+ tender. For Stripe the PaymentIntent is minted FIRST (idempotency key
457
499
  = session id) so a Stripe session row can never exist without its
458
500
  intent; amount drift syncs the PI in place; terminal PIs self-heal by
459
- rotation.
501
+ rotation. Method sessions share ONE NULL-provider row per collection —
502
+ switching methods updates its snapshot in place.
460
503
  - **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.
504
+ - **Request** — `{provider_id? XOR payment_method_id?, data?,
505
+ save_payment_method?}` exactly one tender. Keys the server owns
506
+ (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
507
+ `setup_future_usage`, and the method snapshot
508
+ `payment_method_id/name/kind`) are stripped from `data` — they cannot
509
+ be forged from the client.
465
510
  - **`save_payment_method`** — saves the card for future off-session
466
511
  renewal charges: the server resolves the CART'S customer (never a
467
512
  client-supplied id), ensures a Stripe Customer for them, and mints the
@@ -475,21 +520,26 @@ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
475
520
  the plan selection / next to the payment element (e.g. "Your card will
476
521
  be saved for future subscription charges"). Re-initiating an existing
477
522
  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.
523
+ intent in place (same `client_secret`). No card to save on method
524
+ sessions (COD/manual subscriptions renew offline) the account gate
525
+ still applies. Never set the flag on ordinary checkouts.
481
526
  - **Response** — `201 {payment_session}` when created, `200` when the
482
527
  existing one was returned/repaired: `{id, provider_id, amount,
483
528
  currency_code, status:"pending", authorized_at:null, data}` — for
484
529
  Stripe, `data` carries `payment_intent_id` + `client_secret` (mount
485
530
  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.
531
+ `setup_future_usage: "off_session"` + `stripe_customer_id`. For a
532
+ method, `provider_id` is null and `data` carries the snapshot
533
+ (`payment_method_id`, `payment_method_name`, `payment_method_kind`).
534
+ The session `amount` is `collection.amount − gift_card_total` — the
535
+ remainder.
488
536
  - **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?})`.
537
+ (pp_giftcard, or an id the catalog doesn't know — the dead pp_ ids land
538
+ here) | `payment_provider_disabled` | `payment_provider_not_in_region` |
539
+ `payment_method_not_in_region` | `stripe_not_configured` |
540
+ `customer_required` | `validation_failed`.
541
+ - **SDK** — `checkout.initiatePaymentSession(client, pcId, {provider_id?,
542
+ payment_method_id?, save_payment_method?})`.
493
543
 
494
544
  ### POST /api/store/carts/:id/shipping-methods
495
545
 
@@ -532,8 +582,9 @@ PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
532
582
  SES_JSON=$(curl -sf -X POST \
533
583
  "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
534
584
  -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"'
585
+ -d '{"payment_method_id":"'"$PM_ID"'"}')
586
+ echo "$SES_JSON" | grep -q '"provider_id":null'
587
+ echo "$SES_JSON" | grep -q '"payment_method_name"'
537
588
  echo "$SES_JSON" | grep -q '"status":"pending"'
538
589
 
539
590
  # Error contract: the internal gift tender is not initiable.
@@ -564,7 +615,7 @@ PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
564
615
 
565
616
  curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
566
617
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
567
- -d '{"provider_id":"pp_manual","save_payment_method":true}' \
618
+ -d '{"payment_method_id":"'"$PM_ID"'","save_payment_method":true}' \
568
619
  | grep -q '"code":"customer_required"'
569
620
  ```
570
621
 
@@ -597,15 +648,15 @@ rotates only on `succeeded`/`canceled`/`requires_capture`/missing.
597
648
  exercised by `tests/store/checkout-rules.test.ts`.
598
649
  - **`account_required` (403)** — requires `accounts_mode='required'` on
599
650
  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`).
651
+ - **Payment method fee** — any method may carry `fee_amount`/`fee_label`
652
+ (the COD method included); exercised by
653
+ `tests/store/payment-method-fee.test.ts` (method-switch switches the
654
+ fee) and `tests/store/checkout-orchestration.test.ts` (fee-inclusive
655
+ session on prepare with the COD method).
605
656
 
606
657
  ## Cleanup / accretion note
607
658
 
608
- This page creates two carts and completes two `pp_manual` orders on the
659
+ This page creates two carts and completes two method-tender orders on the
609
660
  shared dev tenant — the same inert accretion the checkout test suites
610
661
  produce (no store-facing delete exists for either; suites always create
611
662
  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