Repository navigation
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 —
|
Local node_modules carries prettier 3.9.6 while yarn.lock pins 3.6.2 (what CI installs); the two disagree on long union types, so `ops-events.ts` and `fixtures.ts` passed locally and failed Check Code. Reformatted with the pinned version. Also reworded "mis-mapping" (x2) and "unparseable" for the typos check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
…y and pending exit holes (#508) Three review findings on the reconcile worker's write-off paths: 1. Expiry wrote off a PAID order when the vendor reported paidPendingFulfillment. settleOrderFromVendor returned the order unchanged for it, so processExpiry saw no movement and expired an INVOICE_ISSUED row the vendor had positively vouched for — money left, a card ships, the row says EXPIRED and nothing polls it again. The vouch is now recorded: settleOrderFromVendor's paidPendingFulfillment arm transitions INVOICE_ISSUED/EXPIRED -> PAID (reason vendor-reported-payment, paidSats = invoiceSats ?? quoteSats) and the normal PAID poll path finishes fulfilment. PAYMENT_PENDING keeps its payment re-read as the authority; PAID is already past it. 2. A PAYMENT_PENDING order WITH a providerPaymentRef whose IBEX send stayed IN_FLIGHT forever had no terminal exit and was never vendor-polled — the payment-unresolved escalation required no ref. Past the PAYMENT_PENDING_WARN_MS warn horizon the vendor is now polled too: its payment vouch or fulfilment settles the order, and a vendor awaitingPayment past the 24h grace escalates to payment-unresolved-expired with ibex: "in-flight" in the event. 3. An INVOICE_ISSUED order whose expiry vendor poll ERRORED was expired unconditionally; EXPIRED is never revisited, so one transient outage permanently buried a possibly-paid order. A non-answer (vendor unreachable) or a vouch that failed to record now leaves the row INVOICE_ISSUED, touched so the batch rotates; expiry fires only when the vendor answers and does not report payment. The expiry spec expectations that asserted EXPIRED under the paidPendingFulfillment mock are rewritten to the new behaviour; new tests cover the vouch transition, the with-ref in-flight vendor poll and its escalation, and the deferred expiry. Prettier 3.6.2 (pinned) and typos clean; tsc and eslint clean on touched files. Co-Authored-By: Claude Code <noreply@anthropic.com>
#508) A PAYMENT_PENDING order with no providerPaymentRef gets "unknown" back from the payment re-read unconditionally — lookupSentPaymentStatus never asks IBEX without a ref — so the vendor is the only possible arbiter for that row. vendorVouchedPayment nevertheless excluded it, and vendorFailed's PAYMENT_PENDING arm returned it unchanged on every vendor answer: a send that errored without a verdict (socket reset / 5xx after IBEX accepted) and then settled at the vendor polled forever, recording nothing — never PAID, never the 24h escalation. - vendorVouchedPayment now accepts a ref-less PAYMENT_PENDING row's paidPendingFulfillment vouch (reason vendor-reported-payment); rows with a ref keep the payment re-read as the authority. The PAID path's 24h fulfilment timeout and the vendor-positive REFUND_REQUIRED escalation remain the safety net if the vouch proves wrong. - vendorFailed's no-ref arm terminates on vendor refunded (proof money moved: PAID via vendor-reported-payment, then REFUND_REQUIRED, which pages). A plain failed still only warns — it says nothing about money that may have left. - FLOWS.md §4/§6/§4a and ALERTING.md order-paid row brought in line with the vouch behaviour (FLOWS was stale from the previous round; ARCHITECTURE.md was already updated). - reconcile-orders.spec's settlement mirror and paidPendingFulfillment cases extended to cover the no-ref vouch; settle-order.spec gains with-ref vs no-ref coverage and the refunded two-step. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ref test
check-code (tsc) failed on the it.each ternary: an inferred
{ kind: string } is not assignable to GiftCardProviderOrderStatus. Give
the argument an explicit annotation.
Co-Authored-By: Claude Code <noreply@anthropic.com>
) - FLOWS.md: the with-ref pending vouch is fulfilled only — a paidPendingFulfillment answer is left for the re-read. - ALERTING.md: vendor-reported-payment on a ref-less PAYMENT_PENDING row also covers the vendor's refunded (lands PAID then REFUND_REQUIRED). Co-Authored-By: Claude Code <noreply@anthropic.com>
Review rounds 3–5 appliedThree more simon-review rounds ran over Rounds 2–3 returned ACCEPTABLE, 0 blocking; their remaining items were doc drift, fixed in
Final verdict: no blocking, no should-fix outstanding. Review history this PR: rounds 1–2 (2026-09-12), rounds 3–5 (this batch, 2026-09-14). 🤖 Generated with Claude Code |
1. Eligibility is not a limit. Level 0 / minAccountLevel, account age and the open-loop level floor lived inside the limits evaluation, and the shipped default `limits.mode: log-only` authorised every rejection it logged, so a device-only account created minutes ago could buy bearer codes. `checkEligibility` now runs before the mode switch and refuses in every mode, `off` included; only caps and velocity follow the rollout mode. Its refusal emits `giftcard / rejected`, never `would-reject`. 2. The 24h escalation refunded on any non-final vendor answer. `held`, `senttofulfillment`, unknown statuses and `fulfilled` without claim data all reach the worker as `paidPendingFulfillment`, and the row stayed PAID after settling, which the old code read as "vendor positively reports no card". Ops would refund, the vendor would ship, the customer keeps both. Now only `awaitingPayment` escalates to REFUND_REQUIRED; a still-in-progress answer keeps PAID, keeps polling, and pages `fulfillment-stalled` once per order. 3. A send in flight at IBEX when the worker expired the row had no exit when IBEX then answered Pending: the PAYMENT_PENDING write failed, the IBEX ref was never stored, the worker never polls EXPIRED, and the client read "expired, buy again" while the HTLC settled. EXPIRED now also exits to PAYMENT_PENDING (`payment-pending-after-expiry`) with the ref, mirroring the existing EXPIRED -> PAID branch. 4. The vendor client previewed any response that failed validation, redacted by a denylist of key names, including invoice-status. The branch that logs is exactly the one a renamed vendor field takes, and redaction cannot know the new name, so a `claimData -> claim` rename would have written live gift card codes to the log every reconcile tick. Only catalog and quote responses are previewable now; order and auth ops log the zod issue paths alone. Tests: matrix and per-mode eligibility cases (fix 1); awaitingPayment vs paidPendingFulfillment at 24h, stalled-then-fulfilled, stalled-then- unpaid, paged once (fix 2); late Pending on an EXPIRED row (fix 3); renamed-key invoice-status body never logged, catalog body still previewed, redaction proof moved to the quote op (fix 4). Docs updated where they described the old behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
…w cleanups The re-review of 7f83716 found that a PAID order the vendor still calls in-progress after 24h was polled on every 30s trigger tick: the horizon branch had no cadence gate because, before that commit, nothing stayed in it. The 5s/15s/60s/5m-then-15m schedule now applies on both sides of the horizon; a test drives +0, +30s, +5m, +15m and expects two vendor calls, and fails without the gate. Also from the re-review: `stalledReported` is cleared when the order leaves PAID by any route, the unreachable error branch in the stalled path is gone, `fulfillmentStalled` is on the run span, the EXPIRED re-open reason is a fixed `payment-pending-after-expiry`, and the log preview rationale says "a shape the schema did not predict" rather than "a renamed field" (a rename alone parses; it is the unknown nesting that reaches the preview branch). Formatted with prettier 3.6.2 as CI runs it; the previous commit's spec had one line the newer local prettier accepted and CI did not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
…sion CI runs) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
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