feat(gift-cards): vendor-agnostic gift card spend rail — TBC adapter, purchase orchestration, GraphQL API (ENG-574) - #508
feat(gift-cards): vendor-agnostic gift card spend rail — TBC adapter, purchase orchestration, GraphQL API (ENG-574)#508forge0x wants to merge 6 commits into
Conversation
…ration (ENG-574)
Vendor-agnostic gift card spend rail. One GiftCardProvider port, routed by
country from config; The Bitcoin Company is the first adapter (self-serve,
US/CA/EU catalog), Bitrefill is wired in config only. Orchestration, limits,
idempotency, persistence, and accounting hooks live once in Flash.
Domain + config
- src/domain/gift-cards: product/quote/order/claim types, IGiftCardProvider
port, order state machine (GIFT_CARD_TRANSITIONS), GiftCard* errors
registered in app/errors and mapped in graphql/error-map.
- giftCards config block (routing, providers, catalog, limits with
off|log-only|enforce) + flags-off baseline; RateLimitConfig.giftCardPurchase;
"giftcard" ops-event flow.
Adapter (ENG-577)
- services/gift-cards/bitcoin-company: JWT auth cached in Redis under a lock,
paginated catalog, quote, order (BOLT11), status with claim data; zod-validated
envelopes; retries only on idempotent reads; tokens and claim data redacted.
- Shared provider contract test.
Persistence (ENG-579)
- GiftCardOrder Mongo schema + repository with conditional transition(),
partial-unique providerOrderId index, unique {walletId, idempotencyKey},
migration; claim codes AES-256-GCM at rest with per-order keyId; toJSON
never emits ciphertext.
Catalog (ENG-578)
- Redis catalog cache per provider/country with stale marker; 6h sync job in
cron under a lock; list/search/paginate served from cache only.
Purchase path (ENG-580/581/583)
- purchaseGiftCard: gate -> product -> quote -> authorize -> persist CREATED ->
vendor order -> quote tolerance -> INVOICE_ISSUED -> idempotent payment ->
PAID/PAYMENT_PENDING/PAYMENT_FAILED -> one fulfilment read.
- Payment goes through the IBEX inline rail (new app/payments/
pay-invoice-via-ibex.ts mirroring lnInvoicePaymentSend) inside
withPaymentIdempotency with fingerprint ln|<bolt11>|giftcard|<orderId>;
IBEX transaction id persisted as providerPaymentRef for pending re-checks.
payInvoiceByWalletId is the dormant LND rail and is not used.
- authorizeGiftCardPurchase: level, account age, per-card and daily caps
(Redis reservations), vendor caps, velocity, open-loop rule; log-only by
default with would-reject ops events.
- reconcileGiftCardOrders: expiry, pending settlement via IBEX lookup,
vendor polling with backoff, REFUND_REQUIRED escalation; cron + 30s trigger.
- Fulfilment push notification (no claim data in the payload).
Tests: 477 unit tests across domain, adapter, persistence, crypto, cache,
orchestration, limits, worker; Mongo integration spec for the transition race.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf
…(ENG-582)
Public API for the gift card rail.
- Queries: giftCardCatalog (relay connection over the Redis catalog, country
defaults to the account's), giftCardQuote, giftCardOrder, giftCardOrders.
- Mutation: giftCardPurchase(input { productId, value, quantity, walletId,
idempotencyKey }) at wallet level so the existing walletId middleware proves
ownership before the resolver; payload carries the order and, when already
fulfilled, the decrypted claim.
- globals.giftCardsEnabled = feature on AND at least one provider enabled.
- Catalog, quote, and purchase open with the master gate. Order reads are
deliberately ungated: they are owner-scoped reads of orders the customer
already paid for, and switching the rail off must never hide their codes.
- GiftCardOrderStatus enum values are typed against the domain union; the
order type is built from an allow-list mapper so ciphertext/keyId can never
be exposed. failureReason is machine-oriented for now.
- API-key scope map: all five fields BLOCKED.
- SDL and supergraph regenerated (write-sdl + compose-supergraph run by hand
because `make codegen` trips on the two pre-existing Express type errors in
`yarn build`).
Tests: 120 resolver/type/enum tests incl. api-key scope enforcement.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf
…document ungated order reads - RateLimitConfig.giftCardPurchase used getFygaroCheckoutCreateAttemptLimits() by copy-paste; point it at getGiftCardPurchaseAttemptLimits() (same values today, independent knob from now on). - gift-card-gate.ts header and globals.giftCardsEnabled description said every giftCard* field is gated; only catalog, quote, and purchase are. Order history and delivered codes stay readable with the rail off. SDL regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf
…, testing (ENG-588)
docs/gift-cards/{README,ARCHITECTURE,FLOWS,API,CONFIG,ALERTING,RUNBOOK,TESTING}.md
written against the code as built, including the as-built deviations from the
design: IBEX inline payment rail, phone-only country resolution, no claim-key
rotation script yet, no hash-based payment fallback when providerPaymentRef is
missing, feeBps/referralCode defined but unused, Bitrefill present in config
only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf
…quote budget, pagination (#508) Blocking - Indeterminate send errors no longer become PAYMENT_FAILED. Only proven refusals (insufficient balance, FailedIbexPayment, send-guard rejection, idempotency key errors) are terminal; a generic IbexError / unconfirmed payment moves the order to PAYMENT_PENDING and the pending order is returned so the client polls instead of re-buying. The reconciler falls back to a vendor poll when IBEX cannot account for the payment (no providerPaymentRef or unknown), including for INVOICE_ISSUED orders at expiry, so a paid invoice can no longer be written off. - giftCardQuote now consumes its own attempt budget (30/min, 5 min block) before touching the vendor, with the same store-fault fall-through as purchase, and carries query complexity 120. New error code GIFT_CARD_QUOTE_RATE_LIMITED. Should fix - 24h fulfilment timeout polls the vendor first; REFUND_REQUIRED only when the vendor does not report fulfilled. - The giftCards.enabled kill switch stops new money leaving only. The reconciler runs whenever non-terminal orders exist and settles through the registered (not enabled) provider, so paid customers still get their codes and the 24h alert still fires. - Same-key replay of an unpaid, unexpired INVOICE_ISSUED order resumes from the pay step (idempotent by construction) instead of returning a row that would only ever expire. CREATED is never re-entered. - giftCardOrder returns the order with claim null and records the crypto error at Critical instead of throwing; the mutation keeps returning the order plus the mapped error. - Vendor expiresAt is nullable; the order's expiry is the earliest of the order TTL, the decoded BOLT11 expiry, and any vendor-stated expiry. - TBC catalog pagination advances by rows returned and stops on an empty page; at the page cap the sync fails (keeping the last good catalog) rather than writing a truncated or duplicate-heavy one. PAYMENT_FAILED enum description updated; SDL regenerated. Docs updated to match as-built behaviour (API, FLOWS, ARCHITECTURE, ALERTING, RUNBOOK, CONFIG, TESTING). Tests: full yarn test:unit green (see PR); ~150 new or retargeted specs across purchase, reconcile, settle, registry, quote, get-order, resolvers, adapter client and mapping. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf
Review round 1 applied —
|
… escalation, post-pay bookkeeping, vendor hardening (#508) Blocking - A busy idempotency lock during a same-key replay no longer writes PAYMENT_PENDING with a null ref over the first attempt's outcome; it returns the current row unchanged (regression from round 1). - The 24h fulfilment escalation writes REFUND_REQUIRED only when the final vendor poll positively reports not fulfilled. Vendor errors, claim-key faults, and repository faults keep the order PAID, are counted, and retry. The claim key is verified loadable (claimCryptoReady) immediately before every pay, on first attempt and replay-resume; failure fails the order (claim-key-not-configured) before IBEX is called. - After IBEX has answered, purchaseGiftCard never returns a bare error. A lost transition re-reads and returns the row (PAID/FULFILLED/PENDING); a repository fault returns the in-memory order and pages a Critical giftcard/paid-not-recorded event. Same treatment on the pending and failed bookkeeping steps. - quantity is capped per product (GiftCardProduct.maxQuantity; 1 for TBC until a multi-card fulfilment response is captured); the adapter refuses quantity > 1 before calling the vendor. Should fix - Resolver-level gate removed from giftCardPurchase so a same-key replay still returns the existing order while the rail is off. - PAYMENT_PENDING with no IBEX ref, vendor not-paid, past expiresAt + 24h -> PAYMENT_FAILED (payment-unresolved-expired); polls that leave status unchanged bump updatedAt so the worker batch rotates (repo touch()). - New transition EXPIRED -> PAID (payment-settled-after-expiry) so a late Success after worker expiry is recorded and fulfilled, not written off. - Unique-index race with no readable winner returns UnknownGiftCardError (the repository duplicate-key error no longer reaches error-map). - Product country is compared with the account's gated country at quote and purchase (skipped when the country is unknown), as the docs claimed. - Registry requires a provider to be registered as well as enabled; byCountry keys are case-normalised. - Vendor 4xx text no longer reaches customers: fixed message on order rejection; a 4xx on quote maps to GIFT_CARD_INVALID_VALUE at Warn. - TBC Disputed is held pending, not treated as refunded. Physical and non-Lightning products are skipped at sync (counted); non-resellable rows are flagged and kept until KYB (TODO ENG-586). VariableNoCents products carry wholeUnitsOnly and cents are refused up front. Non-load-bearing vendor fields are nullish. Inline first poll uses retry:false. - A sync that yields zero products is a failed sync; the previous catalog keeps serving. Cache reads validate shape, default legacy rows, and de-listed product keys are deleted on each sync. - Claim ciphertext AAD now binds version, keyId, and orderId, so a ciphertext cannot be transplanted between orders. Strict base64 check on decrypt. - Same-key replay no longer consumes the purchase rate limit. - Test fixtures' fake repository enforces the real transition table. GraphQL: GiftCardProduct gains maxQuantity and wholeUnitsOnly; paidSats description corrected; SDL regenerated. Docs brought to as-built throughout. Tests: full yarn test:unit green (see PR); +~350 specs incl. list-orders, registry, domain primitives, claim-crypto order binding, mutation payload shape after payment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf
Review round 2 applied —
|
Summary
Vendor-agnostic gift card spend rail for Flash: one
GiftCardProviderport routed by country, The Bitcoin Company (TBC) as the first adapter, Bitrefill wired in config only until partner credentials arrive. Users pick a card, Flash pays the vendor's BOLT11 from the user's own wallet over the IBEX rail, the code comes back encrypted at rest and is shown only to the owner.Linear: epic ENG-574 (project "Gift Cards — Spend Rail"), design doc "Gift Cards — Architecture Design". Tickets covered here: ENG-576, 577, 578, 579, 580, 581, 582, 583, and the docs half of 588.
What is in this PR
src/domain/gift-cards,giftCardsconfig block (routing, providers, catalog, limitsoff|log-only|enforce, flags-off baseline),GiftCard*errors registered and mapped,RateLimitConfig.giftCardPurchase,giftcardops-event flow.src/services/gift-cards/bitcoin-company: JWT auth cached in Redis under a lock, paginated catalog, quote, order, status with claim data; zod-validated vendor envelopes; retries only on idempotent reads; tokens and claim data redacted from logs. Shared provider contract test.GiftCardOrderMongo schema + repository with conditionaltransition(), partial-uniqueproviderOrderId, unique{walletId, idempotencyKey}, migration. Claim codes AES-256-GCM with per-order key id;toJSONnever emits ciphertext.purchaseGiftCard: gate → product → quote → authorize → persist CREATED → vendor order → quote tolerance → INVOICE_ISSUED → idempotent payment → PAID/PAYMENT_PENDING/PAYMENT_FAILED → one fulfilment read. Payment uses the IBEX inline rail (src/app/payments/pay-invoice-via-ibex.ts, mirrorslnInvoicePaymentSend) insidewithPaymentIdempotencywith fingerprintln|<bolt11>|giftcard|<orderId>; IBEXtransaction.idstored asproviderPaymentRef.log-onlyby default withwould-rejectops events (same rollout discipline as ENG-573).reconcileGiftCardOrders: expiry, pending settlement via IBEX lookup, vendor polling with backoff,REFUND_REQUIREDescalation; cron job + 30s trigger interval. Fulfilment push notification with no claim data.giftCardCatalog,giftCardQuote,giftCardOrder,giftCardOrders,giftCardPurchase,globals.giftCardsEnabled; relay connections; mutation at wallet level; API-key scope map entries BLOCKED; SDL + supergraph regenerated.docs/gift-cards/{README,ARCHITECTURE,FLOWS,API,CONFIG,ALERTING,RUNBOOK,TESTING}.md.Feature is off by default (
giftCards.enabled: false, every provider disabled). No behaviour change until config overrides turn it on.Review notes
giftCardOrder,giftCardOrders) are deliberately not behind the master gate: switching the rail off must never hide codes a customer already paid for. Ownership is enforced in the app layer.Payments.payInvoiceByWalletIdwas not used on purpose: it pays through LND, which this deployment does not run.Accounthas no country); NANP numbers fall back torouting.default. Persisting a country on the account is an open decision (ENG-575).make codegen/yarn check:sdlfail locally on two pre-existing Express type errors insrc/servers/graphql-server.tsandgraphql-admin-server.ts(they fail onmaintoo). SDL was regenerated with the same steps run by hand.providerPaymentRefis missing;feeBpsandreferralCodeare config-only;failureReasonis machine text.Tests
yarn test:unit: 290 suites, 3667 passed, 3 skipped.test/flash/integration/gift-cards, not run locally, needs Mongo).yarn tsc-check: clean except the two pre-existing errors above.eslint: clean on all touched files.Not in this PR
Mobile (ENG-589–591), ERPNext writer (ENG-584, blocked on doctype decision), Bitrefill adapter (ENG-585, blocked on credentials), alert wiring/dashboards (ENG-588), rollout (ENG-592).
🤖 Generated with Claude Code
https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf