# Carts — lifecycle + line items

The cart is the storefront's working document: created anonymously, mutated
through line-item and update calls, completed into an order (see
[checkout.md](checkout.md) for the Buy-click sequence and
[gift-cards.md](gift-cards.md) for gift-card tender). Every mutation returns
the **full decorated cart** — totals are server truth, the storefront never
does money math. All amounts are EUR decimal major units.

**Auth for every endpoint on this page:** anon `x-client-id` header. A
customer JWT (`authorization: Bearer <jwt>`) is optional and only changes
behavior where noted. Sending `x-publishable-api-key` additionally applies
B2B channel scope (see Settings notes).

SDK module: `@cartbase/storefront/api/carts`.

---

## POST /api/store/carts — create

- **Purpose** — create a cart; first call of every storefront session that
  adds to cart.
- **Auth** — anon `x-client-id`. With a Bearer JWT the customer is attached
  (`customer_id` + `email`) and initial items are priced with the customer's
  B2B groups. With a publishable key, the cart defaults to the key's sales
  channel.
- **Request** — `POST`, body is `.strict()` (unknown keys → 400
  `validation_failed`). `customer_id` is deliberately NOT a field — it only
  ever derives from the JWT (forgery guard).

```jsonc
{
  "region_id": "reg_…",            // optional — falls back to the store's default region
  "email": "jane@example.com",     // optional here; REQUIRED before complete
  "currency_code": "eur",          // optional — unsupported values silently fall back to the region currency
  "items": [{ "variant_id": "variant_…", "quantity": 1,
              "selling_plan_id": "splan_…" }], // selling_plan_id optional —
                                    // same semantics as add-line below

  "sales_channel_id": "sc_…",      // optional; must be inside the publishable key's scope if one is sent
  "promo_codes": ["WELCOME10"],
  "shipping_address": { "first_name": "Jane", "country_code": "bg" }, // all fields optional here
  "billing_address": null,
  "metadata": { "utm": "…" },
  "locale": "bg"
}
```

- **Response** — `201 {cart}` (decorated shape below).
- **Errors** — 400 `validation_failed` | `invalid_region` | `region_required`
  (no `region_id` and no store default) | `invalid_sales_channel` (channel
  outside the publishable key's scope) | `price_not_found` (an initial item
  has no price in the cart currency); 404 `variant_not_found`.
- **SDK** — `carts.createCart(client, input)`.
- **Components** — cart drawer / add-to-cart buttons.
- **Settings** — store default region (`stores.default_region_id`); enabled
  store currencies; publishable-key channel scope (b2b-v1); automatic
  promotions are applied on create.

```bash
# Create a cart on the seeded dev tenant (store default region), with the
# seeded Linen Shirt (M) variant. Capture ids for the blocks below.
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"carts-doc-'"$RUN"'@example.test","currency_code":"eur",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
echo "$CART_JSON" | grep -q '"total"'            # decorated totals present
echo "$CART_JSON" | grep -q '"gift_card_total"'  # gift-card tender decoration present
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$CART_ID" && test -n "$REGION_ID"
```

---

## GET /api/store/carts/:id — retrieve

- **Purpose** — read the decorated cart (page load, cart drawer refresh).
  Every read re-runs tax recalculation, totals decoration and gift-card
  tender resolution — amounts are always current.
- **Auth** — anon `x-client-id`.
- **Request** — `GET`, no query.
- **Response** — `200 {cart}`:

```jsonc
{
  "cart": {
    "id": "cart_…",
    "region_id": "reg_…",
    "currency_code": "eur",
    "email": "jane@example.com",
    "customer_id": null,             // set only via JWT paths
    "sales_channel_id": null,
    "completed_at": null,            // ISO timestamp once completed
    "metadata": {},
    "region": { "…": "regions row" },
    "shipping_address": null,        // cart_addresses row or null
    "billing_address": null,
    "items": [{
      "id": "li_…", "variant_id": "variant_…", "product_id": "prod_…",
      "title": "M", "product_title": "Linen Shirt", "product_handle": "linen-shirt",
      "thumbnail": "https://…", "variant_sku": "LIN-SHIRT-M",
      "quantity": 1, "unit_price": 45, "is_tax_inclusive": false,
      "is_discountable": true, "is_giftcard": false, "requires_shipping": true,
      "adjustments": [], "tax_lines": [{ "rate": 20 }],
      // per-line decoration:
      "subtotal": 45, "total": 54, "tax_total": 9, "original_total": 54,
      "discount_total": 0, "discount_subtotal": 0
    }],
    "shipping_methods": [],          // same per-line decoration shape
    "credit_lines": [],
    "promotions": [],
    "payment_collection": [],        // pivot → payment_collections (+ payment_sessions)
    // cart totals (ALL server-computed, EUR major units):
    "total": 54, "subtotal": 45, "tax_total": 9,
    "discount_total": 0, "discount_subtotal": 0, "discount_tax_total": 0,
    "shipping_total": 0, "shipping_subtotal": 0, "shipping_tax_total": 0,
    "item_total": 54, "item_subtotal": 45, "item_tax_total": 9,
    "original_total": 54, "original_subtotal": 45, "original_tax_total": 9,
    "credit_line_total": 0,
    "cod_fee_total": 0,              // non-zero only with a live pp_cod session + enabled COD integration
    "cod_fee_label": null,
    // gift-card tender decoration (totals above NEVER move):
    "gift_cards": [],                // [{id, last4, amount}] in apply order
    "gift_card_total": 0,            // Σ applied-card coverage
    "gift_card_remainder": 54        // max(total − gift_card_total, 0)
  }
}
```

