Skip to content

feat(gift-cards): vendor-agnostic gift card spend rail — TBC adapter, purchase orchestration, GraphQL API (ENG-574) - #508

Open
forge0x wants to merge 6 commits into
mainfrom
feat/gift-cards-provider
Open

feat(gift-cards): vendor-agnostic gift card spend rail — TBC adapter, purchase orchestration, GraphQL API (ENG-574)#508
forge0x wants to merge 6 commits into
mainfrom
feat/gift-cards-provider

Conversation

@forge0x

@forge0x forge0x commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Vendor-agnostic gift card spend rail for Flash: one GiftCardProvider port 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

  • Domain + configsrc/domain/gift-cards, giftCards config block (routing, providers, catalog, limits off|log-only|enforce, flags-off baseline), GiftCard* errors registered and mapped, RateLimitConfig.giftCardPurchase, giftcard ops-event flow.
  • TBC adaptersrc/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.
  • PersistenceGiftCardOrder Mongo schema + repository with conditional transition(), partial-unique providerOrderId, unique {walletId, idempotencyKey}, migration. Claim codes AES-256-GCM with per-order key id; toJSON never emits ciphertext.
  • Catalog — Redis cache per provider/country with stale marker; 6h sync in cron under a lock; list/search/paginate served from cache only.
  • Purchase pathpurchaseGiftCard: 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, mirrors lnInvoicePaymentSend) inside withPaymentIdempotency with fingerprint ln|<bolt11>|giftcard|<orderId>; IBEX transaction.id stored as providerPaymentRef.
  • Limits — 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 (same rollout discipline as ENG-573).
  • WorkerreconcileGiftCardOrders: expiry, pending settlement via IBEX lookup, vendor polling with backoff, REFUND_REQUIRED escalation; cron job + 30s trigger interval. Fulfilment push notification with no claim data.
  • GraphQLgiftCardCatalog, giftCardQuote, giftCardOrder, giftCardOrders, giftCardPurchase, globals.giftCardsEnabled; relay connections; mutation at wallet level; API-key scope map entries BLOCKED; SDL + supergraph regenerated.
  • Docsdocs/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

  • Order reads (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.payInvoiceByWalletId was not used on purpose: it pays through LND, which this deployment does not run.
  • Country routing is phone-based (Account has no country); NANP numbers fall back to routing.default. Persisting a country on the account is an open decision (ENG-575).
  • make codegen / yarn check:sdl fail locally on two pre-existing Express type errors in src/servers/graphql-server.ts and graphql-admin-server.ts (they fail on main too). SDL was regenerated with the same steps run by hand.
  • Known gaps, documented in the runbook rather than hidden: no claim-key rotation script yet; no hash-based fallback when providerPaymentRef is missing; feeBps and referralCode are config-only; failureReason is machine text.

Tests

  • Full yarn test:unit: 290 suites, 3667 passed, 3 skipped.
  • New: ~600 unit tests across domain, adapter, persistence, crypto, cache, orchestration, limits, worker, GraphQL; Mongo integration spec for the transition race (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

bobodread876 and others added 4 commits September 9, 2026 23:59
…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
@linear

linear Bot commented Sep 10, 2026

Copy link
Copy Markdown

ENG-574

…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
@forge0x

forge0x commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Review round 1 applied — 3fc2191ef

Simon-style review returned NEEDS WORK (2 blocking, 6 should-fix). All eight are addressed in this commit.

Blocking

  • Indeterminate send errors no longer land in PAYMENT_FAILED. Only proven refusals do; a generic IBEX error or unconfirmed payment moves the order to PAYMENT_PENDING, the pending order is returned so the client polls instead of re-buying, and the reconciler falls back to a vendor poll when IBEX cannot account for the payment (including INVOICE_ISSUED at expiry, which closes the documented write-off window).
  • giftCardQuote now consumes its own attempt budget (30/min) before touching the vendor and carries query complexity 120. New 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.
  • giftCards.enabled now stops new money leaving only. The reconciler runs whenever non-terminal orders exist and settles through the registered provider, so paid customers still get 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). CREATED is never re-entered.
  • giftCardOrder returns the order with claim: null and records a claim-crypto error at Critical instead of throwing.
  • Vendor expiresAt is nullable; the order expiry is the earliest of order TTL, 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 and keeps the last good catalog.

PAYMENT_FAILED enum description updated and SDL regenerated. Docs updated to as-built. Two authentication specs that enumerate @config getters gained the two new limit getters.

Full yarn test:unit: 292 suites, 3738 passed, 3 skipped. Round 2 review in progress.

… 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
@forge0x

forge0x commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 applied — 7fbdf3a2d

Round 2 (reviewing 3fc2191ef) returned NEEDS WORK: 4 blocking, 18 should-fix. All addressed.

Blocking

  • A busy idempotency lock during a same-key replay no longer clobbers the first attempt's outcome with a null-ref PAYMENT_PENDING; it returns the current row unchanged (round-1 regression).
  • 24h escalation writes REFUND_REQUIRED only when the final vendor poll positively says not fulfilled; vendor errors, claim-key faults, and repo faults keep PAID and retry. The claim key is checked loadable immediately before every pay; failure fails the order before IBEX is called.
  • After IBEX answers, purchaseGiftCard never returns a bare error: lost transitions re-read and return the row; a repo fault returns the order and pages paid-not-recorded.
  • quantity is capped per product (maxQuantity, 1 for TBC until a multi-card fulfilment response is captured); the adapter refuses more before calling the vendor.

Should fix (highlights)

  • Resolver gate removed from giftCardPurchase so a replay works while the rail is off.
  • No-ref PAYMENT_PENDING self-terminates after expiresAt + 24h when the vendor reports not-paid; polls bump updatedAt so the batch rotates.
  • New EXPIRED → PAID transition so a late Success after worker expiry is fulfilled, not written off.
  • Product country is enforced against the account's gated country (as documented). Registry requires registration + enabled.
  • Vendor 4xx text never reaches customers; vendor quote 4xx → GIFT_CARD_INVALID_VALUE. TBC Disputed held pending. Physical/non-Lightning rows skipped at sync; VariableNoCents refuses cents up front; zero-product syncs fail closed.
  • Claim ciphertext AAD binds keyId + orderId (no cross-order transplant).
  • Replay does not consume the purchase rate limit; fake repo enforces the real transition table; ~350 new/retargeted specs.

GraphQL: GiftCardProduct.maxQuantity, wholeUnitsOnly; SDL regenerated. Docs brought to as-built.

Full yarn test:unit: 294 suites, 3920 tests (3 skipped), green. One earlier run hit a jest worker SIGSEGV in an unrelated suite; it passes in isolation on branch and main and the rerun is clean. Round 3 review in progress.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants