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.
Files changed (34) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +24 -24
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +376 -376
  7. package/template/app/docs/categories.md +194 -194
  8. package/template/app/docs/checkout.md +714 -714
  9. package/template/app/docs/components.md +44 -11
  10. package/template/app/docs/consent.md +91 -91
  11. package/template/app/docs/deploy.md +197 -197
  12. package/template/app/docs/gift-cards.md +153 -153
  13. package/template/app/docs/metaobjects.md +126 -126
  14. package/template/app/docs/orders.md +221 -221
  15. package/template/app/docs/products.md +51 -2
  16. package/template/app/docs/regions.md +269 -269
  17. package/template/app/docs/reviews.md +223 -223
  18. package/template/app/docs/search.md +227 -227
  19. package/template/app/docs/store.md +47 -47
  20. package/template/app/docs/subscriptions.md +148 -148
  21. package/template/app/docs/variables.md +315 -315
  22. package/template/app/package.json +1 -1
  23. package/template/app/postcss.config.cjs +11 -11
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
  25. package/template/app/src/app/checkout/page.tsx +48 -48
  26. package/template/app/src/app/globals.css +26 -26
  27. package/template/app/src/app/page.tsx +28 -28
  28. package/template/app/src/app/products/[handle]/page.tsx +87 -87
  29. package/template/app/src/app/providers.tsx +64 -64
  30. package/template/app/src/app/search/page.tsx +23 -23
  31. package/template/app/src/lib/browser-client.ts +35 -35
  32. package/template/app/src/lib/config.ts +41 -41
  33. package/template/app/src/lib/server-client.ts +25 -25
  34. package/template/app/src/lib/cart-actions.ts +0 -47
