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.
- package/package.json +1 -1
- package/template/app/docs/BUILD-A-STOREFRONT.md +1 -1
- package/template/app/docs/README.md +1 -1
- package/template/app/docs/auth.md +1 -1
- package/template/app/docs/carts.md +3 -3
- package/template/app/docs/categories.md +1 -1
- package/template/app/docs/checkout.md +135 -78
- package/template/app/docs/collections.md +1 -1
- package/template/app/docs/components.md +53 -55
- package/template/app/docs/consent.md +1 -1
- package/template/app/docs/content.md +1 -1
- package/template/app/docs/customers.md +1 -1
- package/template/app/docs/deploy.md +1 -1
- package/template/app/docs/gift-cards.md +1 -1
- package/template/app/docs/integrations.md +1 -1
- package/template/app/docs/menus.md +1 -1
- package/template/app/docs/metaobjects.md +1 -1
- package/template/app/docs/orders.md +1 -1
- package/template/app/docs/platform.md +1 -1
- package/template/app/docs/products.md +1 -1
- package/template/app/docs/redirects.md +1 -1
- package/template/app/docs/regions.md +1 -1
- package/template/app/docs/reviews.md +1 -1
- package/template/app/docs/search.md +1 -1
- package/template/app/docs/subscriptions.md +1 -1
- package/template/app/docs/variables.md +315 -0
- package/template/app/next-env.d.ts +6 -0
- package/template/app/package.json +1 -1
- package/template/app/smoke.mjs +1 -1
- package/template/app/src/app/checkout/checkout-page-client.tsx +26 -10
- package/template/app/src/app/checkout/mypos-demo-tab.tsx +101 -0
- package/template/app/src/app/checkout/page.tsx +48 -51
- package/template/app/src/app/order/[id]/confirmed/page.tsx +5 -4
- package/template/app/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Carts
|
|
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
|
-
"
|
|
122
|
-
"
|
|
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
|
-
# Checkout
|
|
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,
|
|
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
|
-
└─
|
|
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":
|
|
60
|
-
"
|
|
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": { "
|
|
63
|
-
"amount": 5, //
|
|
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": "
|
|
138
|
-
//
|
|
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":
|
|
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** —
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 '"
|
|
157
|
-
curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
|
|
158
|
-
-H "x-client-id: $CLIENT_ID"
|
|
190
|
+
-H "x-client-id: $CLIENT_ID" | grep -q '"payment_method_id"'
|
|
191
|
+
PAY_JSON=$(curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
|
|
192
|
+
-H "x-client-id: $CLIENT_ID")
|
|
193
|
+
echo "$PAY_JSON" | grep -q '"payment_method_id"'
|
|
194
|
+
# The seeded Bank transfer method carries the rest of this page.
|
|
195
|
+
PM_ID=$(echo "$PAY_JSON" | grep -o '"payment_method_id":"pm_[^"]*"' | head -1 | cut -d'"' -f4)
|
|
196
|
+
test -n "$PM_ID"
|
|
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
|
|
170
|
-
|
|
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).
|
|
182
|
-
`
|
|
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
|
-
"
|
|
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
|
|
218
|
-
"provider_id":
|
|
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
|
|
224
|
-
`provider_id` come back null) and the cart
|
|
225
|
-
alone — no Stripe involved
|
|
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
|
-
"
|
|
245
|
-
echo "$PREP_JSON" | grep -q '"
|
|
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
|
|
274
|
-
a DIFFERENT
|
|
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
|
-
|
|
|
283
|
-
|
|
|
284
|
-
|
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
341
|
+
switcher (pass the new tender id).
|
|
297
342
|
|
|
298
343
|
```bash
|
|
299
|
-
# No-drift no-op on the prepared
|
|
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 '"
|
|
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
|
-
#
|
|
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
|
|
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
|
|
382
|
-
`
|
|
383
|
-
|
|
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`;
|
|
399
|
-
fee exists only while
|
|
400
|
-
cart at prepare
|
|
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
|
|
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
|
|
456
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
`
|
|
464
|
-
|
|
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
|
|
479
|
-
(COD/manual subscriptions renew offline)
|
|
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`.
|
|
487
|
-
`
|
|
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
|
|
490
|
-
`
|
|
491
|
-
|
|
492
|
-
|
|
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 '{"
|
|
536
|
-
echo "$SES_JSON" | grep -q '"provider_id":
|
|
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 '{"
|
|
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
|
-
- **
|
|
601
|
-
(
|
|
602
|
-
`tests/store/
|
|
603
|
-
`tests/store/checkout-orchestration.test.ts` (fee-inclusive
|
|
604
|
-
prepare with
|
|
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
|
|
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).
|