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