# Gift cards — tender on carts

Gift-card redemption is a **payment tender** (`pp_giftcard`), never a
discount: cart totals and VAT compute first and **never move**; applied
cards cover part (or all) of `cart.total` and the remainder provider
(Stripe/COD/manual) charges only what is left. Balances derive from an
append-only transactions ledger — resolved LIVE on every cart read, so a
balance spent elsewhere shrinks this cart's tender instead of
over-redeeming. Actual redemption happens ONLY at cart complete, atomically.

**Auth for every endpoint on this page:** anon `x-client-id`.

SDK module: `@cartbase/storefront/api/gift-cards`. Zero-remainder checkout
(cards cover everything) is part of the Buy-click sequence — see
[checkout.md](checkout.md).

---

## Cart decoration (every cart read)

Every cart response carries three tender fields (declared on the `Cart` DTO
in `@cartbase/storefront/api/carts`; see [carts.md](carts.md) for the full
cart shape):

```jsonc
{
  "cart": {
    // …totals (NEVER moved by gift cards)…
    "total": 54,
    "gift_cards": [                 // apply order; each covers min(live balance, remaining total)
      { "id": "gift_…", "last4": "PQRS", "amount": 25 }
      // a disabled/expired/depleted card stays listed at amount: 0
    ],
    "gift_card_total": 25,          // Σ coverage = the pp_giftcard session amount
    "gift_card_remainder": 29       // max(total − gift_card_total, 0) — what the remainder provider charges
  }
}
```

```bash
# The decoration fields exist on every cart read (empty state shown here).
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"gc-doc-'"$RUN"'@example.test",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
echo "$CART_JSON" | grep -q '"gift_cards":\[\]'
echo "$CART_JSON" | grep -q '"gift_card_total":0'
echo "$CART_JSON" | grep -q '"gift_card_remainder"'
```

---

## POST /api/store/carts/:id/gift-cards — apply a code

- **Purpose** — apply a gift-card code to an open cart (idempotent
  re-apply).
- **Auth** — anon `x-client-id`.
- **Request** — `{code}` (`.strict()`, 4–64 chars). The code travels ONCE,
  here; it is hashed at rest and never echoed back.
- **Response** — `200 {cart, gift_card}` — a MASKED confirmation:

```jsonc
{
  "cart": { "…": "decorated cart — gift_cards/gift_card_total/gift_card_remainder updated, total untouched" },
  "gift_card": {
    "id": "gift_…",
    "last4": "PQRS",              // last 4 chars of the code — never the code
    "amount_applied": 25          // how much of THIS cart this card covers right now
  }
}
```

- **Errors** — deliberately a single generic answer, **no code-existence
  oracle**: unknown, disabled, expired, depleted and foreign-tenant codes
  are all `400 {code: "invalid_gift_card"}`. Attempts are recorded BEFORE
  lookup and rate-limited per cart (10 / 15 min) AND per IP (30 / 15 min) →
  `429 {code: "rate_limited"}` — a valid code inside a burned window is
  refused the same way. Also 404 `cart_not_found`, 409 `cart_completed`,
  400 `validation_failed`.
- **SDK** — `giftCards.applyGiftCard(client, cartId, {code})`.
- **Components** — checkout gift-card field (show `last4` +
  `amount_applied`; on 400 show one generic "code cannot be applied"
  message — do NOT branch on failure reasons that the API deliberately
  hides).
- **Settings** — cards are issued/disabled in the admin (gift-cards admin
  surface); expiry + balance live on the card's ledger. Applying also syncs
  the internal `pp_giftcard` payment session when a payment collection
  already exists (creating one composes it — checkout.md).

```bash
# Executable error contract: the generic-oracle answer. (A happy-path apply
# needs an admin-issued card — shown in the jsonc above; proven end-to-end
# by tests/store/gift-cards.test.ts.)
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/gift-cards" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -H "x-forwarded-for: 10.90.$((RANDOM % 250)).$((RANDOM % 250))" \
  -d '{"code":"NOPE-NOPE-NOPE-NOPE"}')
test "$STATUS" = 400
RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/gift-cards" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -H "x-forwarded-for: 10.90.$((RANDOM % 250)).$((RANDOM % 250))" \
  -d '{"code":"ALSO-NOT-A-REAL-CODE"}')
echo "$RES" | grep -q '"code":"invalid_gift_card"'
```

---

## DELETE /api/store/carts/:id/gift-cards — remove an applied card

- **Purpose** — remove an applied card from an open cart (idempotent —
  removing a never-applied id still returns the cart).
- **Auth** — anon `x-client-id`.
- **Request** — `{gift_card_id}` (`.strict()`; the id from
  `cart.gift_cards[].id` or the apply confirmation).
- **Response** — `200 {cart}` (decoration recomputed; the `pp_giftcard`
  session re-synced to the remaining tender).
- **Errors** — 404 `cart_not_found`, 409 `cart_completed`, 400
  `validation_failed`.
- **SDK** — `giftCards.removeGiftCard(client, cartId, {gift_card_id})`.
- **Components** — checkout applied-cards list (remove chip).

```bash
# Idempotent remove is executable without an issued card.
curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/gift-cards" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"gift_card_id":"gift_never_applied"}' | grep -q '"gift_card_total":0'
```

---

## What happens at complete (contract summary)

Redemption is atomic at `POST /carts/:id/complete` — idempotent per
(card, order); two carts racing one balance: exactly one completes, the
loser gets `402 gift_card_insufficient_balance` and its order is
compensated away. A remainder-payment failure reverses this order's
redemptions back to the cards before rethrowing. A gift session that no
longer covers its amount (card disabled/drained since apply) →
`402 payment_incomplete` / `402 gift_card_not_redeemable`, nothing burned.
Zero-remainder carts complete on the gift session alone — no provider
involved. Refunds/cancels always reverse tender **to the card**, never to
a bank. Buying gift-card products (digital issue-on-purchase, physical
activate-at-packing) is a catalog concern — see the product docs and the
gift-cards admin surface.

## Cleanup / accretion note

This page creates carts only (no store-facing delete endpoint exists —
same inert accretion as the suite's own cart tests) and applies no real
cards. Invalid-code attempts land in the rate-limit ledger under a unique
random IP per run, far below the per-IP window.
