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,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).
|