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