- **Errors** — 404 `cart_not_found`.
- **SDK** — `carts.retrieveCart(client, cartId)`.
- **Components** — cart page/drawer, checkout summary.
- **Settings** — region `automatic_taxes` (tax lines appear once a shipping
  address exists), COD integration (fee), gift cards.

```bash
curl -sf "$BASE/api/store/carts/$CART_ID" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"gift_card_remainder"'

# Error contract: unknown cart → 404 {error, code: "cart_not_found"}
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/carts/cart_does_not_exist" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```

---

## POST /api/store/carts/:id — update

- **Purpose** — partial update: email, region/currency switch, addresses,
  metadata, locale.
- **Auth** — anon `x-client-id`.
- **Request** — `.strict()` body; every key optional. Address semantics:
  object = set/replace in place, `null` = clear, absent = untouched. A
  region change re-resolves the currency, **clears the shipping address**
  (unless a new one is sent in the same call), deletes custom-priced items
  and re-prices the rest with the cart customer's groups.

```jsonc
{
  "region_id": "reg_…",
  "email": "new@example.com",
  "currency_code": "eur",
  "sales_channel_id": "sc_…",
  "metadata": {},
  "locale": "bg",
  "shipping_address": { "first_name": "Jane", "city": "Sofia", "country_code": "bg" },
  "billing_address": null
}
```

- **Response** — `200 {cart}`.
- **Errors** — 404 `cart_not_found`; 409 `cart_completed`; 400
  `validation_failed` | `invalid_region`.
- **SDK** — `carts.updateCart(client, cartId, input)`.
- **Components** — region/locale switcher, checkout contact step.
- **Settings** — store currencies; B2B price lists (region-change reprice);
  promotions re-applied after a region change.

```bash
curl -sf -X POST "$BASE/api/store/carts/$CART_ID" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"carts-doc-'"$RUN"'-updated@example.test"}' \
  | grep -q 'carts-doc-'"$RUN"'-updated@example.test'
```

---

## POST /api/store/carts/:id/customer — attach the authenticated customer

- **Purpose** — after login, claim the guest cart for the signed-in
  customer (sets `customer_id` + `email` from the JWT).
- **Auth** — anon `x-client-id` **+ REQUIRED Bearer JWT**.
- **Request** — body MUST be `{}` (`z.object({}).strict()`). The customer
  is never accepted from the body — that was a forgery vector.
- **Response** — `200 {cart}` with `customer_id` set.
- **Errors** — 401 `unauthenticated` (no/invalid JWT); 404 `cart_not_found`;
  409 `cart_completed`; 400 `validation_failed` (any body key).
- **SDK** — `carts.setCartCustomer(client, cartId)`.
- **Components** — login/callback flow, account drawer.
- **Settings** — B2B: attaching a grouped customer changes subsequent line
  pricing (price lists).

```bash
# Error contract (executable without a session): guest call → 401 unauthenticated.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/customer" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
test "$STATUS" = 401
```

---

## POST /api/store/carts/:id/line-items — add item

- **Purpose** — add a variant (adding a variant already in the cart under
  the SAME plan — or both one-time — bumps its quantity instead of
  duplicating the line; a one-time line and a subscription line of the same
  variant stay separate).
- **Auth** — anon `x-client-id`.
- **Request** — `{variant_id, quantity, selling_plan_id?, metadata?}`
  (quantity: positive integer). `selling_plan_id` (from
  `GET /products/:id/selling-plans`) subscribes THIS line: the server
  validates the plan is enabled + attached to the variant's product and
  applies the plan price (percent off / fixed / catalog for cadence-only) —
  the storefront never computes it. The line comes back with
  `selling_plan_id` set.
- **Response** — `200 {cart}`.
- **Errors** — 404 `cart_not_found` | `variant_not_found`; 409
  `cart_completed`; 400 `insufficient_inventory` (kit-aware — every linked
  inventory component is checked; `details` carries `{variant_id,
  inventory_item_id, available, requested}`) | `price_not_found` |
  `invalid_selling_plan` (unknown, disabled, or not attached to this
  product) | `validation_failed`.
