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. - Response —
200 {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 404cart_not_found, 409cart_completed, 400validation_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_giftcardpayment 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 fromcart.gift_cards[].idor the apply confirmation). - Response —
200 {cart}(decoration recomputed; thepp_giftcardsession re-synced to the remaining tender). - Errors — 404
cart_not_found, 409cart_completed, 400validation_failed. - SDK —
giftCards.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.