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.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/tenants/:tenant/gift-cards | Issue 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/void | Void 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/balance | Look up a card’s balance. |
POST | /v1/ops/giftcards/accounting-offset-drain/run | Operator-triggered drain of the accounting-offset outbox (also a 30s sweep). |
GET | /v1/giftcards/audit | Operator 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.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /v1/merchant/gift-cards | manage:own_giftcards | List the caller’s issued cards. |
POST | /v1/merchant/gift-cards | manage:own_giftcards | Issue 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/void | manage:own_giftcards | Void one of the caller’s own cards — operator parity over the same repo transition; a double-void is an honest 409. |
Events
| Direction | Event |
|---|---|
| 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 frombehavior.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
checkoutwith deterministic per-leg idempotency keys; this module just holds the balance and the liability offset.