- **SDK** — `carts.addLineItem(client, cartId, input)`.
- **Components** — PDP add-to-cart, cart drawer upsell.
- **Settings** — B2B price lists (cart customer's groups); gift-card
  products ride the line as `is_giftcard: true` / `is_discountable: false`
  (digital cards also `requires_shipping: false`); promotions re-applied.

```bash
# Add the seeded Wool Beanie (Black); capture its line id from the response.
ADD_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
echo "$ADD_JSON" | grep -q '"variant_sku":"WOOL-BNE-BLK"'
# Anchor the line id to the beanie's SKU (item order in the array is not guaranteed).
LINE_ID=$(echo "$ADD_JSON" \
  | grep -o '"id":"li_[^"]*"[^}]*"variant_sku":"WOOL-BNE-BLK"' \
  | head -1 | cut -d'"' -f4)
test -n "$LINE_ID"

# Error contract: an unknown subscription plan is refused before any write.
SP_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"variant_id":"variant_01tst000000000000000003","quantity":1,"selling_plan_id":"splan_nope"}')
echo "$SP_RES" | grep -q '"code":"invalid_selling_plan"'
```

---

## POST /api/store/carts/:id/line-items/:lineId — set quantity

- **Purpose** — set a line's quantity; `0` deletes the line.
- **Auth** — anon `x-client-id`.
- **Request** — `{quantity}` (integer ≥ 0, REQUIRED).
  > Contract note (code wins over store-api.md): `metadata` is **not**
  > accepted on update — only on add.
- **Response** — `200 {cart}`.
- **Errors** — 404 `cart_not_found` | `line_item_not_found`; 409
  `cart_completed`; 400 `insufficient_inventory` | `validation_failed`.
- **SDK** — `carts.updateLineItem(client, cartId, lineId, {quantity})`.
- **Components** — cart drawer quantity stepper.

```bash
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"quantity":2}' | grep -q '"cart"'
```

---

## DELETE /api/store/carts/:id/line-items/:lineId — remove item

- **Purpose** — remove a line (idempotent — an already-gone line still
  returns `200 {cart}`).
- **Auth** — anon `x-client-id`.
- **Request** — `DELETE`, no body.
- **Response** — `200 {cart}`.
- **Errors** — 404 `cart_not_found` (bad cart id).
- **SDK** — `carts.deleteLineItem(client, cartId, lineId)`.
- **Components** — cart drawer remove button.

```bash
curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"cart"'
```

---

## POST + DELETE /api/store/carts/:id/promotions — promo codes

- **Purpose** — apply / remove discount codes; the server re-applies every
  cart promotion and recomputes totals (automatic promotions layer in on
  their own — only coded ones travel through here).
- **Auth** — anon `x-client-id`.
- **Request** — `{promo_codes: string[]}` on BOTH verbs (Medusa reads the
  DELETE body, not query).
- **Response** — `200 {cart}` (decorated; `cart.promotions` is the raw
  pivot embed `[{promotion: {...}}]`). Unknown codes on REMOVE silently
  no-op (Medusa parity); on ADD they error.
- **Errors** — 404 `cart_not_found`; 404 `promotion_not_found` (unknown
  code on add), 400 `promotion_inactive` (draft/expired code on add), 400
  zod (empty array / non-string entries).
- **SDK** — `carts.applyPromotions(client, cartId, codes)` /
  `carts.removePromotions(client, cartId, codes)`.
- **Components** — checkout `DiscountSection` (apply + error copy via
  `translatePromotionError`); cart-drawer promo banner is display-only.
- **Settings** — Promotions admin (codes, status, rules); the promotions
  engine decides eligibility server-side.

```bash
# Error contract is executable without fixtures: unknown code on ADD.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/promotions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}')
test "$STATUS" = 404

# REMOVE of an unknown code silently no-ops and returns the cart.
curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/promotions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}' | grep -q '"cart"'
```

---

## POST /api/store/carts/:id/complete — place the order

Documented in full in [checkout.md](checkout.md) (the Buy-click sequence,
completion guard, payment authorization, idempotency). Summary of the
validation gate, demonstrated executably below:

- **Response** — `200 {type: "order", order}`.
  > Contract note (code wins over store-api.md): the documented
  > `{type: "cart", cart, error}` failure union is **never** returned —
  > failures throw the standard error envelope (`{error, code, details?}`)
  > with a 4xx/5xx status and the cart stays open and retryable.
- **Validation errors (400)** — `cart_email_required`, `cart_empty`,
  `shipping_address_required`, `shipping_method_required` (only when a line
  requires shipping — digital-only carts skip it),
  `payment_collection_required`, `payment_session_required`,
  `checkout_method_hidden` (checkout rules), `insufficient_inventory`.
- **Other** — 403 `account_required` (store `accounts_mode='required'` +
  guest cart); 402 payment family (see checkout.md); 409 `cart_locked`.
- **SDK** — `carts.completeCart(client, cartId)`.

```bash
# Validation gate, executable: a bare cart (no email) refuses to complete.
BARE_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
BARE_ID=$(echo "$BARE_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
COMPLETE_RES=$(curl -s -X POST "$BASE/api/store/carts/$BARE_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$COMPLETE_RES" | grep -q '"code":"cart_email_required"'
```

---

## Cleanup / accretion note

Carts have **no store-facing delete endpoint** (retention is a server
concern). The carts this page creates are inert rows on the shared dev
tenant — the same accretion the test suite's own cart tests produce; they
are never re-read by other suites (every suite creates its own carts).
