ReferenceGift cards

Gift cards

The giftcards module (schema giftcards) issues and redeems gift-card codes. A gift card is bearer-instrument money, distinct from the cash module’s per-user store-credit ledger: issuance mints a code (shown once, stored hashed); redemption applies it as (partial) tender at checkout, decrementing a maintained balance_cents under a row lock.

It is a near-exact structural mirror of cash: the same operator-token gate, the same accounting-offset outbox + 30s drain sweep posting a balanced transaction to accounting in-process (createAccountingModule(deps).repo — never hand-rolled HTTP), the same deterministic-idempotency exactly-once discipline. It is its own module (not a table in commerce) because it owns a schema, a liability outbox, and a sweep — the same three things that earned cash its own module.

Accounting model (mirrors cash): a two-account chart per tenant — giftcard:liability (what the tenant owes bearers of outstanding cards) and giftcard:program_expense. An issue increases both; a redeem decreases both symmetrically; a void extinguishes the remaining liability the same way a redeem draws it down (keyed giftcard-void:{card_id} in the offset outbox).

Checkout is the in-process consumer, both ways: it composes createGiftcardsRepo on its own connection and calls redeemByCodeHash to spend a card at checkout, and issue (with the published encodeCode / formatGiftCardId minters) to issue a card when a gift-card product line — a variant whose SKU begins GIFTCARD — is purchased, keyed to the order line so it mints exactly once. Operator/merchant issuance stays the routes below.

Operator routes

x-operator-token, rate-limited. Every gift-card route is an operator/service action today; redemption happens in-process via createGiftcardsRepo from checkout, not over HTTP.

MethodPathPurpose
POST/v1/tenants/:tenant/gift-cardsIssue a card — mints a high-entropy code returned here and only here (stored hashed). Idempotency-Key header: a replay returns the same metadata without re-showing the code. Currency is a closed allowlist (USD today) — anything else is a 400 unsupported_currency, never persisted.
POST/v1/tenants/:tenant/gift-cards/:id/voidVoid a card (the issue’s inverse): zeroes the remaining balance under a row lock, queues the liability-reversing accounting offset in the same transaction, refuses a double-void with an honest 409.
GET/v1/tenants/:tenant/gift-cards/balanceLook up a card’s balance.
POST/v1/ops/giftcards/accounting-offset-drain/runOperator-triggered drain of the accounting-offset outbox (also a 30s sweep).
GET/v1/giftcards/auditOperator audit-log read (makeAuditReadRoute).

Merchant routes

fdk_ credential, tenant from the credential per credential-carries-ownership, scope manage:own_giftcards. Mounted at /v1/merchant/gift-cards — the portal’s gift-cards page.

MethodPathScopePurpose
GET/v1/merchant/gift-cardsmanage:own_giftcardsList the caller’s issued cards.
POST/v1/merchant/gift-cardsmanage:own_giftcardsIssue a card (fresh idempotency key mints; a replay returns metadata only). Same closed currency allowlist as the operator route.
POST/v1/merchant/gift-cards/:id/voidmanage:own_giftcardsVoid one of the caller’s own cards — operator parity over the same repo transition; a double-void is an honest 409.

Events

DirectionEvent
Publishes(none — in-process accounting offset outbox + sweep)
Consumes(none)

Manifest gates

  • behavior.giftCards.enabled — purchasable/redeemable gift cards as a checkout tender. Default disabled — a new money surface ships dark. Distinct from behavior.storeCredit.enabled (the cash-backed store-credit tender, also default disabled).

Seams (honest notes)

  • A lost code cannot be recovered, only re-issued — plaintext is returned once at issuance and never stored (a later read yields the hash only).
  • Multi-tender redemption (store credit + gift card + card) is orchestrated by checkout with deterministic per-leg idempotency keys; this module just holds the balance and the liability offset.