@@ -1,376 +1,376 @@
1
- # Carts
2
-
3
- The cart is the storefront's working document: created anonymously, mutated
4
- through line-item and update calls, completed into an order (see
5
- [checkout.md](checkout.md) for the Buy-click sequence and
6
- [gift-cards.md](gift-cards.md) for gift-card tender). Every mutation returns
7
- the **full decorated cart** — totals are server truth, the storefront never
8
- does money math. All amounts are EUR decimal major units.
9
-
10
- **Auth for every endpoint on this page:** anon `x-client-id` header. A
11
- customer JWT (`authorization: Bearer <jwt>`) is optional and only changes
12
- behavior where noted. Sending `x-publishable-api-key` additionally applies
13
- B2B channel scope (see Settings notes).
14
-
15
- SDK module: `@cartbase/storefront/api/carts`.
16
-
17
- ---
18
-
19
- ## POST /api/store/carts — create
20
-
21
- - **Purpose** — create a cart; first call of every storefront session that
22
- adds to cart.
23
- - **Auth** — anon `x-client-id`. With a Bearer JWT the customer is attached
24
- (`customer_id` + `email`) and initial items are priced with the customer's
25
- B2B groups. With a publishable key, the cart defaults to the key's sales
26
- channel.
27
- - **Request** — `POST`, body is `.strict()` (unknown keys → 400
28
- `validation_failed`). `customer_id` is deliberately NOT a field — it only
29
- ever derives from the JWT (forgery guard).
30
-
31
- ```jsonc
32
- {
33
- "region_id": "reg_…", // optional — falls back to the store's default region
34
- "email": "jane@example.com", // optional here; REQUIRED before complete
35
- "currency_code": "eur", // optional — unsupported values silently fall back to the region currency
36
- "items": [{ "variant_id": "variant_…", "quantity": 1,
37
- "selling_plan_id": "splan_…" }], // selling_plan_id optional —
38
- // same semantics as add-line below
39
-
40
- "sales_channel_id": "sc_…", // optional; must be inside the publishable key's scope if one is sent
41
- "promo_codes": ["WELCOME10"],
42
- "shipping_address": { "first_name": "Jane", "country_code": "bg" }, // all fields optional here
43
- "billing_address": null,
44
- "metadata": { "utm": "…" },
45
- "locale": "bg"
46
- }
47
- ```
48
-
49
- - **Response** — `201 {cart}` (decorated shape below).
50
- - **Errors** — 400 `validation_failed` | `invalid_region` | `region_required`
51
- (no `region_id` and no store default) | `invalid_sales_channel` (channel
52
- outside the publishable key's scope) | `price_not_found` (an initial item
53
- has no price in the cart currency); 404 `variant_not_found`.
54
- - **SDK** — `carts.createCart(client, input)`.
55
- - **Components** — cart drawer / add-to-cart buttons.
56
- - **Settings** — store default region (`stores.default_region_id`); enabled
57
- store currencies; publishable-key channel scope (b2b-v1); automatic
58
- promotions are applied on create.
59
-
60
- ```bash
61
- # Create a cart on the seeded dev tenant (store default region), with the
62
- # seeded Linen Shirt (M) variant. Capture ids for the blocks below.
63
- CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
64
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
65
- -d '{"email":"carts-doc-'"$RUN"'@example.test","currency_code":"eur",
66
- "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
67
- echo "$CART_JSON" | grep -q '"total"' # decorated totals present
68
- echo "$CART_JSON" | grep -q '"gift_card_total"' # gift-card tender decoration present
69
- CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
70
- REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
71
- test -n "$CART_ID" && test -n "$REGION_ID"
72
- ```
73
-
74
- ---
75
-
76
- ## GET /api/store/carts/:id — retrieve
77
-
78
- - **Purpose** — read the decorated cart (page load, cart drawer refresh).
79
- Every read re-runs tax recalculation, totals decoration and gift-card
80
- tender resolution — amounts are always current.
81
- - **Auth** — anon `x-client-id`.
82
- - **Request** — `GET`, no query.
83
- - **Response** — `200 {cart}`:
84
-
85
- ```jsonc
86
- {
87
- "cart": {
88
- "id": "cart_…",
89
- "region_id": "reg_…",
90
- "currency_code": "eur",
91
- "email": "jane@example.com",
92
- "customer_id": null, // set only via JWT paths
93
- "sales_channel_id": null,
94
- "completed_at": null, // ISO timestamp once completed
95
- "metadata": {},
96
- "region": { "…": "regions row" },
97
- "shipping_address": null, // cart_addresses row or null
98
- "billing_address": null,
99
- "items": [{
100
- "id": "li_…", "variant_id": "variant_…", "product_id": "prod_…",
101
- "title": "M", "product_title": "Linen Shirt", "product_handle": "linen-shirt",
102
- "thumbnail": "https://…", "variant_sku": "LIN-SHIRT-M",
103
- "quantity": 1, "unit_price": 45, "is_tax_inclusive": false,
104
- "is_discountable": true, "is_giftcard": false, "requires_shipping": true,
105
- "adjustments": [], "tax_lines": [{ "rate": 20 }],
106
- // per-line decoration:
107
- "subtotal": 45, "total": 54, "tax_total": 9, "original_total": 54,
108
- "discount_total": 0, "discount_subtotal": 0
109
- }],
110
- "shipping_methods": [], // same per-line decoration shape
111
- "credit_lines": [],
112
- "promotions": [],
113
- "payment_collection": [], // pivot → payment_collections (+ payment_sessions)
114
- // cart totals (ALL server-computed, EUR major units):
115
- "total": 54, "subtotal": 45, "tax_total": 9,
116
- "discount_total": 0, "discount_subtotal": 0, "discount_tax_total": 0,
117
- "shipping_total": 0, "shipping_subtotal": 0, "shipping_tax_total": 0,
118
- "item_total": 54, "item_subtotal": 45, "item_tax_total": 9,
119
- "original_total": 54, "original_subtotal": 45, "original_tax_total": 9,
120
- "credit_line_total": 0,
121
- "payment_method_fee_total": 0, // the selected method's own fee — non-zero only with a live method session
122
- "payment_method_fee_label": null,
123
- // gift-card tender decoration (totals above NEVER move):
124
- "gift_cards": [], // [{id, last4, amount}] in apply order
125
- "gift_card_total": 0, // Σ applied-card coverage
126
- "gift_card_remainder": 54 // max(total − gift_card_total, 0)
127
- }
128
- }
129
- ```
130
-
131
- - **Errors** — 404 `cart_not_found`.
132
- - **SDK** — `carts.retrieveCart(client, cartId)`.
133
- - **Components** — cart page/drawer, checkout summary.
134
- - **Settings** — region `automatic_taxes` (tax lines appear once a shipping
135
- address exists), COD integration (fee), gift cards.
136
-
137
- ```bash
138
- curl -sf "$BASE/api/store/carts/$CART_ID" -H "x-client-id: $CLIENT_ID" \
139
- | grep -q '"gift_card_remainder"'
140
-
141
- # Error contract: unknown cart → 404 {error, code: "cart_not_found"}
142
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
143
- "$BASE/api/store/carts/cart_does_not_exist" -H "x-client-id: $CLIENT_ID")
144
- test "$STATUS" = 404
145
- ```
146
-
147
- ---
148
-
149
- ## POST /api/store/carts/:id — update
150
-
151
- - **Purpose** — partial update: email, region/currency switch, addresses,
152
- metadata, locale.
153
- - **Auth** — anon `x-client-id`.
154
- - **Request** — `.strict()` body; every key optional. Address semantics:
155
- object = set/replace in place, `null` = clear, absent = untouched. A
156
- region change re-resolves the currency, **clears the shipping address**
157
- (unless a new one is sent in the same call), deletes custom-priced items
158
- and re-prices the rest with the cart customer's groups.
159
-
160
- ```jsonc
161
- {
162
- "region_id": "reg_…",
163
- "email": "new@example.com",
164
- "currency_code": "eur",
165
- "sales_channel_id": "sc_…",
166
- "metadata": {},
167
- "locale": "bg",
168
- "shipping_address": { "first_name": "Jane", "city": "Sofia", "country_code": "bg" },
169
- "billing_address": null
170
- }
171
- ```
172
-
173
- - **Response** — `200 {cart}`.
174
- - **Errors** — 404 `cart_not_found`; 409 `cart_completed`; 400
175
- `validation_failed` | `invalid_region`.
176
- - **SDK** — `carts.updateCart(client, cartId, input)`.
177
- - **Components** — region/locale switcher, checkout contact step.
178
- - **Settings** — store currencies; B2B price lists (region-change reprice);
179
- promotions re-applied after a region change.
180
-
181
- ```bash
182
- curl -sf -X POST "$BASE/api/store/carts/$CART_ID" \
183
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
184
- -d '{"email":"carts-doc-'"$RUN"'-updated@example.test"}' \
185
- | grep -q 'carts-doc-'"$RUN"'-updated@example.test'
186
- ```
187
-
188
- ---
189
-
190
- ## POST /api/store/carts/:id/customer — attach the authenticated customer
191
-
192
- - **Purpose** — after login, claim the guest cart for the signed-in
193
- customer (sets `customer_id` + `email` from the JWT).
194
- - **Auth** — anon `x-client-id` **+ REQUIRED Bearer JWT**.
195
- - **Request** — body MUST be `{}` (`z.object({}).strict()`). The customer
196
- is never accepted from the body — that was a forgery vector.
197
- - **Response** — `200 {cart}` with `customer_id` set.
198
- - **Errors** — 401 `unauthenticated` (no/invalid JWT); 404 `cart_not_found`;
199
- 409 `cart_completed`; 400 `validation_failed` (any body key).
200
- - **SDK** — `carts.setCartCustomer(client, cartId)`.
201
- - **Components** — login/callback flow, account drawer.
202
- - **Settings** — B2B: attaching a grouped customer changes subsequent line
203
- pricing (price lists).
204
-
205
- ```bash
206
- # Error contract (executable without a session): guest call → 401 unauthenticated.
207
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
208
- "$BASE/api/store/carts/$CART_ID/customer" \
209
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
210
- test "$STATUS" = 401
211
- ```
212
-
213
- ---
214
-
215
- ## POST /api/store/carts/:id/line-items — add item
216
-
217
- - **Purpose** — add a variant (adding a variant already in the cart under
218
- the SAME plan — or both one-time — bumps its quantity instead of
219
- duplicating the line; a one-time line and a subscription line of the same
220
- variant stay separate).
221
- - **Auth** — anon `x-client-id`.
222
- - **Request** — `{variant_id, quantity, selling_plan_id?, metadata?}`
223
- (quantity: positive integer). `selling_plan_id` (from
224
- `GET /products/:id/selling-plans`) subscribes THIS line: the server
225
- validates the plan is enabled + attached to the variant's product and
226
- applies the plan price (percent off / fixed / catalog for cadence-only) —
227
- the storefront never computes it. The line comes back with
228
- `selling_plan_id` set.
229
- - **Response** — `200 {cart}`.
230
- - **Errors** — 404 `cart_not_found` | `variant_not_found`; 409
231
- `cart_completed`; 400 `insufficient_inventory` (kit-aware — every linked
232
- inventory component is checked; `details` carries `{variant_id,
233
- inventory_item_id, available, requested}`) | `price_not_found` |
234
- `invalid_selling_plan` (unknown, disabled, or not attached to this
235
- product) | `validation_failed`.
236
- - **SDK** — `carts.addLineItem(client, cartId, input)`.
237
- - **Components** — PDP add-to-cart, cart drawer upsell.
238
- - **Settings** — B2B price lists (cart customer's groups); gift-card
239
- products ride the line as `is_giftcard: true` / `is_discountable: false`
240
- (digital cards also `requires_shipping: false`); promotions re-applied.
241
-
242
- ```bash
243
- # Add the seeded Wool Beanie (Black); capture its line id from the response.
244
- ADD_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
245
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
246
- -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
247
- echo "$ADD_JSON" | grep -q '"variant_sku":"WOOL-BNE-BLK"'
248
- # Anchor the line id to the beanie's SKU (item order in the array is not guaranteed).
249
- LINE_ID=$(echo "$ADD_JSON" \
250
- | grep -o '"id":"li_[^"]*"[^}]*"variant_sku":"WOOL-BNE-BLK"' \
251
- | head -1 | cut -d'"' -f4)
252
- test -n "$LINE_ID"
253
-
254
- # Error contract: an unknown subscription plan is refused before any write.
255
- SP_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
256
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
257
- -d '{"variant_id":"variant_01tst000000000000000003","quantity":1,"selling_plan_id":"splan_nope"}')
258
- echo "$SP_RES" | grep -q '"code":"invalid_selling_plan"'
259
- ```
260
-
261
- ---
262
-
263
- ## POST /api/store/carts/:id/line-items/:lineId — set quantity
264
-
265
- - **Purpose** — set a line's quantity; `0` deletes the line.
266
- - **Auth** — anon `x-client-id`.
267
- - **Request** — `{quantity}` (integer ≥ 0, REQUIRED).
268
- > Contract note (code wins over store-api.md): `metadata` is **not**
269
- > accepted on update — only on add.
270
- - **Response** — `200 {cart}`.
271
- - **Errors** — 404 `cart_not_found` | `line_item_not_found`; 409
272
- `cart_completed`; 400 `insufficient_inventory` | `validation_failed`.
273
- - **SDK** — `carts.updateLineItem(client, cartId, lineId, {quantity})`.
274
- - **Components** — cart drawer quantity stepper.
275
-
276
- ```bash
277
- curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
278
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
279
- -d '{"quantity":2}' | grep -q '"cart"'
280
- ```
281
-
282
- ---
283
-
284
- ## DELETE /api/store/carts/:id/line-items/:lineId — remove item
285
-
286
- - **Purpose** — remove a line (idempotent — an already-gone line still
287
- returns `200 {cart}`).
288
- - **Auth** — anon `x-client-id`.
289
- - **Request** — `DELETE`, no body.
290
- - **Response** — `200 {cart}`.
291
- - **Errors** — 404 `cart_not_found` (bad cart id).
292
- - **SDK** — `carts.deleteLineItem(client, cartId, lineId)`.
293
- - **Components** — cart drawer remove button.
294
-
295
- ```bash
296
- curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
297
- -H "x-client-id: $CLIENT_ID" | grep -q '"cart"'
298
- ```
299
-
300
- ---
301
-
302
- ## POST + DELETE /api/store/carts/:id/promotions — promo codes
303
-
304
- - **Purpose** — apply / remove discount codes; the server re-applies every
305
- cart promotion and recomputes totals (automatic promotions layer in on
306
- their own — only coded ones travel through here).
307
- - **Auth** — anon `x-client-id`.
308
- - **Request** — `{promo_codes: string[]}` on BOTH verbs (the DELETE reads
309
- its body, not query params).
310
- - **Response** — `200 {cart}` (decorated; `cart.promotions` is the raw
311
- pivot embed `[{promotion: {...}}]`). Unknown codes on REMOVE silently
312
- no-op by design; on ADD they error.
313
- - **Errors** — 404 `cart_not_found`; 404 `promotion_not_found` (unknown
314
- code on add), 400 `promotion_inactive` (draft/expired code on add), 400
315
- zod (empty array / non-string entries).
316
- - **SDK** — `carts.applyPromotions(client, cartId, codes)` /
317
- `carts.removePromotions(client, cartId, codes)`.
318
- - **Components** — checkout `DiscountSection` (apply + error copy via
319
- `translatePromotionError`); cart-drawer promo banner is display-only.
320
- - **Settings** — Promotions admin (codes, status, rules); the promotions
321
- engine decides eligibility server-side.
322
-
323
- ```bash
324
- # Error contract is executable without fixtures: unknown code on ADD.
325
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
326
- "$BASE/api/store/carts/$CART_ID/promotions" \
327
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
328
- -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}')
329
- test "$STATUS" = 404
330
-
331
- # REMOVE of an unknown code silently no-ops and returns the cart.
332
- curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/promotions" \
333
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
334
- -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}' | grep -q '"cart"'
335
- ```
336
-
337
- ---
338
-
339
- ## POST /api/store/carts/:id/complete — place the order
340
-
341
- Documented in full in [checkout.md](checkout.md) (the Buy-click sequence,
342
- completion guard, payment authorization, idempotency). Summary of the
343
- validation gate, demonstrated executably below:
344
-
345
- - **Response** — `200 {type: "order", order}`.
346
- > Contract note (code wins over store-api.md): the documented
347
- > `{type: "cart", cart, error}` failure union is **never** returned —
348
- > failures throw the standard error envelope (`{error, code, details?}`)
349
- > with a 4xx/5xx status and the cart stays open and retryable.
350
- - **Validation errors (400)** — `cart_email_required`, `cart_empty`,
351
- `shipping_address_required`, `shipping_method_required` (only when a line
352
- requires shipping — digital-only carts skip it),
353
- `payment_collection_required`, `payment_session_required`,
354
- `checkout_method_hidden` (checkout rules), `insufficient_inventory`.
355
- - **Other** — 403 `account_required` (store `accounts_mode='required'` +
356
- guest cart); 402 payment family (see checkout.md); 409 `cart_locked`.
357
- - **SDK** — `carts.completeCart(client, cartId)`.
358
-
359
- ```bash
360
- # Validation gate, executable: a bare cart (no email) refuses to complete.
361
- BARE_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
362
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
363
- BARE_ID=$(echo "$BARE_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
364
- COMPLETE_RES=$(curl -s -X POST "$BASE/api/store/carts/$BARE_ID/complete" \
365
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
366
- echo "$COMPLETE_RES" | grep -q '"code":"cart_email_required"'
367
- ```
368
-
369
- ---
370
-
371
- ## Cleanup / accretion note
372
-
373
- Carts have **no store-facing delete endpoint** (retention is a server
374
- concern). The carts this page creates are inert rows on the shared dev
375
- tenant — the same accretion the test suite's own cart tests produce; they
376
- are never re-read by other suites (every suite creates its own carts).
1
+ # Carts
2
+
3
+ The cart is the storefront's working document: created anonymously, mutated
4
+ through line-item and update calls, completed into an order (see
5
+ [checkout.md](checkout.md) for the Buy-click sequence and
6
+ [gift-cards.md](gift-cards.md) for gift-card tender). Every mutation returns
7
+ the **full decorated cart** — totals are server truth, the storefront never
8
+ does money math. All amounts are EUR decimal major units.
9
+
10
+ **Auth for every endpoint on this page:** anon `x-client-id` header. A
11
+ customer JWT (`authorization: Bearer <jwt>`) is optional and only changes
12
+ behavior where noted. Sending `x-publishable-api-key` additionally applies
13
+ B2B channel scope (see Settings notes).
14
+
15
+ SDK module: `@cartbase/storefront/api/carts`.
16
+
17
+ ---
18
+
19
+ ## POST /api/store/carts — create
20
+
21
+ - **Purpose** — create a cart; first call of every storefront session that
22
+ adds to cart.
23
+ - **Auth** — anon `x-client-id`. With a Bearer JWT the customer is attached
24
+ (`customer_id` + `email`) and initial items are priced with the customer's
25
+ B2B groups. With a publishable key, the cart defaults to the key's sales
26
+ channel.
27
+ - **Request** — `POST`, body is `.strict()` (unknown keys → 400
28
+ `validation_failed`). `customer_id` is deliberately NOT a field — it only
29
+ ever derives from the JWT (forgery guard).
30
+
31
+ ```jsonc
32
+ {
33
+ "region_id": "reg_…", // optional — falls back to the store's default region
34
+ "email": "jane@example.com", // optional here; REQUIRED before complete
35
+ "currency_code": "eur", // optional — unsupported values silently fall back to the region currency
36
+ "items": [{ "variant_id": "variant_…", "quantity": 1,
37
+ "selling_plan_id": "splan_…" }], // selling_plan_id optional —
38
+ // same semantics as add-line below
39
+
40
+ "sales_channel_id": "sc_…", // optional; must be inside the publishable key's scope if one is sent
41
+ "promo_codes": ["WELCOME10"],
42
+ "shipping_address": { "first_name": "Jane", "country_code": "bg" }, // all fields optional here
43
+ "billing_address": null,
44
+ "metadata": { "utm": "…" },
45
+ "locale": "bg"
46
+ }
47
+ ```
48
+
49
+ - **Response** — `201 {cart}` (decorated shape below).
50
+ - **Errors** — 400 `validation_failed` | `invalid_region` | `region_required`
51
+ (no `region_id` and no store default) | `invalid_sales_channel` (channel
52
+ outside the publishable key's scope) | `price_not_found` (an initial item
53
+ has no price in the cart currency); 404 `variant_not_found`.
54
+ - **SDK** — `carts.createCart(client, input)`.
55
+ - **Components** — cart drawer / add-to-cart buttons.
56
+ - **Settings** — store default region (`stores.default_region_id`); enabled
57
+ store currencies; publishable-key channel scope (b2b-v1); automatic
58
+ promotions are applied on create.
59
+
60
+ ```bash
61
+ # Create a cart on the seeded dev tenant (store default region), with the
62
+ # seeded Linen Shirt (M) variant. Capture ids for the blocks below.
63
+ CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
64
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
65
+ -d '{"email":"carts-doc-'"$RUN"'@example.test","currency_code":"eur",
66
+ "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
67
+ echo "$CART_JSON" | grep -q '"total"' # decorated totals present
68
+ echo "$CART_JSON" | grep -q '"gift_card_total"' # gift-card tender decoration present
69
+ CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
70
+ REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
71
+ test -n "$CART_ID" && test -n "$REGION_ID"
72
+ ```
73
+
74
+ ---
75
+
76
+ ## GET /api/store/carts/:id — retrieve
77
+
78
+ - **Purpose** — read the decorated cart (page load, cart drawer refresh).
79
+ Every read re-runs tax recalculation, totals decoration and gift-card
80
+ tender resolution — amounts are always current.
81
+ - **Auth** — anon `x-client-id`.
82
+ - **Request** — `GET`, no query.
83
+ - **Response** — `200 {cart}`:
84
+
85
+ ```jsonc
86
+ {
87
+ "cart": {
88
+ "id": "cart_…",
89
+ "region_id": "reg_…",
90
+ "currency_code": "eur",
91
+ "email": "jane@example.com",
92
+ "customer_id": null, // set only via JWT paths
93
+ "sales_channel_id": null,
94
+ "completed_at": null, // ISO timestamp once completed
95
+ "metadata": {},
96
+ "region": { "…": "regions row" },
97
+ "shipping_address": null, // cart_addresses row or null
98
+ "billing_address": null,
99
+ "items": [{
100
+ "id": "li_…", "variant_id": "variant_…", "product_id": "prod_…",
101
+ "title": "M", "product_title": "Linen Shirt", "product_handle": "linen-shirt",
102
+ "thumbnail": "https://…", "variant_sku": "LIN-SHIRT-M",
103
+ "quantity": 1, "unit_price": 45, "is_tax_inclusive": false,
104
+ "is_discountable": true, "is_giftcard": false, "requires_shipping": true,
105
+ "adjustments": [], "tax_lines": [{ "rate": 20 }],
106
+ // per-line decoration:
107
+ "subtotal": 45, "total": 54, "tax_total": 9, "original_total": 54,
108
+ "discount_total": 0, "discount_subtotal": 0
109
+ }],
110
+ "shipping_methods": [], // same per-line decoration shape
111
+ "credit_lines": [],
112
+ "promotions": [],
113
+ "payment_collection": [], // pivot → payment_collections (+ payment_sessions)
114
+ // cart totals (ALL server-computed, EUR major units):
115
+ "total": 54, "subtotal": 45, "tax_total": 9,
116
+ "discount_total": 0, "discount_subtotal": 0, "discount_tax_total": 0,
117
+ "shipping_total": 0, "shipping_subtotal": 0, "shipping_tax_total": 0,
118
+ "item_total": 54, "item_subtotal": 45, "item_tax_total": 9,
119
+ "original_total": 54, "original_subtotal": 45, "original_tax_total": 9,
120
+ "credit_line_total": 0,
121
+ "payment_method_fee_total": 0, // the selected method's own fee — non-zero only with a live method session
122
+ "payment_method_fee_label": null,
123
+ // gift-card tender decoration (totals above NEVER move):
124
+ "gift_cards": [], // [{id, last4, amount}] in apply order
125
+ "gift_card_total": 0, // Σ applied-card coverage
126
+ "gift_card_remainder": 54 // max(total − gift_card_total, 0)
127
+ }
128
+ }
129
+ ```
130
+
131
+ - **Errors** — 404 `cart_not_found`.
132
+ - **SDK** — `carts.retrieveCart(client, cartId)`.
133
+ - **Components** — cart page/drawer, checkout summary.
134
+ - **Settings** — region `automatic_taxes` (tax lines appear once a shipping
135
+ address exists), COD integration (fee), gift cards.
136
+
137
+ ```bash
138
+ curl -sf "$BASE/api/store/carts/$CART_ID" -H "x-client-id: $CLIENT_ID" \
139
+ | grep -q '"gift_card_remainder"'
140
+
141
+ # Error contract: unknown cart → 404 {error, code: "cart_not_found"}
142
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
143
+ "$BASE/api/store/carts/cart_does_not_exist" -H "x-client-id: $CLIENT_ID")
144
+ test "$STATUS" = 404
145
+ ```
146
+
147
+ ---
148
+
149
+ ## POST /api/store/carts/:id — update
150
+
151
+ - **Purpose** — partial update: email, region/currency switch, addresses,
152
+ metadata, locale.
153
+ - **Auth** — anon `x-client-id`.
154
+ - **Request** — `.strict()` body; every key optional. Address semantics:
155
+ object = set/replace in place, `null` = clear, absent = untouched. A
156
+ region change re-resolves the currency, **clears the shipping address**
157
+ (unless a new one is sent in the same call), deletes custom-priced items
158
+ and re-prices the rest with the cart customer's groups.
159
+
160
+ ```jsonc
161
+ {
162
+ "region_id": "reg_…",
163
+ "email": "new@example.com",
164
+ "currency_code": "eur",
165
+ "sales_channel_id": "sc_…",
166
+ "metadata": {},
167
+ "locale": "bg",
168
+ "shipping_address": { "first_name": "Jane", "city": "Sofia", "country_code": "bg" },
169
+ "billing_address": null
170
+ }
171
+ ```
172
+
173
+ - **Response** — `200 {cart}`.
174
+ - **Errors** — 404 `cart_not_found`; 409 `cart_completed`; 400
175
+ `validation_failed` | `invalid_region`.
176
+ - **SDK** — `carts.updateCart(client, cartId, input)`.
177
+ - **Components** — region/locale switcher, checkout contact step.
178
+ - **Settings** — store currencies; B2B price lists (region-change reprice);
179
+ promotions re-applied after a region change.
180
+
181
+ ```bash
182
+ curl -sf -X POST "$BASE/api/store/carts/$CART_ID" \
183
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
184
+ -d '{"email":"carts-doc-'"$RUN"'-updated@example.test"}' \
185
+ | grep -q 'carts-doc-'"$RUN"'-updated@example.test'
186
+ ```
187
+
188
+ ---
189
+
190
+ ## POST /api/store/carts/:id/customer — attach the authenticated customer
191
+
192
+ - **Purpose** — after login, claim the guest cart for the signed-in
193
+ customer (sets `customer_id` + `email` from the JWT).
194
+ - **Auth** — anon `x-client-id` **+ REQUIRED Bearer JWT**.
195
+ - **Request** — body MUST be `{}` (`z.object({}).strict()`). The customer
196
+ is never accepted from the body — that was a forgery vector.
197
+ - **Response** — `200 {cart}` with `customer_id` set.
198
+ - **Errors** — 401 `unauthenticated` (no/invalid JWT); 404 `cart_not_found`;
199
+ 409 `cart_completed`; 400 `validation_failed` (any body key).
200
+ - **SDK** — `carts.setCartCustomer(client, cartId)`.
201
+ - **Components** — login/callback flow, account drawer.
202
+ - **Settings** — B2B: attaching a grouped customer changes subsequent line
203
+ pricing (price lists).
204
+
205
+ ```bash
206
+ # Error contract (executable without a session): guest call → 401 unauthenticated.
207
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
208
+ "$BASE/api/store/carts/$CART_ID/customer" \
209
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
210
+ test "$STATUS" = 401
211
+ ```
212
+
213
+ ---
214
+
215
+ ## POST /api/store/carts/:id/line-items — add item
216
+
217
+ - **Purpose** — add a variant (adding a variant already in the cart under
218
+ the SAME plan — or both one-time — bumps its quantity instead of
219
+ duplicating the line; a one-time line and a subscription line of the same
220
+ variant stay separate).
221
+ - **Auth** — anon `x-client-id`.
222
+ - **Request** — `{variant_id, quantity, selling_plan_id?, metadata?}`
223
+ (quantity: positive integer). `selling_plan_id` (from
224
+ `GET /products/:id/selling-plans`) subscribes THIS line: the server
225
+ validates the plan is enabled + attached to the variant's product and
226
+ applies the plan price (percent off / fixed / catalog for cadence-only) —
227
+ the storefront never computes it. The line comes back with
228
+ `selling_plan_id` set.
229
+ - **Response** — `200 {cart}`.
230
+ - **Errors** — 404 `cart_not_found` | `variant_not_found`; 409
231
+ `cart_completed`; 400 `insufficient_inventory` (kit-aware — every linked
232
+ inventory component is checked; `details` carries `{variant_id,
233
+ inventory_item_id, available, requested}`) | `price_not_found` |
234
+ `invalid_selling_plan` (unknown, disabled, or not attached to this
235
+ product) | `validation_failed`.
236
+ - **SDK** — `carts.addLineItem(client, cartId, input)`.
237
+ - **Components** — PDP add-to-cart, cart drawer upsell.
238
+ - **Settings** — B2B price lists (cart customer's groups); gift-card
239
+ products ride the line as `is_giftcard: true` / `is_discountable: false`
240
+ (digital cards also `requires_shipping: false`); promotions re-applied.
241
+
242
+ ```bash
243
+ # Add the seeded Wool Beanie (Black); capture its line id from the response.
244
+ ADD_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
245
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
246
+ -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
247
+ echo "$ADD_JSON" | grep -q '"variant_sku":"WOOL-BNE-BLK"'
248
+ # Anchor the line id to the beanie's SKU (item order in the array is not guaranteed).
249
+ LINE_ID=$(echo "$ADD_JSON" \
250
+ | grep -o '"id":"li_[^"]*"[^}]*"variant_sku":"WOOL-BNE-BLK"' \
251
+ | head -1 | cut -d'"' -f4)
252
+ test -n "$LINE_ID"
253
+
254
+ # Error contract: an unknown subscription plan is refused before any write.
255
+ SP_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
256
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
257
+ -d '{"variant_id":"variant_01tst000000000000000003","quantity":1,"selling_plan_id":"splan_nope"}')
258
+ echo "$SP_RES" | grep -q '"code":"invalid_selling_plan"'
259
+ ```
260
+
261
+ ---
262
+
263
+ ## POST /api/store/carts/:id/line-items/:lineId — set quantity
264
+
265
+ - **Purpose** — set a line's quantity; `0` deletes the line.
266
+ - **Auth** — anon `x-client-id`.
267
+ - **Request** — `{quantity}` (integer ≥ 0, REQUIRED).
268
+ > Contract note (code wins over store-api.md): `metadata` is **not**
269
+ > accepted on update — only on add.
270
+ - **Response** — `200 {cart}`.
271
+ - **Errors** — 404 `cart_not_found` | `line_item_not_found`; 409
272
+ `cart_completed`; 400 `insufficient_inventory` | `validation_failed`.
273
+ - **SDK** — `carts.updateLineItem(client, cartId, lineId, {quantity})`.
274
+ - **Components** — cart drawer quantity stepper.
275
+
276
+ ```bash
277
+ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
278
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
279
+ -d '{"quantity":2}' | grep -q '"cart"'
280
+ ```
281
+
282
+ ---
283
+
284
+ ## DELETE /api/store/carts/:id/line-items/:lineId — remove item
285
+
286
+ - **Purpose** — remove a line (idempotent — an already-gone line still
287
+ returns `200 {cart}`).
288
+ - **Auth** — anon `x-client-id`.
289
+ - **Request** — `DELETE`, no body.
290
+ - **Response** — `200 {cart}`.
291
+ - **Errors** — 404 `cart_not_found` (bad cart id).
292
+ - **SDK** — `carts.deleteLineItem(client, cartId, lineId)`.
293
+ - **Components** — cart drawer remove button.
294
+
295
+ ```bash
296
+ curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
297
+ -H "x-client-id: $CLIENT_ID" | grep -q '"cart"'
298
+ ```
299
+
300
+ ---
301
+
302
+ ## POST + DELETE /api/store/carts/:id/promotions — promo codes
303
+
304
+ - **Purpose** — apply / remove discount codes; the server re-applies every
305
+ cart promotion and recomputes totals (automatic promotions layer in on
306
+ their own — only coded ones travel through here).
307
+ - **Auth** — anon `x-client-id`.
308
+ - **Request** — `{promo_codes: string[]}` on BOTH verbs (the DELETE reads
309
+ its body, not query params).
310
+ - **Response** — `200 {cart}` (decorated; `cart.promotions` is the raw
311
+ pivot embed `[{promotion: {...}}]`). Unknown codes on REMOVE silently
312
+ no-op by design; on ADD they error.
313
+ - **Errors** — 404 `cart_not_found`; 404 `promotion_not_found` (unknown
314
+ code on add), 400 `promotion_inactive` (draft/expired code on add), 400
315
+ zod (empty array / non-string entries).
316
+ - **SDK** — `carts.applyPromotions(client, cartId, codes)` /
317
+ `carts.removePromotions(client, cartId, codes)`.
318
+ - **Components** — checkout `DiscountSection` (apply + error copy via
319
+ `translatePromotionError`); cart-drawer promo banner is display-only.
320
+ - **Settings** — Promotions admin (codes, status, rules); the promotions
321
+ engine decides eligibility server-side.
322
+
323
+ ```bash
324
+ # Error contract is executable without fixtures: unknown code on ADD.
325
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
326
+ "$BASE/api/store/carts/$CART_ID/promotions" \
327
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
328
+ -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}')
329
+ test "$STATUS" = 404
330
+
331
+ # REMOVE of an unknown code silently no-ops and returns the cart.
332
+ curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/promotions" \
333
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
334
+ -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}' | grep -q '"cart"'
335
+ ```
336
+
337
+ ---
338
+
339
+ ## POST /api/store/carts/:id/complete — place the order
340
+
341
+ Documented in full in [checkout.md](checkout.md) (the Buy-click sequence,
342
+ completion guard, payment authorization, idempotency). Summary of the
343
+ validation gate, demonstrated executably below:
344
+
345
+ - **Response** — `200 {type: "order", order}`.
346
+ > Contract note (code wins over store-api.md): the documented
347
+ > `{type: "cart", cart, error}` failure union is **never** returned —
348
+ > failures throw the standard error envelope (`{error, code, details?}`)
349
+ > with a 4xx/5xx status and the cart stays open and retryable.
350
+ - **Validation errors (400)** — `cart_email_required`, `cart_empty`,
351
+ `shipping_address_required`, `shipping_method_required` (only when a line
352
+ requires shipping — digital-only carts skip it),
353
+ `payment_collection_required`, `payment_session_required`,
354
+ `checkout_method_hidden` (checkout rules), `insufficient_inventory`.
355
+ - **Other** — 403 `account_required` (store `accounts_mode='required'` +
356
+ guest cart); 402 payment family (see checkout.md); 409 `cart_locked`.
357
+ - **SDK** — `carts.completeCart(client, cartId)`.
358
+
359
+ ```bash
360
+ # Validation gate, executable: a bare cart (no email) refuses to complete.
361
+ BARE_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
362
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
363
+ BARE_ID=$(echo "$BARE_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
364
+ COMPLETE_RES=$(curl -s -X POST "$BASE/api/store/carts/$BARE_ID/complete" \
365
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
366
+ echo "$COMPLETE_RES" | grep -q '"code":"cart_email_required"'
367
+ ```
368
+
369
+ ---
370
+
371
+ ## Cleanup / accretion note
372
+
373
+ Carts have **no store-facing delete endpoint** (retention is a server
374
+ concern). The carts this page creates are inert rows on the shared dev
375
+ tenant — the same accretion the test suite's own cart tests produce; they
376
+ are never re-read by other suites (every suite creates its own carts).