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: @barter/storefront/api/gift-cards. Zero-remainder checkout (cards cover everything) is part of the Buy-click sequence — see checkout.md.


Cart decoration (every cart read)

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

{
  "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
  }
}
# 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.
  • Response200 {cart, gift_card} — a MASKED confirmation:
{
  "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.
  • SDKgiftCards.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).
# 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).
  • Response200 {cart} (decoration recomputed; the pp_giftcard session re-synced to the remaining tender).
  • Errors — 404 cart_not_found, 409 cart_completed, 400 validation_failed.
  • SDKgiftCards.removeGiftCard(client, cartId, {gift_card_id}).
  • Components — checkout applied-cards list (remove chip).
# 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.