From 2eac641ae66276e4debd4e5748b176f07559110d Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 9 Sep 2026 23:59:03 -0700 Subject: [PATCH 01/14] feat(gift-cards): provider abstraction, TBC adapter, purchase orchestration (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||giftcard|; 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 Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf --- dev/config/base-config.yaml | 44 ++ src/app/errors.ts | 2 + src/app/gift-cards/authorize-purchase.ts | 330 ++++++++ src/app/gift-cards/get-order.ts | 51 ++ src/app/gift-cards/gift-cards-master-gate.ts | 112 +++ src/app/gift-cards/index.ts | 42 + src/app/gift-cards/list-orders.ts | 50 ++ src/app/gift-cards/list-products.ts | 179 +++++ src/app/gift-cards/ops.ts | 61 ++ src/app/gift-cards/purchase-gift-card.ts | 528 +++++++++++++ src/app/gift-cards/quote-gift-card.ts | 83 ++ src/app/gift-cards/reconcile-orders.ts | 498 ++++++++++++ src/app/gift-cards/reservation-store.ts | 155 ++++ .../gift-cards/send-fulfilled-notification.ts | 52 ++ src/app/gift-cards/settle-order.ts | 232 ++++++ src/app/gift-cards/sync-catalog.ts | 245 ++++++ src/app/index.ts | 3 + .../send-outcome-notification.ts | 12 +- src/app/payments/pay-invoice-via-ibex.ts | 64 ++ src/config/locales/en.json | 6 + src/config/locales/es.json | 6 + src/config/schema.ts | 148 ++++ src/config/schema.types.d.ts | 56 ++ src/config/yaml.ts | 8 + src/domain/gift-cards/errors.ts | 87 ++ src/domain/gift-cards/index.ts | 56 ++ src/domain/gift-cards/index.types.d.ts | 149 ++++ src/domain/gift-cards/primitives.ts | 93 +++ src/domain/rate-limit/errors.ts | 1 + src/domain/rate-limit/index.ts | 8 + src/graphql/error-map.ts | 121 +++ ...20260909120000-gift-card-orders-indexes.ts | 120 +++ src/servers/cron.ts | 31 +- src/servers/trigger.ts | 6 + src/services/alerts/ops-events.ts | 4 +- .../gift-cards/bitcoin-company/client.ts | 743 ++++++++++++++++++ .../gift-cards/bitcoin-company/errors.ts | 38 + .../gift-cards/bitcoin-company/index.ts | 165 ++++ .../gift-cards/bitcoin-company/mapping.ts | 218 +++++ .../gift-cards/bitcoin-company/schemas.ts | 123 +++ src/services/gift-cards/catalog-cache.ts | 243 ++++++ src/services/gift-cards/claim-crypto.ts | 168 ++++ src/services/gift-cards/index.ts | 8 + src/services/gift-cards/registry.ts | 64 ++ .../mongoose/gift-card-orders.mapping.ts | 119 +++ src/services/mongoose/gift-card-orders.ts | 294 +++++++ src/services/mongoose/index.ts | 1 + src/services/mongoose/schema.ts | 91 +++ src/services/mongoose/schema.types.d.ts | 47 ++ .../gift-cards/gift-card-orders.spec.ts | 299 +++++++ .../app/gift-cards/authorize-purchase.spec.ts | 536 +++++++++++++ test/flash/unit/app/gift-cards/fixtures.ts | 256 ++++++ .../gift-cards/gift-cards-master-gate.spec.ts | 144 ++++ .../unit/app/gift-cards/list-products.spec.ts | 429 ++++++++++ .../app/gift-cards/purchase-gift-card.spec.ts | 636 +++++++++++++++ .../app/gift-cards/quote-gift-card.spec.ts | 191 +++++ .../app/gift-cards/reconcile-orders.spec.ts | 538 +++++++++++++ .../unit/app/gift-cards/settle-order.spec.ts | 328 ++++++++ .../unit/app/gift-cards/sync-catalog.spec.ts | 408 ++++++++++ .../app/payments/pay-invoice-via-ibex.spec.ts | 147 ++++ .../gift-cards/bitcoin-company/client.spec.ts | 738 +++++++++++++++++ .../bitcoin-company/contract.spec.ts | 298 +++++++ .../gift-cards/bitcoin-company/fixtures.ts | 273 +++++++ .../bitcoin-company/mapping.spec.ts | 412 ++++++++++ .../services/gift-cards/catalog-cache.spec.ts | 347 ++++++++ .../services/gift-cards/claim-crypto.spec.ts | 260 ++++++ .../services/gift-cards/provider-contract.ts | 216 +++++ .../mongoose/gift-card-orders.mapping.spec.ts | 336 ++++++++ .../mongoose/gift-card-orders.schema.spec.ts | 132 ++++ .../mongoose/gift-card-orders.spec.ts | 377 +++++++++ 70 files changed, 13261 insertions(+), 5 deletions(-) create mode 100644 src/app/gift-cards/authorize-purchase.ts create mode 100644 src/app/gift-cards/get-order.ts create mode 100644 src/app/gift-cards/gift-cards-master-gate.ts create mode 100644 src/app/gift-cards/index.ts create mode 100644 src/app/gift-cards/list-orders.ts create mode 100644 src/app/gift-cards/list-products.ts create mode 100644 src/app/gift-cards/ops.ts create mode 100644 src/app/gift-cards/purchase-gift-card.ts create mode 100644 src/app/gift-cards/quote-gift-card.ts create mode 100644 src/app/gift-cards/reconcile-orders.ts create mode 100644 src/app/gift-cards/reservation-store.ts create mode 100644 src/app/gift-cards/send-fulfilled-notification.ts create mode 100644 src/app/gift-cards/settle-order.ts create mode 100644 src/app/gift-cards/sync-catalog.ts create mode 100644 src/app/payments/pay-invoice-via-ibex.ts create mode 100644 src/domain/gift-cards/errors.ts create mode 100644 src/domain/gift-cards/index.ts create mode 100644 src/domain/gift-cards/index.types.d.ts create mode 100644 src/domain/gift-cards/primitives.ts create mode 100644 src/migrations/20260909120000-gift-card-orders-indexes.ts create mode 100644 src/services/gift-cards/bitcoin-company/client.ts create mode 100644 src/services/gift-cards/bitcoin-company/errors.ts create mode 100644 src/services/gift-cards/bitcoin-company/index.ts create mode 100644 src/services/gift-cards/bitcoin-company/mapping.ts create mode 100644 src/services/gift-cards/bitcoin-company/schemas.ts create mode 100644 src/services/gift-cards/catalog-cache.ts create mode 100644 src/services/gift-cards/claim-crypto.ts create mode 100644 src/services/gift-cards/index.ts create mode 100644 src/services/gift-cards/registry.ts create mode 100644 src/services/mongoose/gift-card-orders.mapping.ts create mode 100644 src/services/mongoose/gift-card-orders.ts create mode 100644 test/flash/integration/gift-cards/gift-card-orders.spec.ts create mode 100644 test/flash/unit/app/gift-cards/authorize-purchase.spec.ts create mode 100644 test/flash/unit/app/gift-cards/fixtures.ts create mode 100644 test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts create mode 100644 test/flash/unit/app/gift-cards/list-products.spec.ts create mode 100644 test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts create mode 100644 test/flash/unit/app/gift-cards/quote-gift-card.spec.ts create mode 100644 test/flash/unit/app/gift-cards/reconcile-orders.spec.ts create mode 100644 test/flash/unit/app/gift-cards/settle-order.spec.ts create mode 100644 test/flash/unit/app/gift-cards/sync-catalog.spec.ts create mode 100644 test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts create mode 100644 test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts create mode 100644 test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts create mode 100644 test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts create mode 100644 test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts create mode 100644 test/flash/unit/services/gift-cards/catalog-cache.spec.ts create mode 100644 test/flash/unit/services/gift-cards/claim-crypto.spec.ts create mode 100644 test/flash/unit/services/gift-cards/provider-contract.ts create mode 100644 test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts create mode 100644 test/flash/unit/services/mongoose/gift-card-orders.schema.spec.ts create mode 100644 test/flash/unit/services/mongoose/gift-card-orders.spec.ts diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index fa46f6dfc..b67dd9f1c 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -40,6 +40,50 @@ fygaro: # auto-credit limit so ops has runway to top up before the well runs dry. floorUsd: 2000 +giftCards: + enabled: false # flags-off baseline; enable per environment via config overrides + allowOpenLoop: false + feeBps: 0 + # 32-byte key for AES-256-GCM encryption of claim codes at rest. + # Real key injected via config overrides; never commit a real key. + claimDataEncryptionKey: "" + quoteToleranceBps: 100 + routing: + default: bitcoinCompany + byCountry: {} # e.g. { JM: bitrefill, TT: bitrefill } once the Bitrefill adapter is live + providers: + bitcoinCompany: + enabled: false + baseUrl: "https://api.dev.thebitcoincompany.com" # sandbox (Mutinynet); prod is https://api.thebitcoincompany.com + email: "" # real credentials injected via config overrides; never commit real credentials + password: "" + referralCode: "" + timeoutMs: 10000 + bitrefill: + enabled: false + baseUrl: "" # set once Bitrefill Business API credentials are issued + apiId: "" + apiSecret: "" + webhookSecret: "" + timeoutMs: 10000 + catalog: + syncIntervalSeconds: 21600 + ttlSeconds: 21600 + staleAfterSeconds: 86400 + limits: + mode: log-only # off | log-only | enforce — same rollout discipline as the send guard + minAccountLevel: 1 + minAccountAgeHours: 24 + maxOrdersPerHour: 5 + vendorDailyCapCents: 1000000 # $10,000 — TBC FinCEN prepaid-access exemption + vendorOpenLoopCardCapCents: 100000 # $1,000 + vendorClosedLoopCardCapCents: 200000 # $2,000 + perLevel: + level0: { perCardCents: 0, dailyCents: 0 } + level1: { perCardCents: 20000, dailyCents: 50000 } + level2: { perCardCents: 50000, dailyCents: 200000 } + level3: { perCardCents: 100000, dailyCents: 500000 } + bridge: enabled: false # flags-off baseline; enable per environment via config overrides apiKey: "" # real key injected via config overrides; never commit a real key diff --git a/src/app/errors.ts b/src/app/errors.ts index 1fabd9921..2b0a66259 100644 --- a/src/app/errors.ts +++ b/src/app/errors.ts @@ -31,6 +31,7 @@ import * as IbexErrors from "@services/ibex/errors" import * as ErpNextErrors from "@services/frappe/errors" import * as BridgeErrors from "@services/bridge/errors" import * as FygaroErrors from "@services/fygaro/errors" +import * as GiftCardErrors from "@domain/gift-cards/errors" import * as ApiKeyErrors from "@domain/api-keys/errors" export const ApplicationErrors = { @@ -68,5 +69,6 @@ export const ApplicationErrors = { ...ErpNextErrors, ...BridgeErrors, ...FygaroErrors, + ...GiftCardErrors, ...ApiKeyErrors, } as const diff --git a/src/app/gift-cards/authorize-purchase.ts b/src/app/gift-cards/authorize-purchase.ts new file mode 100644 index 000000000..766c2f832 --- /dev/null +++ b/src/app/gift-cards/authorize-purchase.ts @@ -0,0 +1,330 @@ +import { GiftCardsConfig } from "@config" + +import { effectiveAccountLevel } from "@domain/accounts" +import { + GiftCardLevelNotEligibleError, + GiftCardLimitExceededError, + GiftCardOrderStatus, + GiftCardsDisabledError, + UnknownGiftCardError, +} from "@domain/gift-cards" +import { notifyOpsEvent } from "@services/alerts/ops-events" +import { baseLogger } from "@services/logger" +import { GiftCardOrdersRepository } from "@services/mongoose" +import { addAttributesToCurrentSpan } from "@services/tracing" + +import { + readGiftCardReservations, + releaseGiftCardReservation, + writeGiftCardReservation, +} from "./reservation-store" + +export { releaseGiftCardReservation } + +/** + * ENG-583 — limits and compliance for the gift-card spend rail. + * + * TBC's unverified-user limits ($1,000 open-loop / $2,000 closed-loop per + * card, $10,000 per user per day) are what keep the product inside FinCEN's + * prepaid-access exemption, and TBC puts the monitoring burden on the + * reseller. Flash layers its own per-level caps underneath (perLevel.*), so + * the effective cap is always `min(flash, vendor)`. + * + * Modes (`giftCards.limits.mode`), same rollout discipline as the ENG-573 send + * guard: `off` skips every check; `log-only` runs them all, emits a + * `giftcard / would-reject` ops event on failure and AUTHORISES anyway; + * `enforce` refuses. A week of would-reject data is reviewed before the flip. + * + * Rejection reasons have exactly one source — this const — so the specs and + * the would-reject census reference the same strings. + */ +export const GiftCardRejectionReasons = { + levelNotEligible: "level-not-eligible", + accountTooNew: "account-too-new", + perCardCap: "per-card-cap", + dailyCap: "daily-cap", + vendorDailyCap: "vendor-daily-cap", + vendorCardCap: "vendor-card-cap", + velocity: "velocity", + openLoopNotAllowed: "open-loop-not-allowed", + limitsUnavailable: "limits-unavailable", +} as const + +export type GiftCardRejectionReason = + (typeof GiftCardRejectionReasons)[keyof typeof GiftCardRejectionReasons] + +export type GiftCardAuthorization = + | { authorized: true; reservationId: string | null } + | { authorized: false; error: ApplicationError; reason: GiftCardRejectionReason } + +type Rejection = { error: ApplicationError; reason: GiftCardRejectionReason } + +export type AuthorizeGiftCardPurchaseArgs = { + account: Pick + product: Pick + valueMinor: number + quantity: number + /** Injectable clock for the specs. */ + nowMs?: number +} + +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS + +// The trailing-24h window can only be summed from a bounded page. Anything +// past this many orders in a day is already far beyond every configured +// velocity (maxOrdersPerHour × 24 with the shipped defaults is 120), so a +// truncated page is treated as "cannot evaluate" rather than summed short. +const ORDER_PAGE_LIMIT = 200 + +// Orders that never moved money. EXPIRED included: an expired invoice was +// never paid. REFUND_REQUIRED deliberately NOT included — money left Flash. +const NON_SPEND_STATUSES: readonly GiftCardOrderStatus[] = [ + GiftCardOrderStatus.Failed, + GiftCardOrderStatus.PaymentFailed, + GiftCardOrderStatus.Expired, +] + +const OPEN_LOOP_MIN_LEVEL = 2 + +const levelLimits = (level: number) => { + const perLevel = GiftCardsConfig.limits.perLevel + const key = `level${level}` as keyof typeof perLevel + // A level the config does not name (future L4) inherits the top tier rather + // than zero: a new tier must never silently lose an allowance it had. + return perLevel[key] ?? perLevel.level3 +} + +const formatMinor = (minor: number, currency: string) => + `${(minor / 100).toFixed(2)} ${currency}` + +const limitExceeded = (message: string, reason: GiftCardRejectionReason): Rejection => ({ + error: new GiftCardLimitExceededError(message), + reason, +}) + +// The store we sum from (Mongo or Redis) could not be read. Critical-level so +// an outage of a limits dependency pages, rather than surfacing to customers +// as a "limit exceeded" they never hit. +const limitsUnavailable = (cause: Error): Rejection => ({ + error: new UnknownGiftCardError( + `Could not verify gift card limits: ${cause.constructor.name}`, + ), + reason: GiftCardRejectionReasons.limitsUnavailable, +}) + +/** + * Every check, in order, against the account's history and live holds. + * Returns the first failure, or `true`. A repository/Redis fault is a + * `limits-unavailable` rejection: what that means is decided by the mode. + */ +const evaluate = async ({ + account, + product, + valueMinor, + quantity, + nowMs, +}: Required): Promise => { + const limits = GiftCardsConfig.limits + const level = effectiveAccountLevel(account.level) + const totalMinor = valueMinor * quantity + const currency = product.currency + + // 1. Level. Level 0 is refused regardless of config: a device-only account + // has no identity and no allowance to spend against. + if (level === 0 || level < limits.minAccountLevel) { + return { + error: new GiftCardLevelNotEligibleError(), + reason: GiftCardRejectionReasons.levelNotEligible, + } + } + + // 2. Account age (new-account cooldown). + const ageHours = (nowMs - account.createdAt.getTime()) / HOUR_MS + if (ageHours < limits.minAccountAgeHours) { + return limitExceeded( + `Gift cards unlock ${limits.minAccountAgeHours} hours after you join`, + GiftCardRejectionReasons.accountTooNew, + ) + } + + // 3. Open-loop (Visa/Mastercard-style) cards: the conversion-to-cash vector + // CUS-7 flags. Feature switch first, then the level floor. + if (product.isOpenLoop) { + if (!GiftCardsConfig.allowOpenLoop) { + return { + error: new GiftCardsDisabledError("Prepaid cards are not available right now"), + reason: GiftCardRejectionReasons.openLoopNotAllowed, + } + } + if (level < OPEN_LOOP_MIN_LEVEL) { + return { + error: new GiftCardLevelNotEligibleError( + "Prepaid cards aren't available on your account level yet", + ), + reason: GiftCardRejectionReasons.openLoopNotAllowed, + } + } + } + + // 4. Per-card cap: min(Flash per-level, vendor open/closed-loop). + const perLevel = levelLimits(level) + const vendorCardCap = product.isOpenLoop + ? limits.vendorOpenLoopCardCapCents + : limits.vendorClosedLoopCardCapCents + if (totalMinor > perLevel.perCardCents) { + return limitExceeded( + `Gift card purchases are limited to ${formatMinor(perLevel.perCardCents, currency)} per order on your account level`, + GiftCardRejectionReasons.perCardCap, + ) + } + if (totalMinor > vendorCardCap) { + return limitExceeded( + `Gift card purchases are limited to ${formatMinor(vendorCardCap, currency)} per order`, + GiftCardRejectionReasons.vendorCardCap, + ) + } + + // 5 + 6. Velocity and daily cap share one page of recent orders. + const recent = await GiftCardOrdersRepository().listByAccount({ + accountId: account.id, + limit: ORDER_PAGE_LIMIT, + }) + if (recent instanceof Error) return limitsUnavailable(recent) + + const windowStart = nowMs - DAY_MS + const inWindow = recent.filter((o) => o.createdAt.getTime() >= windowStart) + if (recent.length >= ORDER_PAGE_LIMIT && inWindow.length === recent.length) { + // The page is full and every row is inside the window: the window is + // truncated and the sum would be short. Refuse to guess. + return limitsUnavailable(new Error("TruncatedOrderWindow")) + } + + const hourStart = nowMs - HOUR_MS + const lastHour = inWindow.filter((o) => o.createdAt.getTime() >= hourStart).length + if (lastHour >= limits.maxOrdersPerHour) { + return limitExceeded( + "Too many gift card orders in the last hour; please try again later", + GiftCardRejectionReasons.velocity, + ) + } + + const spentMinor = inWindow + .filter((o) => !NON_SPEND_STATUSES.includes(o.status)) + .reduce((sum, o) => sum + o.valueMinor * o.quantity, 0) + + const reservations = await readGiftCardReservations({ accountId: account.id, nowMs }) + if (reservations instanceof Error) return limitsUnavailable(reservations) + const heldMinor = reservations.reduce((sum, r) => sum + r.amountMinor, 0) + + const projected = spentMinor + heldMinor + totalMinor + if (projected > perLevel.dailyCents) { + return limitExceeded( + `Gift card purchases are limited to ${formatMinor(perLevel.dailyCents, currency)} per day on your account level`, + GiftCardRejectionReasons.dailyCap, + ) + } + if (projected > limits.vendorDailyCapCents) { + return limitExceeded( + `Gift card purchases are limited to ${formatMinor(limits.vendorDailyCapCents, currency)} per day`, + GiftCardRejectionReasons.vendorDailyCap, + ) + } + + return true +} + +const reportWouldReject = ({ + account, + product, + totalMinor, + rejection, + mode, +}: { + account: Pick + product: Pick + totalMinor: number + rejection: Rejection + mode: GiftCardLimitsMode +}) => { + const level = String(effectiveAccountLevel(account.level)) + addAttributesToCurrentSpan({ + "giftcard.limits.mode": mode, + "giftcard.limits.reason": rejection.reason, + "giftcard.limits.error": rejection.error.constructor.name, + }) + notifyOpsEvent({ + flow: "giftcard", + phase: mode === "enforce" ? "rejected" : "would-reject", + status: mode === "enforce" ? "failed" : "pending", + accountId: account.id, + amount: { value: (totalMinor / 100).toFixed(2), currency: product.currency }, + error: rejection.error.constructor.name, + meta: { reason: rejection.reason, level, productId: product.id }, + }) +} + +/** + * Decide whether this account may buy this card right now, and if so hold the + * amount against its daily allowance until the order row exists. + * + * `reservationId` is null only in `off` mode (nothing was evaluated, nothing is + * held) or when the hold could not be written in log-only (logged, allowed). + */ +export const authorizeGiftCardPurchase = async ( + args: AuthorizeGiftCardPurchaseArgs, +): Promise => { + const mode = GiftCardsConfig.limits.mode + if (mode === "off") return { authorized: true, reservationId: null } + + const nowMs = args.nowMs ?? Date.now() + const totalMinor = args.valueMinor * args.quantity + + const outcome = await evaluate({ ...args, nowMs }) + + if (outcome !== true) { + reportWouldReject({ + account: args.account, + product: args.product, + totalMinor, + rejection: outcome, + mode, + }) + if (mode === "enforce") return { authorized: false, ...outcome } + baseLogger.info( + { + accountId: args.account.id, + productId: args.product.id, + reason: outcome.reason, + mode, + }, + "Gift card limits would reject; allowing in log-only mode", + ) + } + + const reservationId = await writeGiftCardReservation({ + accountId: args.account.id, + amountMinor: totalMinor, + nowMs, + }) + if (reservationId instanceof Error) { + const rejection = limitsUnavailable(reservationId) + if (mode === "enforce") { + reportWouldReject({ + account: args.account, + product: args.product, + totalMinor, + rejection, + mode, + }) + return { authorized: false, ...rejection } + } + baseLogger.warn( + { accountId: args.account.id, error: reservationId.constructor.name }, + "Could not write gift card reservation; allowing in log-only mode", + ) + return { authorized: true, reservationId: null } + } + + return { authorized: true, reservationId } +} diff --git a/src/app/gift-cards/get-order.ts b/src/app/gift-cards/get-order.ts new file mode 100644 index 000000000..eb535681a --- /dev/null +++ b/src/app/gift-cards/get-order.ts @@ -0,0 +1,51 @@ +import { GiftCardOrderNotFoundError, GiftCardOrderStatus } from "@domain/gift-cards" +import { decryptGiftCardClaim } from "@services/gift-cards/claim-crypto" +import { GiftCardOrdersRepository } from "@services/mongoose" +import { addAttributesToCurrentSpan } from "@services/tracing" + +export type GiftCardOrderWithClaim = { + order: GiftCardOrder + /** Decrypted bearer data. Present only when the order is FULFILLED. */ + claim: GiftCardClaim | null +} + +/** + * One order, for its owner, with the claim decrypted only once FULFILLED. + * + * A non-owner gets `GiftCardOrderNotFoundError`, the same answer as a missing + * id, so the endpoint cannot be used to confirm that someone else's order + * exists. The claim is decrypted here and nowhere else; it goes straight into + * the authenticated response and is never logged or traced. + */ +export const getGiftCardOrderForAccount = async ({ + accountId, + orderId, +}: { + accountId: AccountId + orderId: GiftCardOrderId +}): Promise => { + const order = await GiftCardOrdersRepository().findById(orderId) + if (order instanceof Error) return order + if (order.accountId !== accountId) return new GiftCardOrderNotFoundError() + + addAttributesToCurrentSpan({ + "giftcard.orderId": order.id, + "giftcard.status": order.status, + }) + + if ( + order.status !== GiftCardOrderStatus.Fulfilled || + !order.claimCiphertext || + !order.claimKeyId + ) { + return { order, claim: null } + } + + const claim = decryptGiftCardClaim({ + ciphertext: order.claimCiphertext, + keyId: order.claimKeyId, + }) + if (claim instanceof Error) return claim + + return { order, claim } +} diff --git a/src/app/gift-cards/gift-cards-master-gate.ts b/src/app/gift-cards/gift-cards-master-gate.ts new file mode 100644 index 000000000..b4f1488e7 --- /dev/null +++ b/src/app/gift-cards/gift-cards-master-gate.ts @@ -0,0 +1,112 @@ +import { GiftCardsConfig } from "@config" + +import { resolvePhoneCountries } from "@app/bridge/kyc-gate" +import { + GiftCardProviderUnavailableError, + GiftCardsDisabledError, + normalizeCountryCode, +} from "@domain/gift-cards" +import { resolveGiftCardProviderIdForCountry } from "@services/gift-cards/registry" +import { baseLogger } from "@services/logger" +import { UsersRepository } from "@services/mongoose" +import { addAttributesToCurrentSpan } from "@services/tracing" + +/** + * The deploy-level gate every gift-card surface must apply, in ONE place + * (pattern: `fygaroCheckoutMasterGate`). + * + * Two switches, checked in order so the caller gets the more specific answer: + * `giftCards.enabled` (the whole rail), then "is there an ENABLED provider + * routed for this country" (`routing.byCountry[cc] ?? routing.default`, gated + * on `providers..enabled`). Both the product listing and the purchase + * mutation go through this so a catalog can never invite a purchase the + * mutation then refuses. + * + * Hands back the provider id it gated on, so a caller cannot pass the gate for + * one provider and then order from another. + */ +export type GiftCardsMasterGate = + | { ok: true; providerId: GiftCardProviderId } + | { ok: false; error: GiftCardsDisabledError | GiftCardProviderUnavailableError } + +/** + * Sentinel for "country unknown". Not a real ISO code (XX is user-assigned), + * so it can never collide with a `routing.byCountry` entry and therefore + * always resolves to `routing.default`. + */ +export const UNKNOWN_COUNTRY_CODE = "XX" + +export const giftCardsMasterGate = (countryCode: string): GiftCardsMasterGate => { + if (!GiftCardsConfig?.enabled) { + return { ok: false, error: new GiftCardsDisabledError() } + } + + const cc = normalizeCountryCode(countryCode || UNKNOWN_COUNTRY_CODE) + const providerId = resolveGiftCardProviderIdForCountry(cc) + if (providerId instanceof Error) return { ok: false, error: providerId } + + return { ok: true, providerId } +} + +/** + * The account's country for provider routing, or null when nothing reliable is + * on file. + * + * WHAT FLASH STORES (surveyed 2026-09-10): + * + * - `Account` has no country field at all (src/domain/accounts/index.types.d.ts). + * - Bridge KYC: `Account.bridgeCustomerId` / `bridgeKycStatus` exist, but the + * Bridge `Customer` response type (src/services/bridge/client.ts) carries no + * address or country, and `snapshotBridgeCustomer` deliberately keeps only + * id/status/updated_at/endorsements. A residential address is SENT to Bridge + * on customer creation and never read back. So there is no Bridge-sourced + * country to prefer; the "Bridge KYC country" tier of the precedence is + * empty until the client grows an address read. + * - Phone: `User.phoneMetadata.countryCode` (Twilio Lookup at signup) and the + * E.164 number itself. `resolvePhoneCountries` (the Bridge KYC gate's own + * resolver) already reconciles the two — it trusts the Lookup stamp only + * while the number on file cannot contradict it, and falls back to the set + * of regions a calling code could denote when libphonenumber cannot name one + * (~340 NANP area codes are missing from the pinned metadata). + * + * So the effective precedence is: phone country (single, unambiguous + * candidate) → null. An ambiguous calling-code set (e.g. the 25 NANP regions) + * is reported as null rather than guessing: for routing, "unknown" goes to + * `routing.default`, which is the right answer for a country we cannot name. + * + * Never throws and never blocks a purchase: a lookup failure is logged and + * resolves to null, which the caller maps to `UNKNOWN_COUNTRY_CODE`. + */ +export const resolveAccountCountryCode = async ( + account: Pick, +): Promise => { + try { + const user = await UsersRepository().findById(account.kratosUserId) + if (user instanceof Error) { + baseLogger.warn( + { accountId: account.id, error: user.constructor.name }, + "Could not load user to resolve gift card routing country; using default", + ) + return null + } + + const resolution = resolvePhoneCountries(user) + addAttributesToCurrentSpan({ + "giftcard.country.source": resolution.source, + "giftcard.country.candidates": resolution.countries.length, + }) + if (resolution.countries.length === 1) return resolution.countries[0] + return null + } catch (error) { + baseLogger.warn( + { accountId: account.id, error }, + "Failed resolving gift card routing country; using default", + ) + return null + } +} + +/** `resolveAccountCountryCode`, with the null already mapped to the routing sentinel. */ +export const resolveAccountCountryCodeOrUnknown = async ( + account: Pick, +): Promise => (await resolveAccountCountryCode(account)) ?? UNKNOWN_COUNTRY_CODE diff --git a/src/app/gift-cards/index.ts b/src/app/gift-cards/index.ts new file mode 100644 index 000000000..f32cea9fd --- /dev/null +++ b/src/app/gift-cards/index.ts @@ -0,0 +1,42 @@ +// Catalog (ENG-579) +export * from "./sync-catalog" +export * from "./list-products" + +// Gate, limits, purchase, settlement, worker, reads (ENG-580 / 581 / 583). +// +// Named exports only: `src/app/index.ts` wraps EVERY key of this namespace in +// a tracing span as if it were a function, so a const object exported here +// would come out the other side as a callable and silently stop being an +// object. Consts (`GiftCardRejectionReasons`, the reconcile schedule helpers) +// stay importable from their own modules. +export { + giftCardsMasterGate, + resolveAccountCountryCode, + resolveAccountCountryCodeOrUnknown, +} from "./gift-cards-master-gate" +export type { GiftCardsMasterGate } from "./gift-cards-master-gate" +export { + authorizeGiftCardPurchase, + releaseGiftCardReservation, +} from "./authorize-purchase" +export type { + GiftCardAuthorization, + GiftCardRejectionReason, + AuthorizeGiftCardPurchaseArgs, +} from "./authorize-purchase" +export { purchaseGiftCard } from "./purchase-gift-card" +export type { PurchaseGiftCardArgs } from "./purchase-gift-card" +export { quoteGiftCard } from "./quote-gift-card" +export type { QuoteGiftCardArgs } from "./quote-gift-card" +export { settleOrderFromVendor, fetchAndSettle } from "./settle-order" +export { + reconcileGiftCardOrders, + reconcileGiftCardOrdersJob, + startGiftCardReconcileInterval, +} from "./reconcile-orders" +export type { GiftCardReconcileSummary } from "./reconcile-orders" +export { getGiftCardOrderForAccount } from "./get-order" +export type { GiftCardOrderWithClaim } from "./get-order" +export { listGiftCardOrdersForAccount } from "./list-orders" +export type { GiftCardOrderPage } from "./list-orders" +export { sendGiftCardFulfilledNotificationBestEffort } from "./send-fulfilled-notification" diff --git a/src/app/gift-cards/list-orders.ts b/src/app/gift-cards/list-orders.ts new file mode 100644 index 000000000..65db9a8cb --- /dev/null +++ b/src/app/gift-cards/list-orders.ts @@ -0,0 +1,50 @@ +import { GiftCardOrdersRepository } from "@services/mongoose" + +export const GIFT_CARD_ORDERS_DEFAULT_FIRST = 20 +export const GIFT_CARD_ORDERS_MAX_FIRST = 100 + +export type GiftCardOrderPage = { + orders: GiftCardOrder[] + hasNextPage: boolean + /** `createdAt` of the last order on the page, to pass back as `after`. */ + endCursor: Date | null +} + +const clampFirst = (first: number | undefined): number => { + if (first === undefined || !Number.isFinite(first) || first < 1) { + return GIFT_CARD_ORDERS_DEFAULT_FIRST + } + return Math.min(Math.floor(first), GIFT_CARD_ORDERS_MAX_FIRST) +} + +/** + * The account's orders, newest first, keyset-paginated on `createdAt`. Claim + * data is never part of a listing — `claimCiphertext` rides along on the + * order but is only ever decrypted by `getGiftCardOrderForAccount`. + */ +export const listGiftCardOrdersForAccount = async ({ + accountId, + first, + after, +}: { + accountId: AccountId + first?: number + after?: Date +}): Promise => { + const limit = clampFirst(first) + // One extra row tells us whether another page exists without a count query. + const rows = await GiftCardOrdersRepository().listByAccount({ + accountId, + limit: limit + 1, + before: after, + }) + if (rows instanceof Error) return rows + + const orders = rows.slice(0, limit) + const last = orders[orders.length - 1] + return { + orders, + hasNextPage: rows.length > limit, + endCursor: last ? last.createdAt : null, + } +} diff --git a/src/app/gift-cards/list-products.ts b/src/app/gift-cards/list-products.ts new file mode 100644 index 000000000..72d2811a1 --- /dev/null +++ b/src/app/gift-cards/list-products.ts @@ -0,0 +1,179 @@ +import { GiftCardsConfig } from "@config" + +import { + decodeGiftCardProductCursor, + encodeGiftCardProductCursor, + GiftCardProductNotFoundError, + normalizeCountryCode, + parseGiftCardProductId, +} from "@domain/gift-cards" +import { notifyOpsEvent } from "@services/alerts/ops-events" +import { resolveGiftCardProviderIdForCountry } from "@services/gift-cards" +import { GiftCardCatalogCache } from "@services/gift-cards/catalog-cache" +import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" + +/** + * Catalog reads for the storefront. Served entirely from the Redis read model + * the sync job maintains — no request here ever reaches a vendor. + * + * Flash policy lives here, not in the cache: out-of-stock rows are hidden, + * open-loop (Visa/Mastercard-style) cards are hidden unless + * `giftCards.allowOpenLoop` is on, and the same rule is applied to a direct + * product lookup so a client cannot reach an open-loop card by id that the list + * would never have shown it. + */ +export type GiftCardProductPage = { + products: GiftCardProduct[] + hasNextPage: boolean + endCursor: string | null + stale: boolean +} + +export const GIFT_CARD_LIST_DEFAULT_FIRST = 50 +export const GIFT_CARD_LIST_MAX_FIRST = 200 + +// A stale catalog is served, not refused — but someone should know. One embed +// per provider per window keeps a busy storefront on an aging catalog from +// posting once per request into the 50-deep ops queue (same shape as +// `OPS_EVENT_COALESCE_MS` in app/payments/authorize-send.ts). +export const GIFT_CARD_STALE_EVENT_COALESCE_MS = 10 * 60 * 1000 + +const staleEventWindows = new Map() + +// Test-only. The windows are per-process by design; without this one spec's +// stale event silences the next spec's. +export const __resetGiftCardStaleEventsForTest = (): void => { + staleEventWindows.clear() +} + +const notifyStaleCatalog = ( + providerId: GiftCardProviderId, + countryCode: string, +): void => { + const now = Date.now() + const openedAt = staleEventWindows.get(providerId) + if (openedAt !== undefined && now - openedAt < GIFT_CARD_STALE_EVENT_COALESCE_MS) return + + staleEventWindows.set(providerId, now) + notifyOpsEvent({ + flow: "giftcard", + phase: "catalog-stale", + status: "pending", + meta: { providerId, countryCode }, + }) +} + +const isOpenLoopAllowed = (): boolean => GiftCardsConfig.allowOpenLoop === true + +const encodeCursor = encodeGiftCardProductCursor +const decodeCursor = decodeGiftCardProductCursor + +const clampFirst = (first: number | undefined): number => { + if (first === undefined || !Number.isFinite(first)) return GIFT_CARD_LIST_DEFAULT_FIRST + const whole = Math.floor(first) + if (whole < 1) return GIFT_CARD_LIST_DEFAULT_FIRST + return Math.min(whole, GIFT_CARD_LIST_MAX_FIRST) +} + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0) + +// Brand, then name, then id. Case-insensitive on the human-facing fields so +// "amazon" and "Amazon" sit together; the id tiebreak makes the order total, +// which is what lets an id-based cursor resume in the right place. +const compareProducts = (a: GiftCardProduct, b: GiftCardProduct): number => + compareStrings(a.brand.toLowerCase(), b.brand.toLowerCase()) || + compareStrings(a.name.toLowerCase(), b.name.toLowerCase()) || + compareStrings(a.id, b.id) + +const matchesCategory = (product: GiftCardProduct, category: string): boolean => { + const wanted = category.trim().toLowerCase() + return product.categories.some((c) => c.toLowerCase() === wanted) +} + +const matchesSearch = (product: GiftCardProduct, search: string): boolean => { + const needle = search.trim().toLowerCase() + return ( + product.name.toLowerCase().includes(needle) || + product.brand.toLowerCase().includes(needle) + ) +} + +const listProducts = async ({ + countryCode, + category, + search, + first, + after, +}: { + countryCode: string + category?: string + search?: string + first?: number + after?: string +}): Promise => { + const cc = normalizeCountryCode(countryCode) + + const providerId = resolveGiftCardProviderIdForCountry(cc) + if (providerId instanceof Error) return providerId + + const catalog = await GiftCardCatalogCache().read({ providerId, countryCode: cc }) + if (catalog instanceof Error) return catalog + + if (catalog.stale) notifyStaleCatalog(providerId, cc) + + const allowOpenLoop = isOpenLoopAllowed() + const wantedCategory = category?.trim() ? category : undefined + const wantedSearch = search?.trim() ? search : undefined + + const matching = catalog.products + .filter((p) => p.inStock) + .filter((p) => allowOpenLoop || !p.isOpenLoop) + .filter((p) => wantedCategory === undefined || matchesCategory(p, wantedCategory)) + .filter((p) => wantedSearch === undefined || matchesSearch(p, wantedSearch)) + .sort(compareProducts) + + // A cursor names the last id the client saw. If that product has since left + // the catalog (resync dropped it, or it went out of stock), we cannot place it + // and start over rather than guess — the client may see a repeat, never a gap. + let start = 0 + if (after) { + const afterId = decodeCursor(after) + const idx = matching.findIndex((p) => p.id === afterId) + if (idx >= 0) start = idx + 1 + } + + const limit = clampFirst(first) + const page = matching.slice(start, start + limit) + const last = page[page.length - 1] + + return { + products: page, + hasNextPage: start + limit < matching.length, + endCursor: last ? encodeCursor(last.id) : null, + stale: catalog.stale, + } +} + +const getProduct = async ( + productId: GiftCardProductId, +): Promise => { + const parsed = parseGiftCardProductId(productId) + if (parsed instanceof Error) return parsed + + const product = await GiftCardCatalogCache().readProduct(productId) + if (product instanceof Error) return product + + // Same answer as "never existed": admitting the card exists but is withheld + // tells a client which ids to try once the flag flips. + if (product.isOpenLoop && !isOpenLoopAllowed()) + return new GiftCardProductNotFoundError() + + return product +} + +export const { listGiftCardProducts, getGiftCardProduct } = wrapAsyncFunctionsToRunInSpan( + { + namespace: "app.gift-cards", + fns: { listGiftCardProducts: listProducts, getGiftCardProduct: getProduct }, + }, +) diff --git a/src/app/gift-cards/ops.ts b/src/app/gift-cards/ops.ts new file mode 100644 index 000000000..5357f64e1 --- /dev/null +++ b/src/app/gift-cards/ops.ts @@ -0,0 +1,61 @@ +import { notifyOpsEvent, OpsStatus } from "@services/alerts/ops-events" +import { buildGiftCardProductId } from "@domain/gift-cards" + +/** + * One shape for every `giftcard` ops event, so the Discord funnel reads the + * same fields top to bottom: amount in display units, `orderId`, `providerId`, + * `productId`, plus whatever the phase adds. + * + * NEVER pass claim data through here. The helper takes an order, not a claim, + * precisely so the call sites have nothing bearer-shaped in scope to leak. + */ +export type GiftCardOpsPhase = + | "order-created" + | "order-paid" + | "payment-pending" + | "order-fulfilled" + | "order-failed" + | "refund-required" + | "claim-encrypt-failed" + | "vendor-fulfilled-unexpected" + +export const notifyGiftCardOpsEvent = ({ + phase, + status, + order, + error, + meta, +}: { + phase: GiftCardOpsPhase + status: OpsStatus + order: Pick< + GiftCardOrder, + | "id" + | "accountId" + | "providerId" + | "providerProductId" + | "valueMinor" + | "quantity" + | "currency" + > + error?: string + meta?: Record +}): void => { + notifyOpsEvent({ + flow: "giftcard", + phase, + status, + accountId: order.accountId, + amount: { + value: ((order.valueMinor * order.quantity) / 100).toFixed(2), + currency: order.currency, + }, + error, + meta: { + orderId: order.id, + providerId: order.providerId, + productId: buildGiftCardProductId(order.providerId, order.providerProductId), + ...meta, + }, + }) +} diff --git a/src/app/gift-cards/purchase-gift-card.ts b/src/app/gift-cards/purchase-gift-card.ts new file mode 100644 index 000000000..df5282388 --- /dev/null +++ b/src/app/gift-cards/purchase-gift-card.ts @@ -0,0 +1,528 @@ +import { GiftCardsConfig, getSendGuardMode } from "@config" + +import { + authorizeSend, + gateSend, + SendRejectionReasons, +} from "@app/payments/authorize-send" +import { payLnInvoiceViaIbex } from "@app/payments/pay-invoice-via-ibex" +import { toSats } from "@domain/bitcoin" +import { decodeInvoice, PaymentSendStatus } from "@domain/bitcoin/lightning" +import { InvalidIdempotencyKeyError } from "@domain/errors" +import { + checkedGiftCardQuantity, + checkedGiftCardValue, + GIFT_CARD_QUOTE_TOLERANCE_BPS, + GiftCardIdempotencyKeyReuseError, + GiftCardOrderNotFoundError, + GiftCardOrderStatus, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, + GiftCardQuoteMismatchError, + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, + parseGiftCardProductId, +} from "@domain/gift-cards" +import { LnPaymentRequestNonZeroAmountRequiredError } from "@domain/payments" +import { RateLimitConfig } from "@domain/rate-limit" +import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" +import { ValidationError } from "@domain/shared" +import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" +import { baseLogger } from "@services/logger" +import { + AccountsRepository, + GiftCardOrdersRepository, + WalletsRepository, +} from "@services/mongoose" +import { consumeLimiter } from "@services/rate-limit" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +import { + authorizeGiftCardPurchase, + releaseGiftCardReservation, +} from "./authorize-purchase" +import { + giftCardsMasterGate, + resolveAccountCountryCodeOrUnknown, +} from "./gift-cards-master-gate" +import { getGiftCardProduct } from "./list-products" +import { notifyGiftCardOpsEvent } from "./ops" +import { fetchAndSettle } from "./settle-order" + +/** + * ENG-580 — purchase orchestration. + * + * gate → product → validate → quote → authorize → CREATED → vendor order → + * tolerance → INVOICE_ISSUED → pay → PAID | PAYMENT_PENDING | PAYMENT_FAILED + * → one fulfilment poll → return + * + * Returns the order (possibly already FULFILLED) or an ApplicationError; never + * throws. Money moves exactly once per order: the Lightning send is wrapped in + * `withPaymentIdempotency` under `giftcard:`, and the order row itself + * is unique on (walletId, idempotencyKey), so a replayed mutation finds the + * existing order before it ever reaches the vendor. + * + * Claim data never passes through this module: fulfilment is `settle-order`'s + * job and it stores ciphertext only. + */ +export type PurchaseGiftCardArgs = { + accountId: AccountId + walletId: WalletId + productId: string + valueMinor: number + quantity: number + idempotencyKey: string +} + +// How long a CREATED / INVOICE_ISSUED order stays payable before the reconcile +// worker expires it. Vendor invoices are usually shorter; we take the earlier. +const ORDER_TTL_MS = 15 * 60 * 1000 + +const MAX_IDEMPOTENCY_KEY_LENGTH = 256 + +const snapshotOf = (product: GiftCardProduct): GiftCardProductSnapshot => ({ + name: product.name, + brand: product.brand, + countryCode: product.countryCode, + currency: product.currency, + isOpenLoop: product.isOpenLoop, + logoUrl: product.logoUrl, +}) + +const sameParameters = ( + order: GiftCardOrder, + args: { providerProductId: string; valueMinor: number; quantity: number }, +): boolean => + order.providerProductId === args.providerProductId && + order.valueMinor === args.valueMinor && + order.quantity === args.quantity + +/** + * The ENG-573 send guard, built exactly as `lnInvoicePaymentSend` builds it: + * decode is part of the guard (so log-only never blocks on it), a zero-amount + * invoice is a gated rejection, and the decoded sats are what the cap is + * judged on. Runs inside the idempotency lock, only on the path that pays. + */ +const buildSendGuardHook = + ({ + senderAccount, + senderWalletId, + paymentRequest, + }: { + senderAccount: Account + senderWalletId: WalletId + paymentRequest: string + }): SendGuardHook => + async () => { + if (getSendGuardMode() === "off") return true + + const gate = (error: ApplicationError) => + gateSend({ + error, + reason: SendRejectionReasons.undecodableInvoice, + senderAccount, + senderWalletId, + kind: "lightning", + }) + + const decoded = decodeInvoice(paymentRequest) + if (decoded instanceof Error) return gate(decoded) + if (decoded.paymentAmount === null) { + return gate(new LnPaymentRequestNonZeroAmountRequiredError()) + } + + return authorizeSend({ + senderAccount, + senderWalletId, + amount: { currency: "BTC", sats: decoded.paymentAmount.amount }, + kind: "lightning", + }) + } + +/** Move an unpaid order to FAILED and report it. Transition errors are logged, not surfaced. */ +const failUnpaidOrder = async ( + order: GiftCardOrder, + reason: string, + error: ApplicationError, +): Promise => { + const failed = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.Created, GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.Failed, + reason, + patch: { failureReason: reason }, + }) + if (failed instanceof Error) { + baseLogger.error( + { orderId: order.id, reason, error: failed.constructor.name }, + "Could not mark gift card order FAILED", + ) + } + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order, + error: error.constructor.name, + meta: { reason }, + }) +} + +export const purchaseGiftCard = async ( + args: PurchaseGiftCardArgs, +): Promise => { + const { accountId, walletId, productId } = args + addAttributesToCurrentSpan({ + "giftcard.productId": productId, + "giftcard.valueMinor": args.valueMinor, + "giftcard.quantity": args.quantity, + }) + + const idempotencyKey = (args.idempotencyKey ?? "").trim() + if (idempotencyKey.length === 0 || idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) { + return new InvalidIdempotencyKeyError(args.idempotencyKey) + } + + // Attempt budget, charged before any vendor or store round-trip so a client + // looping on a refused request bounds its own cost. A limiter STORE fault + // falls through (same posture as fygaroCheckoutCreate): refusing every first + // attempt during a Redis blip would page nobody and block everyone. + const limitOk = await consumeLimiter({ + rateLimitConfig: RateLimitConfig.giftCardPurchase, + keyToConsume: accountId, + }) + if (limitOk instanceof GiftCardPurchaseRateLimiterExceededError) return limitOk + if (limitOk instanceof Error) { + baseLogger.warn( + { accountId, error: limitOk.constructor.name }, + "Gift card purchase rate limiter unavailable; continuing", + ) + } + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + // Object-level authz: never trust a client-supplied walletId on its own. + if (wallet.accountId !== account.id) { + return new ValidationError("Wallet does not belong to account") + } + + const repo = GiftCardOrdersRepository() + + // Replay: the row is unique on (walletId, idempotencyKey). Same key, same + // parameters → the existing order, whatever state it reached. Same key, + // different parameters → refused, never silently swapped for the old order. + const parsed = parseGiftCardProductId(productId) + if (parsed instanceof Error) return parsed + + const existing = await repo.findByIdempotencyKey({ walletId, idempotencyKey }) + if (!(existing instanceof GiftCardOrderNotFoundError)) { + if (existing instanceof Error) return existing + if ( + existing.providerId !== parsed.providerId || + !sameParameters(existing, { + providerProductId: parsed.providerProductId, + valueMinor: args.valueMinor, + quantity: args.quantity, + }) + ) { + return new GiftCardIdempotencyKeyReuseError() + } + addAttributesToCurrentSpan({ + "giftcard.orderId": existing.id, + "giftcard.replay": true, + }) + return existing + } + + // Master gate with the account's routing country. + const countryCode = await resolveAccountCountryCodeOrUnknown(account) + const gate = giftCardsMasterGate(countryCode) + if (!gate.ok) return gate.error + addAttributesToCurrentSpan({ "giftcard.provider": gate.providerId }) + + const product = await getGiftCardProduct(productId as GiftCardProductId) + if (product instanceof Error) return product + // The product must come from the provider routed for this account. A + // Bitrefill id in a Bitcoin-Company country is "not available here". + if (product.providerId !== gate.providerId) { + return new GiftCardProductNotAvailableInCountryError() + } + if (!product.inStock) { + return new GiftCardProductNotFoundError("This gift card is currently out of stock") + } + + const valueMinor = checkedGiftCardValue(product, args.valueMinor) + if (valueMinor instanceof Error) return valueMinor + const quantity = checkedGiftCardQuantity(args.quantity) + if (quantity instanceof Error) return quantity + + const provider = getEnabledGiftCardProvider(gate.providerId) + if (provider instanceof Error) return provider + + const quote = await provider.quote({ product, valueMinor, quantity }) + if (quote instanceof Error) return quote + + const authorization = await authorizeGiftCardPurchase({ + account, + product, + valueMinor, + quantity, + }) + if (!authorization.authorized) return authorization.error + + const created = await repo.create({ + accountId, + walletId, + walletCurrency: wallet.currency, + providerId: product.providerId, + providerProductId: product.providerProductId, + productSnapshot: snapshotOf(product), + valueMinor, + currency: product.currency, + quantity, + quoteSats: quote.satsCost, + idempotencyKey, + expiresAt: new Date(Date.now() + ORDER_TTL_MS), + }) + // The row now carries the amount for the daily-cap sum (see + // reservation-store.ts); the hold has done its job either way. + await releaseGiftCardReservation(accountId, authorization.reservationId) + if (created instanceof Error) { + // A concurrent same-key request may have won the unique index. Replay it. + const raced = await repo.findByIdempotencyKey({ walletId, idempotencyKey }) + if (!(raced instanceof Error)) { + return sameParameters(raced, { + providerProductId: product.providerProductId, + valueMinor, + quantity, + }) + ? raced + : new GiftCardIdempotencyKeyReuseError() + } + return created + } + addAttributesToCurrentSpan({ "giftcard.orderId": created.id }) + notifyGiftCardOpsEvent({ phase: "order-created", status: "pending", order: created }) + + const vendorOrder = await provider.createOrder({ + product, + valueMinor, + quantity, + reference: created.id, + }) + if (vendorOrder instanceof Error) { + const error = + vendorOrder instanceof GiftCardVendorUnavailableError || + vendorOrder instanceof GiftCardVendorRejectedOrderError + ? vendorOrder + : new GiftCardVendorRejectedOrderError(vendorOrder.message) + await failUnpaidOrder( + created, + `vendor-create-failed: ${vendorOrder.constructor.name}`, + error, + ) + return error + } + + // The invoice we are about to pay is the source of truth for what it costs; + // the vendor's stated amount is cross-checked against it. Either one + // drifting past tolerance from the quote the customer saw refuses the pay. + const decoded = decodeInvoice(vendorOrder.paymentRequest) + if (decoded instanceof Error || decoded.paymentAmount === null) { + const error = new GiftCardVendorRejectedOrderError( + "Gift card provider returned an unreadable invoice", + ) + await failUnpaidOrder(created, "vendor-invoice-undecodable", error) + return error + } + const invoiceSats = toSats(decoded.paymentAmount.amount) + const chargedSats = Math.max(invoiceSats, Number(vendorOrder.amountSats)) + const toleranceBps = GiftCardsConfig.quoteToleranceBps ?? GIFT_CARD_QUOTE_TOLERANCE_BPS + const maxSats = Math.floor(Number(quote.satsCost) * (1 + toleranceBps / 10_000)) + addAttributesToCurrentSpan({ + "giftcard.quoteSats": Number(quote.satsCost), + "giftcard.invoiceSats": invoiceSats, + }) + if (chargedSats > maxSats) { + const error = new GiftCardQuoteMismatchError( + "The gift card price changed; please try again", + ) + await failUnpaidOrder( + created, + `quote-mismatch: quoted ${quote.satsCost} invoiced ${chargedSats}`, + error, + ) + return error + } + + const vendorExpiresAt = + vendorOrder.expiresAt instanceof Date && + !Number.isNaN(vendorOrder.expiresAt.getTime()) + ? vendorOrder.expiresAt + : created.expiresAt + const issued = await repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + reason: "vendor-invoice-issued", + patch: { + providerOrderId: vendorOrder.providerOrderId, + paymentRequest: vendorOrder.paymentRequest, + invoiceSats, + paymentHash: decoded.paymentHash, + expiresAt: + vendorExpiresAt < created.expiresAt ? vendorExpiresAt : created.expiresAt, + }, + }) + if (issued instanceof Error) { + // Nothing paid. The row stays CREATED and the reconcile worker expires it. + recordExceptionInCurrentSpan({ error: issued }) + return issued + } + + // IBEX-custodial rail (see @app/payments/pay-invoice-via-ibex): the same + // idempotency wrapper and send guard as lnInvoicePaymentSend. The + // fingerprint binds the cached result to THIS order as well as the invoice, + // so a different order can never replay a previous success. + // + // The IBEX transaction id is the only handle the reconcile worker has to + // re-query an in-flight send; it is captured from the raw 200 and written + // with whichever transition follows. A crash between the IBEX call and that + // transition leaves INVOICE_ISSUED with no ref — see reconcile-orders.ts. + const captured: { providerPaymentRef: string | null } = { providerPaymentRef: null } + const payment = await payLnInvoiceViaIbex({ + senderWalletId: walletId, + senderAccount: account, + paymentRequest: vendorOrder.paymentRequest, + idempotencyKey: `giftcard:${created.id}`, + requestFingerprint: `ln|${vendorOrder.paymentRequest}|giftcard|${created.id}`, + authorize: buildSendGuardHook({ + senderAccount: account, + senderWalletId: walletId, + paymentRequest: vendorOrder.paymentRequest, + }), + onResponse: (response) => { + const id = response?.transaction?.id + if (typeof id === "string" && id.length > 0) captured.providerPaymentRef = id + }, + }) + const { providerPaymentRef } = captured + addAttributesToCurrentSpan({ "giftcard.providerPaymentRef": providerPaymentRef ?? "" }) + + if (payment instanceof Error) { + await markPaymentFailed( + issued, + `payment-error: ${payment.constructor.name}`, + payment, + providerPaymentRef, + ) + return payment + } + + // Compare by value: a replayed status comes back from the idempotency cache + // as a fresh object, not the `PaymentSendStatus` constant. + switch (payment.value) { + case PaymentSendStatus.Success.value: + case PaymentSendStatus.AlreadyPaid.value: + return markPaidAndSettle(issued, invoiceSats, providerPaymentRef) + case PaymentSendStatus.Pending.value: { + const pending = await repo.transition({ + id: issued.id, + from: [GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.PaymentPending, + reason: "payment-pending", + patch: { providerPaymentRef }, + }) + if (pending instanceof Error) return pending + notifyGiftCardOpsEvent({ + phase: "payment-pending", + status: "pending", + order: pending, + }) + return pending + } + default: { + const failed = await markPaymentFailed( + issued, + "payment-failed", + null, + providerPaymentRef, + ) + return failed ?? issued + } + } +} + +const markPaymentFailed = async ( + order: GiftCardOrder, + reason: string, + error: ApplicationError | null, + providerPaymentRef: string | null, +): Promise => { + const failed = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.PaymentFailed, + reason, + patch: { failureReason: reason, providerPaymentRef }, + }) + if (failed instanceof Error) { + baseLogger.error( + { orderId: order.id, reason, error: failed.constructor.name }, + "Could not mark gift card order PAYMENT_FAILED", + ) + } + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order, + error: error?.constructor.name ?? "PaymentSendStatusFailure", + meta: { reason }, + }) + return failed instanceof Error ? null : failed +} + +const markPaidAndSettle = async ( + order: GiftCardOrder, + invoiceSats: Satoshis, + providerPaymentRef: string | null, +): Promise => { + const paid = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.Paid, + reason: "payment-settled", + patch: { paidSats: invoiceSats, providerPaymentRef }, + }) + if (paid instanceof Error) { + // The payment went through; only the bookkeeping failed. Critical: until + // the reconcile worker picks this up, an order shows unpaid for money that + // has left the wallet. + recordExceptionInCurrentSpan({ error: paid }) + baseLogger.error( + { orderId: order.id, error: paid.constructor.name }, + "Gift card payment settled but PAID transition failed", + ) + return paid + } + notifyGiftCardOpsEvent({ phase: "order-paid", status: "success", order: paid }) + + // One immediate look: most vendors fulfil within seconds of payment, and a + // card in the mutation response is a better experience than a push a moment + // later. A vendor error here is NOT the order's error — PAID is returned and + // the reconcile worker finishes the job. + const settled = await fetchAndSettle(paid) + if (settled instanceof Error) { + baseLogger.info( + { orderId: paid.id, error: settled.constructor.name }, + "First fulfilment poll did not settle; reconcile worker will retry", + ) + return paid + } + return settled +} diff --git a/src/app/gift-cards/quote-gift-card.ts b/src/app/gift-cards/quote-gift-card.ts new file mode 100644 index 000000000..2d6a9cedf --- /dev/null +++ b/src/app/gift-cards/quote-gift-card.ts @@ -0,0 +1,83 @@ +import { + checkedGiftCardQuantity, + checkedGiftCardValue, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, +} from "@domain/gift-cards" +import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" +import { AccountsRepository } from "@services/mongoose" +import { addAttributesToCurrentSpan } from "@services/tracing" + +import { + giftCardsMasterGate, + resolveAccountCountryCodeOrUnknown, +} from "./gift-cards-master-gate" +import { getGiftCardProduct } from "./list-products" + +/** + * ENG-582 — a price for the storefront, BEFORE any order exists. + * + * gate → product → validate → provider.quote + * + * Deliberately the same first four steps as `purchaseGiftCard`, in the same + * order with the same errors, so the quote can never say yes where the + * purchase would then say no: a product from a provider the account's country + * is not routed to, an out-of-stock row, an off-denomination value — every one + * of those is refused here with the error the mutation would give. + * + * Nothing is written and no limit is consumed: a quote is not a reservation. + * The number comes straight from the vendor and is good until `expiresAt`; the + * purchase re-quotes and refuses to pay an invoice that drifted past tolerance + * (`GIFT_CARD_QUOTE_TOLERANCE_BPS`), so a stale quote costs the customer a + * retry, never money. + */ +export type QuoteGiftCardArgs = { + accountId: AccountId + productId: string + valueMinor: number + quantity: number +} + +export const quoteGiftCard = async ({ + accountId, + productId, + valueMinor, + quantity, +}: QuoteGiftCardArgs): Promise => { + addAttributesToCurrentSpan({ + "giftcard.productId": productId, + "giftcard.valueMinor": valueMinor, + "giftcard.quantity": quantity, + }) + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const countryCode = await resolveAccountCountryCodeOrUnknown(account) + const gate = giftCardsMasterGate(countryCode) + if (!gate.ok) return gate.error + addAttributesToCurrentSpan({ "giftcard.provider": gate.providerId }) + + const product = await getGiftCardProduct(productId as GiftCardProductId) + if (product instanceof Error) return product + if (product.providerId !== gate.providerId) { + return new GiftCardProductNotAvailableInCountryError() + } + if (!product.inStock) { + return new GiftCardProductNotFoundError("This gift card is currently out of stock") + } + + const checkedValue = checkedGiftCardValue(product, valueMinor) + if (checkedValue instanceof Error) return checkedValue + const checkedQuantity = checkedGiftCardQuantity(quantity) + if (checkedQuantity instanceof Error) return checkedQuantity + + const provider = getEnabledGiftCardProvider(gate.providerId) + if (provider instanceof Error) return provider + + return provider.quote({ + product, + valueMinor: checkedValue, + quantity: checkedQuantity, + }) +} diff --git a/src/app/gift-cards/reconcile-orders.ts b/src/app/gift-cards/reconcile-orders.ts new file mode 100644 index 000000000..2b5eac385 --- /dev/null +++ b/src/app/gift-cards/reconcile-orders.ts @@ -0,0 +1,498 @@ +import { randomUUID } from "crypto" + +import { GiftCardsConfig } from "@config" + +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { GiftCardOrderStatus, UnknownGiftCardError } from "@domain/gift-cards" +import Ibex from "@services/ibex/client" +import { UnconfirmedIbexPayment } from "@services/ibex/errors" +import { paymentSendStatusFromIbex } from "@services/ibex/payment-status" +import { baseLogger } from "@services/logger" +import { GiftCardOrdersRepository } from "@services/mongoose" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +import { notifyGiftCardOpsEvent } from "./ops" +import { fetchAndSettle } from "./settle-order" + +// Lazy, for the same reason as reservation-store.ts: no live Redis client as +// an import-time side effect of anything that pulls in the app barrel. +const redisClient = async () => (await import("@services/redis")).redis + +const logger = baseLogger.child({ module: "gift-cards.reconcile" }) + +/** + * ENG-581 — the fulfilment worker. + * + * Everything the purchase mutation does not wait for ends up here: + * + * CREATED / INVOICE_ISSUED past `expiresAt` → EXPIRED (after one payment re-read) + * PAYMENT_PENDING → PAID | PAYMENT_FAILED (payment re-read) + * PAID → poll vendor with backoff → FULFILLED + * | REFUND_REQUIRED; 24h → REFUND_REQUIRED + * + * Runs under a Redis lock so the cron pod and the trigger's 30s interval never + * work the same batch at once. Never throws: a per-order failure is logged, + * counted, and the loop moves on — one vendor 500 must not strand the other + * orders in the batch. + * + * Backoff per PAID order: 5s, 15s, 60s, 5m, then every 15m, measured from the + * PAID transition. Attempts are tracked in process memory (nothing on the + * order changes on a poll, by design — `transition` is the only write), which + * gives the trigger's interval its real schedule and costs the cron exactly one + * attempt per run, which is what a 15-minute cadence would do anyway. + */ +export type GiftCardReconcileSummary = { + scanned: number + fulfilled: number + refundRequired: number + expired: number + paymentSettled: number + /** Set when another worker held the lock and this run did nothing. */ + skipped?: "lock-held" +} + +export const GIFT_CARD_RECONCILE_LOCK_KEY = "giftcards:reconcile:lock" +const LOCK_TTL_MS = 5 * 60 * 1000 +const BATCH_LIMIT = 200 + +const SECOND = 1000 +const MINUTE = 60 * SECOND +export const GIFT_CARD_PAID_TIMEOUT_MS = 24 * 60 * MINUTE +// Pending payments have their own horizon: IBEX reports in-flight sends for a +// bounded time, and an order our own rail cannot tell us about after this long +// needs a human, not another poll. +const PAYMENT_PENDING_WARN_MS = 60 * MINUTE + +/** + * Gap to the next poll given how long the order had been PAID at the last + * attempt. The schedule the ticket names, as a step function: 5s, 15s, 60s, + * 5m, then every 15m. + */ +export const giftCardPollGapMs = (elapsedSincePaidMs: number): number => { + if (elapsedSincePaidMs < 5 * SECOND) return 5 * SECOND + if (elapsedSincePaidMs < 20 * SECOND) return 15 * SECOND + if (elapsedSincePaidMs < 80 * SECOND) return 60 * SECOND + if (elapsedSincePaidMs < 380 * SECOND) return 5 * MINUTE + return 15 * MINUTE +} + +/** When the next vendor poll is due. `lastAttemptAtMs` defaults to the PAID transition. */ +export const nextGiftCardPollAt = ( + paidAtMs: number, + lastAttemptAtMs?: number, +): number => { + const last = Math.max(paidAtMs, lastAttemptAtMs ?? paidAtMs) + return last + giftCardPollGapMs(last - paidAtMs) +} + +const lastAttemptAt = new Map() + +export const __resetGiftCardReconcileStateForTest = (): void => { + lastAttemptAt.clear() +} + +const lastTransitionAt = (order: GiftCardOrder, status: GiftCardOrderStatus): Date => { + for (let i = order.statusHistory.length - 1; i >= 0; i -= 1) { + if (order.statusHistory[i].status === status) return order.statusHistory[i].at + } + return order.updatedAt +} + +type SentPaymentStatus = "settled" | "failed" | "pending" | "unknown" + +/** + * Re-read a sent payment from IBEX. + * + * `payLnInvoiceViaIbex` exposes the pay 200's `transaction.id`, which + * `purchaseGiftCard` stores as `providerPaymentRef`. `getTransactionDetails` + * on that id returns the same payment-level fields the pay response carries + * (`payment.statusId`, `payment.status.id`, `payment.failureId`), so the one + * reader, `paymentSendStatusFromIbex`, maps both. + * + * No ref → "unknown". That is an order that crashed between the IBEX call and + * the transition that would have written the ref, or a 200 whose + * `transaction.id` was empty. THERE IS NO HASH-BASED FALLBACK: the + * `getAccountTransactions` (IBEX `G`) 200 items carry only id / createdAt / + * accountId / amount / networkFee / exchangeRateCurrencySats / currencyId / + * transactionTypeId — no `payment.hash` and no `bolt11` — and `invoiceFromHash` + * resolves only invoices WE issued. Such an order stays PAYMENT_PENDING and is + * warned on after PAYMENT_PENDING_WARN_MS; resolving it needs the IBEX + * dashboard. (The LND-rail `lnpayments` collection is not consulted: nothing + * writes it on this IBEX-custodial deployment.) + * + * An IbexError (network, 5xx, 404) is "unknown" too: it says nothing about + * whether money moved, and the next run retries. An UnconfirmedIbexPayment — + * the transaction exists but reports no recognised payment status — is also + * "unknown" rather than "pending", so it cannot hold an expired invoice open + * indefinitely on a claim IBEX never actually made. + */ +const lookupSentPaymentStatus = async ( + order: GiftCardOrder, +): Promise => { + if (!order.providerPaymentRef) return "unknown" + + const details = await Ibex.getTransactionDetails( + order.providerPaymentRef as IbexTransactionId, + ) + if (details instanceof Error) { + logger.warn( + { + orderId: order.id, + providerPaymentRef: order.providerPaymentRef, + error: details.constructor.name, + }, + "Could not re-read gift card payment from IBEX", + ) + return "unknown" + } + + const status = paymentSendStatusFromIbex({ + transaction: { id: details.id, payment: details.payment }, + }) + if (status instanceof UnconfirmedIbexPayment) return "unknown" + + switch (status.value) { + case PaymentSendStatus.Success.value: + case PaymentSendStatus.AlreadyPaid.value: + return "settled" + case PaymentSendStatus.Failure.value: + return "failed" + default: + return "pending" + } +} + +const acquireLock = async (): Promise => { + const redis = await redisClient() + const token = randomUUID() + const res = await redis.set( + GIFT_CARD_RECONCILE_LOCK_KEY, + token, + "PX", + LOCK_TTL_MS, + "NX", + ) + return res === "OK" ? token : null +} + +// Compare-and-delete so a run that outlived its TTL cannot drop a lock another +// worker has since taken. +const releaseLock = async (token: string): Promise => { + try { + const redis = await redisClient() + const current = await redis.get(GIFT_CARD_RECONCILE_LOCK_KEY) + if (current === token) await redis.del(GIFT_CARD_RECONCILE_LOCK_KEY) + } catch (error) { + logger.warn( + { error }, + "Could not release gift card reconcile lock; it will lapse at TTL", + ) + } +} + +const settleAsPaid = async ( + order: GiftCardOrder, + summary: GiftCardReconcileSummary, + reason: string, +): Promise => { + const repo = GiftCardOrdersRepository() + const paid = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.Paid, + reason, + patch: { paidSats: order.invoiceSats ?? order.quoteSats }, + }) + if (paid instanceof Error) throw paid + summary.paymentSettled += 1 + notifyGiftCardOpsEvent({ phase: "order-paid", status: "success", order: paid }) + + lastAttemptAt.set(paid.id, Date.now()) + const settled = await fetchAndSettle(paid) + if (settled instanceof Error) { + logger.info( + { orderId: paid.id, error: settled.constructor.name }, + "Settled payment; vendor poll did not fulfil yet", + ) + return + } + if (settled.status === GiftCardOrderStatus.Fulfilled) { + summary.fulfilled += 1 + lastAttemptAt.delete(paid.id) + } + if (settled.status === GiftCardOrderStatus.RefundRequired) summary.refundRequired += 1 +} + +const processExpiry = async ( + order: GiftCardOrder, + now: Date, + summary: GiftCardReconcileSummary, +): Promise => { + if (order.expiresAt.getTime() >= now.getTime()) return + + // An INVOICE_ISSUED order may have been paid by a call that crashed before + // recording it. One re-read before writing it off: settled → PAID, in + // flight → leave it for the PAYMENT_PENDING path next run. Only possible + // when the IBEX transaction id was persisted; a crash BETWEEN the IBEX call + // and the transition that writes it leaves no ref, the lookup answers + // "unknown", and the order is expired — the one window where a paid invoice + // can be written off, logged here so it is at least visible. + if (order.status === GiftCardOrderStatus.InvoiceIssued) { + const payment = await lookupSentPaymentStatus(order) + if (payment === "settled") { + await settleAsPaid(order, summary, "payment-settled-on-reconcile") + return + } + if (payment === "pending") { + logger.warn( + { orderId: order.id }, + "Expired gift card invoice still has an in-flight payment; not expiring", + ) + return + } + } + + const expired = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.Created, GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.Expired, + reason: "expired", + patch: { failureReason: "expired" }, + }) + if (expired instanceof Error) throw expired + summary.expired += 1 + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order: expired, + meta: { reason: "expired" }, + }) +} + +const processPendingPayment = async ( + order: GiftCardOrder, + now: Date, + summary: GiftCardReconcileSummary, +): Promise => { + const payment = await lookupSentPaymentStatus(order) + switch (payment) { + case "settled": + await settleAsPaid(order, summary, "payment-settled-on-reconcile") + return + case "failed": { + const failed = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.PaymentFailed, + reason: "payment-failed-on-reconcile", + patch: { failureReason: "payment-failed" }, + }) + if (failed instanceof Error) throw failed + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order: failed, + meta: { reason: "payment-failed" }, + }) + return + } + default: { + const pendingSince = lastTransitionAt(order, GiftCardOrderStatus.PaymentPending) + if (now.getTime() - pendingSince.getTime() > PAYMENT_PENDING_WARN_MS) { + logger.warn( + { + orderId: order.id, + paymentHash: order.paymentHash, + providerPaymentRef: order.providerPaymentRef, + since: pendingSince, + }, + "Gift card payment has been pending for over an hour with no resolvable status", + ) + } + } + } +} + +const processPaid = async ( + order: GiftCardOrder, + now: Date, + summary: GiftCardReconcileSummary, +): Promise => { + const paidAtMs = lastTransitionAt(order, GiftCardOrderStatus.Paid).getTime() + const nowMs = now.getTime() + + if (nowMs - paidAtMs >= GIFT_CARD_PAID_TIMEOUT_MS) { + const refund = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.Paid], + to: GiftCardOrderStatus.RefundRequired, + reason: "fulfillment-timeout", + patch: { failureReason: "fulfillment-timeout" }, + }) + if (refund instanceof Error) throw refund + lastAttemptAt.delete(order.id) + summary.refundRequired += 1 + notifyGiftCardOpsEvent({ + phase: "refund-required", + status: "failed", + order: refund, + error: "fulfillment-timeout", + meta: { reason: "fulfillment-timeout" }, + }) + return + } + + if (nowMs < nextGiftCardPollAt(paidAtMs, lastAttemptAt.get(order.id))) return + lastAttemptAt.set(order.id, nowMs) + + const settled = await fetchAndSettle(order) + if (settled instanceof Error) throw settled + if (settled.status === GiftCardOrderStatus.Fulfilled) { + summary.fulfilled += 1 + lastAttemptAt.delete(order.id) + } else if (settled.status === GiftCardOrderStatus.RefundRequired) { + summary.refundRequired += 1 + lastAttemptAt.delete(order.id) + } +} + +const forEachOrder = async ( + orders: GiftCardOrder[], + summary: GiftCardReconcileSummary, + fn: (order: GiftCardOrder) => Promise, +): Promise => { + for (const order of orders) { + summary.scanned += 1 + try { + await fn(order) + } catch (error) { + // One order's vendor/repo failure never stops the batch. + recordExceptionInCurrentSpan({ error }) + logger.error( + { orderId: order.id, status: order.status, error }, + "Gift card reconcile failed for order", + ) + } + } +} + +export const reconcileGiftCardOrders = async ( + now: Date = new Date(), +): Promise => { + const summary: GiftCardReconcileSummary = { + scanned: 0, + fulfilled: 0, + refundRequired: 0, + expired: 0, + paymentSettled: 0, + } + + let token: string | null + try { + token = await acquireLock() + } catch (error) { + return new UnknownGiftCardError( + `Could not acquire gift card reconcile lock: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + if (token === null) { + addAttributesToCurrentSpan({ "giftcard.reconcile.skipped": "lock-held" }) + return { ...summary, skipped: "lock-held" } + } + + try { + const repo = GiftCardOrdersRepository() + + const open = await repo.listByStatus({ + statuses: [GiftCardOrderStatus.Created, GiftCardOrderStatus.InvoiceIssued], + limit: BATCH_LIMIT, + }) + if (open instanceof Error) { + logger.error({ error: open }, "Could not list open gift card orders") + } else { + await forEachOrder( + open.filter((o) => o.expiresAt.getTime() < now.getTime()), + summary, + (order) => processExpiry(order, now, summary), + ) + } + + const pending = await repo.listByStatus({ + statuses: [GiftCardOrderStatus.PaymentPending], + limit: BATCH_LIMIT, + }) + if (pending instanceof Error) { + logger.error({ error: pending }, "Could not list payment-pending gift card orders") + } else { + await forEachOrder(pending, summary, (order) => + processPendingPayment(order, now, summary), + ) + } + + const paid = await repo.listByStatus({ + statuses: [GiftCardOrderStatus.Paid], + limit: BATCH_LIMIT, + }) + if (paid instanceof Error) { + logger.error({ error: paid }, "Could not list paid gift card orders") + } else { + await forEachOrder(paid, summary, (order) => processPaid(order, now, summary)) + } + + addAttributesToCurrentSpan({ + "giftcard.reconcile.scanned": summary.scanned, + "giftcard.reconcile.fulfilled": summary.fulfilled, + "giftcard.reconcile.refundRequired": summary.refundRequired, + "giftcard.reconcile.expired": summary.expired, + "giftcard.reconcile.paymentSettled": summary.paymentSettled, + }) + return summary + } finally { + await releaseLock(token) + } +} + +/** + * Cron entry (src/servers/cron.ts). Self-guards on the master switch; a + * returned error is thrown so the cron runner logs and counts the failure the + * way it does every other task. + */ +export const reconcileGiftCardOrdersJob = async (): Promise => { + if (!GiftCardsConfig?.enabled) return + const summary = await reconcileGiftCardOrders() + if (summary instanceof Error) throw summary + logger.info({ summary }, "gift card reconcile finished") +} + +export const GIFT_CARD_RECONCILE_INTERVAL_MS = 30 * SECOND + +/** + * In-process schedule for the trigger server, so a PAID order reaches + * FULFILLED in seconds rather than at the next 15-minute cron. Overlapping + * ticks are skipped locally; cross-pod overlap is the Redis lock's job. + */ +export const startGiftCardReconcileInterval = ( + intervalMs: number = GIFT_CARD_RECONCILE_INTERVAL_MS, +): NodeJS.Timeout | null => { + if (!GiftCardsConfig?.enabled) return null + let running = false + return setInterval(async () => { + if (running) return + running = true + try { + const summary = await reconcileGiftCardOrders() + if (summary instanceof Error) { + logger.warn({ error: summary }, "gift card reconcile tick failed") + } + } catch (error) { + logger.error({ error }, "gift card reconcile tick threw") + } finally { + running = false + } + }, intervalMs) +} diff --git a/src/app/gift-cards/reservation-store.ts b/src/app/gift-cards/reservation-store.ts new file mode 100644 index 000000000..9835ee457 --- /dev/null +++ b/src/app/gift-cards/reservation-store.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "crypto" + +import { baseLogger } from "@services/logger" + +// Loaded on first use rather than at import time: `@services/redis` constructs +// a live client as a module side effect, and this module is reachable from the +// GraphQL mutation's import graph (pattern: fygaro/checkout-intent-store.ts). +const redisClient = async () => (await import("@services/redis")).redis + +/** + * Per-account holds on the gift-card daily allowance. + * + * A reservation covers the window between `authorizeGiftCardPurchase` passing + * and the order row existing in Mongo. Once the row exists it is what the + * daily-cap sum counts (every non-failed order in the trailing 24h), so the + * purchase path releases the hold immediately after `create` — a hold that + * outlived the row would count the same money twice. Two concurrent + * authorisations for one account therefore see each other's hold even though + * neither has a row yet, which is the one race the repository cannot close. + * + * Storage is two keys, both bounded by `RESERVATION_TTL_SECONDS`: + * + * giftcards:reservation:: -> amount (minor units) + * giftcards:reservations: -> zset, score = expiry ms, + * member = ":" + * + * The zset is the sum index (one ZRANGE, pruned by score on every read); the + * per-reservation key is what lets `release(accountId, id)` find the member to + * ZREM without the caller having to carry the amount around. Amount-first in + * the member because the id is a UUID (no colon), so the split is unambiguous + * and a malformed member parses to NaN and is dropped rather than summed. + * + * FAIL-CLOSED on read: an unreadable index is returned as the error, never as + * "0 outstanding" — treating it as empty is exactly what would let a second + * full-allowance order through while the first is still in flight. The caller + * decides what that means per limits mode (`limitsUnavailable`). + */ +export const RESERVATION_TTL_SECONDS = 24 * 60 * 60 + +const reservationKey = (accountId: string, id: string) => + `giftcards:reservation:${accountId}:${id}` +const indexKey = (accountId: string) => `giftcards:reservations:${accountId}` + +const member = (amountMinor: number, id: string) => `${amountMinor}:${id}` + +const memberAmount = (m: string): number => { + const amount = Number(m.slice(0, m.indexOf(":"))) + return Number.isFinite(amount) && amount > 0 ? amount : 0 +} + +export const newGiftCardReservationId = (): string => randomUUID() + +export type GiftCardReservation = { + id: string + amountMinor: number + expiresAtMs: number +} + +export const writeGiftCardReservation = async ({ + accountId, + amountMinor, + nowMs, + id = newGiftCardReservationId(), +}: { + accountId: string + amountMinor: number + nowMs: number + id?: string +}): Promise => { + try { + const redis = await redisClient() + const expiresAtMs = nowMs + RESERVATION_TTL_SECONDS * 1000 + // Index first: it is the half the cap sum reads, so any early return below + // leaves the hold in place rather than a dangling lookup key with no hold. + await redis.zadd(indexKey(accountId), expiresAtMs, member(amountMinor, id)) + await redis.set( + reservationKey(accountId, id), + String(amountMinor), + "EX", + RESERVATION_TTL_SECONDS, + ) + return id + } catch (err) { + return err instanceof Error ? err : new Error(String(err)) + } +} + +/** Live holds for the account. Expired members are pruned before the read. */ +export const readGiftCardReservations = async ({ + accountId, + nowMs, +}: { + accountId: string + nowMs: number +}): Promise => { + try { + const redis = await redisClient() + const key = indexKey(accountId) + await redis.zremrangebyscore(key, "-inf", nowMs) + const flat = await redis.zrange(key, 0, -1, "WITHSCORES") + + const reservations: GiftCardReservation[] = [] + for (let i = 0; i < flat.length; i += 2) { + const m = flat[i] + const amountMinor = memberAmount(m) + if (amountMinor <= 0) continue + const expiresAtMs = Number(flat[i + 1]) + reservations.push({ + id: m.slice(m.indexOf(":") + 1), + amountMinor, + expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : nowMs, + }) + } + return reservations + } catch (err) { + baseLogger.warn( + { accountId, error: err instanceof Error ? err.constructor.name : String(err) }, + "Failed to read outstanding gift card reservations", + ) + return err instanceof Error ? err : new Error(String(err)) + } +} + +/** + * Drop a hold. Best-effort: the hold expires with its TTL anyway, so the worst + * case of a failed release is the allowance freeing up late, never early. + * Releasing an id that is already gone is a no-op. + */ +export const releaseGiftCardReservation = async ( + accountId: string, + reservationId: string | null | undefined, +): Promise => { + if (!reservationId) return + try { + const redis = await redisClient() + const key = reservationKey(accountId, reservationId) + const raw = await redis.get(key) + if (raw !== null && raw !== undefined) { + const amountMinor = Number(raw) + if (Number.isFinite(amountMinor)) { + await redis.zrem(indexKey(accountId), member(amountMinor, reservationId)) + } + } + await redis.del(key) + } catch (err) { + baseLogger.warn( + { + accountId, + reservationId, + error: err instanceof Error ? err.constructor.name : String(err), + }, + "Failed to release gift card reservation; it will lapse at its TTL", + ) + } +} diff --git a/src/app/gift-cards/send-fulfilled-notification.ts b/src/app/gift-cards/send-fulfilled-notification.ts new file mode 100644 index 000000000..c8e8ea066 --- /dev/null +++ b/src/app/gift-cards/send-fulfilled-notification.ts @@ -0,0 +1,52 @@ +import { sendOutcomeNotificationBestEffort } from "@app/notifications/send-outcome-notification" + +/** + * "Your gift card is ready" — the push that closes the loop the + * mutation deliberately does not wait for. `purchaseGiftCard` returns as soon + * as the invoice is PAID; fulfilment usually lands seconds later via the first + * poll or the reconcile worker, and this is how the customer learns it did. + * + * Carries NO claim data. The push payload travels through FCM and sits in the + * OS notification store; the code is fetched over an authenticated GraphQL + * read (`getGiftCardOrderForAccount`) and nowhere else. `orderId` is included + * so the app can deep-link straight to it. + * + * Best-effort by construction (same contract as the Fygaro top-up push): the + * order is already FULFILLED when this fires, and a notification failure must + * never unwind that. + */ +export type GiftCardFulfilledNotificationArgs = { + accountId: string + orderId: string + brand: string + valueMinor: number + quantity: number + currency: string +} + +const formatMajorUnits = (minor: number): string => (minor / 100).toFixed(2) + +export const GIFT_CARD_FULFILLED_DATA_TYPE = "gift_card_fulfilled" + +export const sendGiftCardFulfilledNotificationBestEffort = async ({ + accountId, + orderId, + brand, + valueMinor, + quantity, + currency, +}: GiftCardFulfilledNotificationArgs): Promise => { + const amount = formatMajorUnits(valueMinor) + const amountArg = + quantity > 1 ? `${quantity} x ${amount} ${currency}` : `${amount} ${currency}` + return sendOutcomeNotificationBestEffort({ + accountId, + phraseBase: "notification.giftCard.fulfilled", + dataType: GIFT_CARD_FULFILLED_DATA_TYPE, + amountArg, + replacements: { brand }, + extraData: { amount, currency, orderId, brand }, + logMessage: "Failed to send gift card fulfilled push notification", + logContext: { accountId, orderId }, + }) +} diff --git a/src/app/gift-cards/settle-order.ts b/src/app/gift-cards/settle-order.ts new file mode 100644 index 000000000..2017f61a9 --- /dev/null +++ b/src/app/gift-cards/settle-order.ts @@ -0,0 +1,232 @@ +import { GiftCardOrderStateError, GiftCardOrderStatus } from "@domain/gift-cards" +import { encryptGiftCardClaim } from "@services/gift-cards/claim-crypto" +import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" +import { baseLogger } from "@services/logger" +import { GiftCardOrdersRepository } from "@services/mongoose" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +import { notifyGiftCardOpsEvent } from "./ops" +import { sendGiftCardFulfilledNotificationBestEffort } from "./send-fulfilled-notification" + +/** + * THE settlement path. The purchase mutation's first poll, the reconcile + * worker, and any future vendor webhook (Bitrefill) all hand a vendor status + * to this one function, so "what does a vendor `refunded` mean for an order in + * state X" has exactly one answer. + * + * Idempotent: settling an already-FULFILLED order with `fulfilled` returns it + * unchanged and never re-encrypts or re-notifies. Races between two settlers + * are decided by the repository's conditional transition — the loser gets a + * `GiftCardOrderStateError`, re-reads, and returns whatever won. + * + * Claim data enters here as plaintext from the adapter and leaves only as + * ciphertext on the order. It is never logged, traced, or put in an ops event. + */ +export const settleOrderFromVendor = async ( + order: GiftCardOrder, + status: GiftCardProviderOrderStatus, +): Promise => { + addAttributesToCurrentSpan({ + "giftcard.orderId": order.id, + "giftcard.provider": order.providerId, + "giftcard.vendorStatus": status.kind, + "giftcard.status": order.status, + }) + + switch (status.kind) { + case "awaitingPayment": + case "paidPendingFulfillment": + return order + case "fulfilled": + return fulfil(order, status.claim) + case "failed": + case "refunded": + return vendorFailed(order, status) + } +} + +const fulfil = async ( + order: GiftCardOrder, + claim: GiftCardClaim, +): Promise => { + const repo = GiftCardOrdersRepository() + + if (order.status === GiftCardOrderStatus.Fulfilled) return order + + let current = order + // The vendor saw our payment before we recorded it (a crash between the pay + // call and the PAID transition, or a Pending that settled). Their word that + // the card shipped is proof of payment; record it so the order can proceed. + if ( + current.status === GiftCardOrderStatus.InvoiceIssued || + current.status === GiftCardOrderStatus.PaymentPending + ) { + const paid = await repo.transition({ + id: current.id, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.Paid, + reason: "vendor-reported-fulfilled", + patch: { paidSats: current.invoiceSats ?? current.quoteSats }, + }) + if (paid instanceof Error) return paid + notifyGiftCardOpsEvent({ phase: "order-paid", status: "success", order: paid }) + current = paid + } + + if (current.status !== GiftCardOrderStatus.Paid) { + // FAILED / PAYMENT_FAILED / EXPIRED / REFUND_REQUIRED, yet the vendor says + // a card was issued. Someone paid for it; our records say not us. Page. + const error = new GiftCardOrderStateError( + `Vendor reports fulfilled but order is ${current.status}`, + ) + recordExceptionInCurrentSpan({ error }) + notifyGiftCardOpsEvent({ + phase: "vendor-fulfilled-unexpected", + status: "failed", + order: current, + error: error.constructor.name, + meta: { orderStatus: current.status }, + }) + return error + } + + const encrypted = encryptGiftCardClaim(claim) + if (encrypted instanceof Error) { + // The card exists at the vendor; only our storage failed. Leave PAID so the + // next poll retries once the key is fixed, and page because until then the + // customer has paid for something they cannot see. + recordExceptionInCurrentSpan({ error: encrypted }) + notifyGiftCardOpsEvent({ + phase: "claim-encrypt-failed", + status: "failed", + order: current, + error: encrypted.constructor.name, + }) + return encrypted + } + + const fulfilled = await repo.transition({ + id: current.id, + from: [GiftCardOrderStatus.Paid], + to: GiftCardOrderStatus.Fulfilled, + reason: "vendor-fulfilled", + patch: { + claimCiphertext: encrypted.ciphertext, + claimKeyId: encrypted.keyId, + fulfilledAt: new Date(), + }, + }) + if (fulfilled instanceof GiftCardOrderStateError) { + // Lost a race with another settler. Whatever it wrote is the truth. + const latest = await repo.findById(current.id) + if (!(latest instanceof Error) && latest.status === GiftCardOrderStatus.Fulfilled) { + return latest + } + return fulfilled + } + if (fulfilled instanceof Error) return fulfilled + + notifyGiftCardOpsEvent({ + phase: "order-fulfilled", + status: "success", + order: fulfilled, + }) + await sendGiftCardFulfilledNotificationBestEffort({ + accountId: fulfilled.accountId, + orderId: fulfilled.id, + brand: fulfilled.productSnapshot.brand, + valueMinor: fulfilled.valueMinor, + quantity: fulfilled.quantity, + currency: fulfilled.currency, + }) + return fulfilled +} + +const vendorFailed = async ( + order: GiftCardOrder, + status: Extract, +): Promise => { + const repo = GiftCardOrdersRepository() + const reason = `vendor-${status.kind}: ${status.reason}`.slice(0, 500) + + switch (order.status) { + case GiftCardOrderStatus.Paid: { + // Money left Flash; no card is coming. The ONLY state that means that, + // and the one this flow pages on. + const refund = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.Paid], + to: GiftCardOrderStatus.RefundRequired, + reason, + patch: { failureReason: reason }, + }) + if (refund instanceof Error) return refund + notifyGiftCardOpsEvent({ + phase: "refund-required", + status: "failed", + order: refund, + error: `vendor-${status.kind}`, + meta: { reason: status.reason.slice(0, 80) }, + }) + return refund + } + case GiftCardOrderStatus.Created: + case GiftCardOrderStatus.InvoiceIssued: { + // Vendor cancelled before we paid. Nothing moved. + const failed = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.Created, GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.Failed, + reason, + patch: { failureReason: reason }, + }) + if (failed instanceof Error) return failed + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order: failed, + error: `vendor-${status.kind}`, + meta: { reason: status.reason.slice(0, 80) }, + }) + return failed + } + case GiftCardOrderStatus.PaymentPending: + // Our payment may still be in flight. Whether it settled decides + // PAID→REFUND_REQUIRED or PAYMENT_FAILED; that is the reconcile worker's + // payment re-read, not the vendor's word. + baseLogger.warn( + { orderId: order.id, vendorStatus: status.kind }, + "Vendor reports failed/refunded while our payment is pending; leaving for payment re-read", + ) + return order + default: + return order + } +} + +/** + * Ask the vendor where the order stands and settle on the answer. The + * reconcile worker's PAID path, and the purchase mutation's single + * fulfilled-already? poll. + */ +export const fetchAndSettle = async ( + order: GiftCardOrder, +): Promise => { + const provider = getEnabledGiftCardProvider(order.providerId) + if (provider instanceof Error) return provider + + if (!order.providerOrderId) { + return new GiftCardOrderStateError("Order has no provider order id to look up") + } + + const status = await provider.getOrder({ + providerOrderId: order.providerOrderId, + paymentRequest: order.paymentRequest, + }) + if (status instanceof Error) return status + + return settleOrderFromVendor(order, status) +} diff --git a/src/app/gift-cards/sync-catalog.ts b/src/app/gift-cards/sync-catalog.ts new file mode 100644 index 000000000..640359690 --- /dev/null +++ b/src/app/gift-cards/sync-catalog.ts @@ -0,0 +1,245 @@ +import { randomBytes } from "crypto" + +import { GiftCardError, UnknownGiftCardError } from "@domain/gift-cards" +import { notifyOpsEvent } from "@services/alerts/ops-events" +import { enabledGiftCardProviders } from "@services/gift-cards" +import { GiftCardCatalogCache } from "@services/gift-cards/catalog-cache" +import { baseLogger } from "@services/logger" +import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" + +// Lazy for the same reason as the catalog cache: `@services/redis` opens a +// connection at import time, and this module is pulled in by the cron entry +// point and by unit tests that never want one. +const redisClient = async () => (await import("@services/redis")).redis + +/** + * Pulls every enabled vendor's full catalog into the Redis read model. + * + * `IGiftCardProvider.listProducts` is the one vendor call that is never on a + * request path — it is this job's, and only this job's, to make. One provider + * failing must not cost the others their refresh, so each is synced on its own + * and reported on its own; the summary the cron logs is whatever succeeded. + * + * Concurrency: the cron Job is one-shot and k8s may start the next run before a + * slow vendor pull has finished, so the whole pass sits behind a Redis + * `SET NX EX` lock. A held lock means another run is already doing this work + * and we return `[]`; a lock we cannot reach means Redis is down, and since the + * catalog is written TO Redis there is nothing useful to do either. + */ +export type GiftCardCatalogSyncSummary = { + providerId: GiftCardProviderId + products: number + countries: number + durationMs: number +} + +export const GIFT_CARD_CATALOG_SYNC_LOCK_KEY = "giftcards:catalog-sync:lock" +export const GIFT_CARD_CATALOG_SYNC_LOCK_TTL_SECONDS = 10 * 60 + +// Throttle marker for callers that honour `catalog.syncIntervalSeconds` (the +// cron runs far more often than the catalog needs refreshing). Separate from +// the lock: the lock says "a run is in flight", the marker says "a run finished +// recently". +export const GIFT_CARD_CATALOG_SYNC_MARKER_KEY = "giftcards:catalog-sync:last-run" + +// Release only the lock WE took. A plain DEL after a run that outlived the TTL +// would free a lock some newer run now holds. +const RELEASE_IF_OWNER = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +end +return 0 +` + +const syncForProvider = async ( + provider: IGiftCardProvider, +): Promise => { + const start = Date.now() + const providerId = provider.id + + const fail = (err: ApplicationError): ApplicationError => { + baseLogger.error({ providerId, err }, "gift card catalog sync failed") + notifyOpsEvent({ + flow: "giftcard", + phase: "catalog-sync-failed", + status: "failed", + error: err.name, + meta: { providerId }, + }) + return err + } + + let products: GiftCardProduct[] | GiftCardError + try { + products = await provider.listProducts() + } catch (err) { + // The port says "return an error, never throw"; an adapter bug that throws + // anyway must not take the other providers down with it. + return fail(new UnknownGiftCardError(err)) + } + if (products instanceof Error) return fail(products) + + const written = await GiftCardCatalogCache().write({ + providerId, + products, + syncedAt: new Date(), + }) + if (written instanceof Error) return fail(written) + + const summary: GiftCardCatalogSyncSummary = { + providerId, + products: products.length, + countries: written.countries.length, + durationMs: Date.now() - start, + } + + notifyOpsEvent({ + flow: "giftcard", + phase: "catalog-synced", + status: "success", + meta: { + providerId, + products: String(summary.products), + countries: String(summary.countries), + durationMs: String(summary.durationMs), + }, + }) + + return summary +} + +type SyncLock = { acquired: false } | { acquired: true; release: () => Promise } + +const acquireSyncLock = async (): Promise => { + try { + const redis = await redisClient() + const token = randomBytes(16).toString("hex") + const set = await redis.set( + GIFT_CARD_CATALOG_SYNC_LOCK_KEY, + token, + "EX", + GIFT_CARD_CATALOG_SYNC_LOCK_TTL_SECONDS, + "NX", + ) + if (set !== "OK") return { acquired: false } + + const release = async () => { + try { + await redis.eval(RELEASE_IF_OWNER, 1, GIFT_CARD_CATALOG_SYNC_LOCK_KEY, token) + } catch (err) { + // The lock expires on its own; the next run is delayed by at most the TTL. + baseLogger.warn({ err }, "gift card catalog sync lock release failed") + } + } + return { acquired: true, release } + } catch (err) { + return err instanceof Error ? err : new Error(String(err)) + } +} + +// `true` when this run should proceed. Sets the marker atomically so two runs +// racing on the interval boundary cannot both pass. +const claimIntervalSlot = async ( + minIntervalSeconds: number, +): Promise => { + try { + const redis = await redisClient() + const set = await redis.set( + GIFT_CARD_CATALOG_SYNC_MARKER_KEY, + new Date().toISOString(), + "EX", + minIntervalSeconds, + "NX", + ) + return set === "OK" + } catch (err) { + return err instanceof Error ? err : new Error(String(err)) + } +} + +// A run in which nothing synced must not hold the interval: the next cron tick +// should retry rather than wait out a full interval on a transient vendor fault. +const releaseIntervalSlot = async (): Promise => { + try { + const redis = await redisClient() + await redis.del(GIFT_CARD_CATALOG_SYNC_MARKER_KEY) + } catch (err) { + baseLogger.warn({ err }, "gift card catalog sync interval marker release failed") + } +} + +/** + * Sync every enabled provider. Never throws; never lets one provider's failure + * stop the others. Returns the summaries of the providers that synced. + * + * `minIntervalSeconds`, when set, skips the run entirely if a run completed + * less than that long ago (Redis marker). The cron passes + * `catalog.syncIntervalSeconds`; direct callers (an operator forcing a refresh) + * leave it unset. + */ +const syncAll = async ({ + minIntervalSeconds, +}: { minIntervalSeconds?: number } = {}): Promise => { + try { + const lock = await acquireSyncLock() + if (lock instanceof Error) { + baseLogger.warn({ err: lock }, "gift card catalog sync skipped: lock unavailable") + return [] + } + if (!lock.acquired) { + baseLogger.info("gift card catalog sync skipped: another run holds the lock") + return [] + } + + try { + let claimedInterval = false + if (minIntervalSeconds !== undefined && minIntervalSeconds > 0) { + const claim = await claimIntervalSlot(minIntervalSeconds) + if (claim instanceof Error) { + baseLogger.warn( + { err: claim }, + "gift card catalog sync skipped: interval marker unavailable", + ) + return [] + } + if (!claim) { + baseLogger.info( + { minIntervalSeconds }, + "gift card catalog sync skipped: synced within the interval", + ) + return [] + } + claimedInterval = true + } + + const providers = enabledGiftCardProviders() + const summaries: GiftCardCatalogSyncSummary[] = [] + for (const provider of providers) { + const result = await syncForProvider(provider) + if (result instanceof Error) continue + summaries.push(result) + baseLogger.info(result, "gift card catalog synced") + } + + if (claimedInterval && providers.length > 0 && summaries.length === 0) { + await releaseIntervalSlot() + } + + return summaries + } finally { + await lock.release() + } + } catch (err) { + baseLogger.error({ err }, "gift card catalog sync crashed") + return [] + } +} + +export const { syncGiftCardCatalogForProvider, syncGiftCardCatalogs } = + wrapAsyncFunctionsToRunInSpan({ + namespace: "app.gift-cards", + fns: { + syncGiftCardCatalogForProvider: syncForProvider, + syncGiftCardCatalogs: syncAll, + }, + }) diff --git a/src/app/index.ts b/src/app/index.ts index e7e104077..c48a38d03 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -16,6 +16,7 @@ import * as MerchantsMod from "./merchants" import * as SwapMod from "./swap" import * as CashWalletCutoverMod from "./cash-wallet-cutover" import * as ApiKeysMod from "./api-keys" +import * as GiftCardsMod from "./gift-cards" const allFunctions = { Accounts: { ...AccountsMod }, @@ -34,6 +35,7 @@ const allFunctions = { Swap: { ...SwapMod }, CashWalletCutover: { ...CashWalletCutoverMod }, ApiKeys: { ...ApiKeysMod }, + GiftCards: { ...GiftCardsMod }, } as const let subModule: keyof typeof allFunctions @@ -66,4 +68,5 @@ export const { Swap, CashWalletCutover, ApiKeys, + GiftCards, } = allFunctions diff --git a/src/app/notifications/send-outcome-notification.ts b/src/app/notifications/send-outcome-notification.ts index 65e25e4b4..cb810b834 100644 --- a/src/app/notifications/send-outcome-notification.ts +++ b/src/app/notifications/send-outcome-notification.ts @@ -38,6 +38,10 @@ export type OutcomeNotificationArgs = { // Merged into the FCM `data` payload alongside `type`. Keys are conventional // across flows: `amount` is MAJOR units ("56.52"), `currency` an ISO code. extraData?: Record + // Extra i18n interpolations applied to BOTH `${phraseBase}.title` and + // `${phraseBase}.body` (e.g. `{{brand}}` in "Your {{brand}} gift card is + // ready"). `amount` stays a body-only argument via `amountArg`. + replacements?: Record } /** @@ -53,6 +57,7 @@ const sendOutcomeNotification = async ({ dataType, amountArg, extraData, + replacements, }: OutcomeNotificationArgs): Promise => { const accountId = checkedToAccountId(accountIdRaw) if (accountId instanceof Error) return accountId @@ -65,8 +70,11 @@ const sendOutcomeNotification = async ({ const locale = getLanguageOrDefault(user.language) - const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) - const body = i18n.__({ phrase: `${phraseBase}.body`, locale }, { amount: amountArg }) + const title = i18n.__({ phrase: `${phraseBase}.title`, locale }, { ...replacements }) + const body = i18n.__( + { phrase: `${phraseBase}.body`, locale }, + { ...replacements, amount: amountArg }, + ) const result = await PushNotificationsService().sendFilteredNotification({ deviceTokens: user.deviceTokens, diff --git a/src/app/payments/pay-invoice-via-ibex.ts b/src/app/payments/pay-invoice-via-ibex.ts new file mode 100644 index 000000000..f7f4049c9 --- /dev/null +++ b/src/app/payments/pay-invoice-via-ibex.ts @@ -0,0 +1,64 @@ +import type { PayInvoiceV2Response200 } from "ibex-client" + +import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" +import { paymentSendStatusOrPending } from "@services/ibex/payment-status" + +import { withPaymentIdempotency } from "./idempotency" + +/** + * Pay a BOLT11 invoice from a Flash wallet over the IBEX-custodial rail. + * + * This is the rail Flash actually runs on. `lnInvoicePaymentSend` pays IBEX + * inline from its resolver (src/graphql/public/root/mutation/ln-invoice-payment-send.ts); + * this is that `withPaymentIdempotency({ authorize, execute })` block lifted + * into @app so other use cases (gift cards, ENG-580) pay the same way with the + * same idempotency and send-guard semantics. `payInvoiceByWalletId` in + * ./send-lightning.ts is the upstream-galoy LND rail: dormant on this + * deployment (no LND nodes), kept for upstream parity only. + * + * On this rail the IBEX account id IS the Flash wallet id. + * + * `onResponse` hands the raw IBEX 200 to the caller before it is reduced to a + * `PaymentSendStatus`. It exists to keep `transaction.id`, the one handle the + * gift-card reconcile worker has to re-query an in-flight send + * (`Ibex.getTransactionDetails`). It runs only when IBEX was actually called — + * never on an idempotent replay — and it is awaited inside the lock, so it must + * be cheap and must not throw: a throw here would turn a payment that has + * already moved money into an uncached error a same-key retry could re-pay. + */ +export type PayLnInvoiceViaIbexArgs = { + senderWalletId: WalletId + senderAccount: Account + paymentRequest: string + idempotencyKey: string + requestFingerprint: string + authorize: SendGuardHook + onResponse?: (response: PayInvoiceV2Response200) => void +} + +export const payLnInvoiceViaIbex = async ({ + senderWalletId, + paymentRequest, + idempotencyKey, + requestFingerprint, + authorize, + onResponse, +}: PayLnInvoiceViaIbexArgs): Promise => + withPaymentIdempotency({ + idempotencyKey, + senderWalletId, + requestFingerprint, + authorize, + execute: async (): Promise => { + const response = await Ibex.payInvoice({ + invoice: paymentRequest as Bolt11, + accountId: senderWalletId, + }) + + if (response instanceof IbexError) return response + + onResponse?.(response) + return paymentSendStatusOrPending(response) + }, + }) diff --git a/src/config/locales/en.json b/src/config/locales/en.json index f625da861..bf139f998 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -134,6 +134,12 @@ "title": "Top-up on its way", "body": "We're crediting {{amount}} to your wallet now. It should appear shortly." } + }, + "giftCard": { + "fulfilled": { + "title": "Your {{brand}} gift card is ready", + "body": "Your {{amount}} {{brand}} gift card is ready. Open Flash to view your code." + } } } } diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 8253f7ccc..896df1783 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -130,6 +130,12 @@ "title": "Tu recarga está en camino", "body": "Estamos acreditando {{amount}} a tu billetera. Debería aparecer en breve." } + }, + "giftCard": { + "fulfilled": { + "title": "Tu tarjeta de regalo {{brand}} está lista", + "body": "Tu tarjeta de regalo {{brand}} de {{amount}} está lista. Abre Flash para ver tu código." + } } } } diff --git a/src/config/schema.ts b/src/config/schema.ts index 4c7ffa12b..70bcbd002 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1006,6 +1006,154 @@ export const configSchema = { // stay hidden unless explicitly enabled per the v0.6.0 flag ramp. default: { enabled: false }, }, + giftCards: { + type: "object", + properties: { + enabled: { type: "boolean", default: false }, + // Open-loop (Visa/Mastercard) products are hidden and unpurchasable unless this is on. + allowOpenLoop: { type: "boolean", default: false }, + // Flash markup on face value, basis points. 0 = revenue is vendor reward/commission only. + feeBps: { type: "integer", default: 0 }, + // 32-byte key (hex or base64) for AES-256-GCM encryption of claim codes at rest. + // Real key injected via config overrides; never commit a real key. + claimDataEncryptionKey: { type: "string", default: "" }, + // Vendor invoice may exceed our quote by at most this many bps before we refuse to pay. + quoteToleranceBps: { type: "integer", default: 100 }, + routing: { + type: "object", + properties: { + default: { + type: "string", + enum: ["bitcoinCompany", "bitrefill"], + default: "bitcoinCompany", + }, + // ISO alpha-2 country → provider id. Unlisted countries use `default`. + byCountry: { + type: "object", + additionalProperties: { + type: "string", + enum: ["bitcoinCompany", "bitrefill"], + }, + default: {}, + }, + }, + additionalProperties: false, + default: {}, + }, + providers: { + type: "object", + properties: { + bitcoinCompany: { + type: "object", + properties: { + enabled: { type: "boolean", default: false }, + baseUrl: { + type: "string", + default: "https://api.dev.thebitcoincompany.com", + }, + email: { type: "string", default: "" }, + password: { type: "string", default: "" }, + referralCode: { type: "string", default: "" }, + timeoutMs: { type: "integer", default: 10000 }, + }, + additionalProperties: false, + default: {}, + }, + bitrefill: { + type: "object", + properties: { + enabled: { type: "boolean", default: false }, + baseUrl: { type: "string", default: "" }, + apiId: { type: "string", default: "" }, + apiSecret: { type: "string", default: "" }, + webhookSecret: { type: "string", default: "" }, + timeoutMs: { type: "integer", default: 10000 }, + }, + additionalProperties: false, + default: {}, + }, + }, + additionalProperties: false, + default: {}, + }, + catalog: { + type: "object", + properties: { + syncIntervalSeconds: { type: "integer", default: 21600 }, + ttlSeconds: { type: "integer", default: 21600 }, + // Serve a stale catalog up to this age; beyond it the catalog is "unavailable". + staleAfterSeconds: { type: "integer", default: 86400 }, + }, + additionalProperties: false, + default: {}, + }, + limits: { + type: "object", + properties: { + // Same rollout discipline as the ENG-573 send guard. + mode: { + type: "string", + enum: ["off", "log-only", "enforce"], + default: "log-only", + }, + minAccountLevel: { type: "integer", default: 1 }, + minAccountAgeHours: { type: "integer", default: 24 }, + maxOrdersPerHour: { type: "integer", default: 5 }, + // Vendor-imposed caps (TBC, FinCEN prepaid-access exemption). Always enforced. + vendorDailyCapCents: { type: "integer", default: 1000000 }, + vendorOpenLoopCardCapCents: { type: "integer", default: 100000 }, + vendorClosedLoopCardCapCents: { type: "integer", default: 200000 }, + perLevel: { + type: "object", + properties: { + level0: { + type: "object", + properties: { + perCardCents: { type: "integer", default: 0 }, + dailyCents: { type: "integer", default: 0 }, + }, + additionalProperties: false, + default: {}, + }, + level1: { + type: "object", + properties: { + perCardCents: { type: "integer", default: 20000 }, + dailyCents: { type: "integer", default: 50000 }, + }, + additionalProperties: false, + default: {}, + }, + level2: { + type: "object", + properties: { + perCardCents: { type: "integer", default: 50000 }, + dailyCents: { type: "integer", default: 200000 }, + }, + additionalProperties: false, + default: {}, + }, + level3: { + type: "object", + properties: { + perCardCents: { type: "integer", default: 100000 }, + dailyCents: { type: "integer", default: 500000 }, + }, + additionalProperties: false, + default: {}, + }, + }, + additionalProperties: false, + default: {}, + }, + }, + additionalProperties: false, + default: {}, + }, + }, + additionalProperties: false, + default: {}, + }, fygaro: { type: "object", properties: { diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index f482d78e3..455c54165 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -102,6 +102,61 @@ type FygaroWebhookConfig = { timestampSkewMs: number } +type GiftCardsLevelLimit = { + perCardCents: number + dailyCents: number +} + +type GiftCardsConfig = { + enabled: boolean + allowOpenLoop: boolean + feeBps: number + claimDataEncryptionKey: string + quoteToleranceBps: number + routing: { + default: GiftCardProviderId + byCountry: Record + } + providers: { + bitcoinCompany: { + enabled: boolean + baseUrl: string + email: string + password: string + referralCode: string + timeoutMs: number + } + bitrefill: { + enabled: boolean + baseUrl: string + apiId: string + apiSecret: string + webhookSecret: string + timeoutMs: number + } + } + catalog: { + syncIntervalSeconds: number + ttlSeconds: number + staleAfterSeconds: number + } + limits: { + mode: GiftCardLimitsMode + minAccountLevel: number + minAccountAgeHours: number + maxOrdersPerHour: number + vendorDailyCapCents: number + vendorOpenLoopCardCapCents: number + vendorClosedLoopCardCapCents: number + perLevel: { + level0: GiftCardsLevelLimit + level1: GiftCardsLevelLimit + level2: GiftCardsLevelLimit + level3: GiftCardsLevelLimit + } + } +} + type FygaroConfig = { enabled: boolean webhook: FygaroWebhookConfig @@ -299,6 +354,7 @@ type YamlSchema = { enabled: boolean } fygaro: FygaroConfig + giftCards: GiftCardsConfig sendgrid: SendGridConfig frappe: FrappeConfig fcmTopics: { diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 6144b0bca..cd0d63f40 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -318,6 +318,12 @@ export const getFygaroCheckoutCreateAttemptLimits = () => ({ blockDuration: toSeconds(300), // 5 minutes }) +export const getGiftCardPurchaseAttemptLimits = () => ({ + points: 10, + duration: toSeconds(60), // 1 minute + blockDuration: toSeconds(300), // 5 minutes +}) + /** * The card top-up allowance READ, per account. * @@ -630,4 +636,6 @@ export const BridgeConfig = yamlConfig.bridge as BridgeConfig export const FygaroConfig = yamlConfig.fygaro as FygaroConfig +export const GiftCardsConfig = yamlConfig.giftCards as GiftCardsConfig + export const FrappeConfig = yamlConfig.frappe as FrappeConfig diff --git a/src/domain/gift-cards/errors.ts b/src/domain/gift-cards/errors.ts new file mode 100644 index 000000000..b1333aac3 --- /dev/null +++ b/src/domain/gift-cards/errors.ts @@ -0,0 +1,87 @@ +import { DomainError, ErrorLevel } from "@domain/shared" + +/** + * Gift card errors. All names carry the `GiftCard` prefix because + * `ApplicationErrors` spreads every error module into one namespace and + * `error-map.ts` switches on the constructor name — a collision there is a + * silent mis-mapping, not a compile error. + */ +export class GiftCardError extends DomainError { + readonly level: ErrorLevel = ErrorLevel.Warn +} + +/** Feature master gate is off, or the provider for this country is disabled. */ +export class GiftCardsDisabledError extends GiftCardError { + constructor(message: string = "Gift cards are currently unavailable") { + super(message) + } +} + +/** No enabled provider serves the account's country. Deterministic until config changes. */ +export class GiftCardProviderUnavailableError extends GiftCardError { + constructor(message: string = "Gift cards are not available in your region yet") { + super(message) + } +} + +export class GiftCardProductNotFoundError extends GiftCardError {} + +export class GiftCardProductNotAvailableInCountryError extends GiftCardError {} + +/** Value is not one of the fixed denominations, or is outside min..max. */ +export class GiftCardInvalidValueError extends GiftCardError {} + +/** A Flash-imposed or vendor-imposed cap would be exceeded (enforce mode only). */ +export class GiftCardLimitExceededError extends GiftCardError {} + +/** + * The account level has no gift card allowance. Deliberately distinct from + * "unavailable": permanent until the account upgrades, so the client can route + * to the upgrade flow instead of asking the user to retry. + */ +export class GiftCardLevelNotEligibleError extends GiftCardError { + constructor(message: string = "Gift cards aren't available on your account level yet") { + super(message) + } +} + +/** The vendor invoice amount drifted beyond tolerance from the quote we showed. No payment was made. */ +export class GiftCardQuoteMismatchError extends GiftCardError {} + +export class GiftCardOrderNotFoundError extends GiftCardError {} + +/** A state transition was attempted from a state that does not allow it. */ +export class GiftCardOrderStateError extends GiftCardError {} + +/** Vendor returned a definitive rejection when creating the order. No payment was made. */ +export class GiftCardVendorRejectedOrderError extends GiftCardError {} + +/** Vendor unreachable, 5xx, or returned a shape we refuse to interpret. */ +export class GiftCardVendorUnavailableError extends GiftCardError { + readonly level: ErrorLevel = ErrorLevel.Critical + constructor(message: string = "Gift card provider is temporarily unavailable") { + super(message) + } +} + +/** Catalog cache is missing or older than the hard staleness limit. */ +export class GiftCardCatalogUnavailableError extends GiftCardError { + constructor(message: string = "Gift card catalog is temporarily unavailable") { + super(message) + } +} + +/** Claim-data encryption/decryption failed (bad key, corrupt ciphertext). Never expose details. */ +export class GiftCardClaimCryptoError extends GiftCardError { + readonly level: ErrorLevel = ErrorLevel.Critical + constructor(message: string = "Could not read gift card details") { + super(message) + } +} + +/** The idempotency key was reused with different purchase parameters. */ +export class GiftCardIdempotencyKeyReuseError extends GiftCardError {} + +export class UnknownGiftCardError extends GiftCardError { + readonly level: ErrorLevel = ErrorLevel.Critical +} diff --git a/src/domain/gift-cards/index.ts b/src/domain/gift-cards/index.ts new file mode 100644 index 000000000..871c2b2e5 --- /dev/null +++ b/src/domain/gift-cards/index.ts @@ -0,0 +1,56 @@ +export * from "./errors" +export * from "./primitives" + +/** + * Order lifecycle. See the design doc §5. + * + * CREATED → INVOICE_ISSUED → (PAYMENT_PENDING →) PAID → FULFILLED + * + * Terminal: FULFILLED, FAILED, PAYMENT_FAILED, EXPIRED, REFUND_REQUIRED. + * REFUND_REQUIRED is the only state meaning "money left Flash and no card arrived". + */ +export const GiftCardOrderStatus = { + Created: "CREATED", + InvoiceIssued: "INVOICE_ISSUED", + PaymentPending: "PAYMENT_PENDING", + Paid: "PAID", + Fulfilled: "FULFILLED", + Failed: "FAILED", + PaymentFailed: "PAYMENT_FAILED", + Expired: "EXPIRED", + RefundRequired: "REFUND_REQUIRED", +} as const + +export const GIFT_CARD_TERMINAL_STATUSES: readonly GiftCardOrderStatus[] = [ + GiftCardOrderStatus.Fulfilled, + GiftCardOrderStatus.Failed, + GiftCardOrderStatus.PaymentFailed, + GiftCardOrderStatus.Expired, + GiftCardOrderStatus.RefundRequired, +] + +/** Allowed transitions. Anything not listed is a `GiftCardOrderStateError`. */ +export const GIFT_CARD_TRANSITIONS: Readonly< + Record +> = { + CREATED: ["INVOICE_ISSUED", "FAILED", "EXPIRED"], + INVOICE_ISSUED: ["PAYMENT_PENDING", "PAID", "PAYMENT_FAILED", "EXPIRED", "FAILED"], + PAYMENT_PENDING: ["PAID", "PAYMENT_FAILED"], + PAID: ["FULFILLED", "REFUND_REQUIRED"], + FULFILLED: [], + FAILED: [], + PAYMENT_FAILED: [], + EXPIRED: [], + REFUND_REQUIRED: [], +} + +export const isGiftCardTerminalStatus = (status: GiftCardOrderStatus): boolean => + GIFT_CARD_TERMINAL_STATUSES.includes(status) + +export const canTransitionGiftCardOrder = ( + from: GiftCardOrderStatus, + to: GiftCardOrderStatus, +): boolean => GIFT_CARD_TRANSITIONS[from].includes(to) + +/** Vendor invoice may exceed our quote by at most this much before we refuse to pay. */ +export const GIFT_CARD_QUOTE_TOLERANCE_BPS = 100 diff --git a/src/domain/gift-cards/index.types.d.ts b/src/domain/gift-cards/index.types.d.ts new file mode 100644 index 000000000..39f465935 --- /dev/null +++ b/src/domain/gift-cards/index.types.d.ts @@ -0,0 +1,149 @@ +// Gift card domain types. Ambient (no exports) to match the rest of `src/domain`. + +type GiftCardProviderId = "bitcoinCompany" | "bitrefill" + +/** Flash-scoped product id: `:`. */ +type GiftCardProductId = string & { readonly brand: unique symbol } +type GiftCardOrderId = string & { readonly brand: unique symbol } +type GiftCardProviderOrderId = string & { readonly brand: unique symbol } + +type GiftCardDenominationType = "fixed" | "variable" + +/** + * Normalised catalog row. Money is in minor units of `currency` (cents for USD). + * Vendor-specific fields never leak past the adapter. + */ +type GiftCardProduct = { + readonly id: GiftCardProductId + readonly providerId: GiftCardProviderId + readonly providerProductId: string + readonly name: string + readonly brand: string + readonly countryCode: string // ISO 3166-1 alpha-2, upper case + readonly currency: string // ISO 4217 + readonly denominationType: GiftCardDenominationType + readonly denominations: readonly number[] // minor units; empty for variable + readonly minValue: number | null // minor units; variable only + readonly maxValue: number | null // minor units; variable only + readonly isOpenLoop: boolean + readonly categories: readonly string[] + readonly logoUrl: string | null + readonly termsUrl: string | null + readonly rewardBps: number // vendor reward/commission on face value, basis points + readonly inStock: boolean +} + +type GiftCardQuote = { + readonly productId: GiftCardProductId + readonly valueMinor: number + readonly currency: string + readonly quantity: number + readonly fiatCostMinor: number // what the vendor charges, minor units of `currency` + readonly satsCost: Satoshis + readonly rewardSats: Satoshis + readonly bitcoinPriceMinor: number | null // vendor's BTC price in minor units, informational + readonly expiresAt: Date +} + +type GiftCardClaimCode = { readonly label: string | null; readonly value: string } + +type GiftCardBarcode = { readonly chars: string; readonly type: string } + +/** Bearer data. Encrypted at rest; never logged, traced, or placed in ops events. */ +type GiftCardClaim = { + readonly codes: readonly GiftCardClaimCode[] + readonly claimLink: string | null + readonly barcode: GiftCardBarcode | null +} + +type GiftCardProviderOrder = { + readonly providerOrderId: GiftCardProviderOrderId + readonly paymentRequest: string // BOLT11 + readonly amountSats: Satoshis + readonly expiresAt: Date +} + +type GiftCardProviderOrderStatus = + | { readonly kind: "awaitingPayment" } + | { readonly kind: "paidPendingFulfillment" } + | { readonly kind: "fulfilled"; readonly claim: GiftCardClaim } + | { readonly kind: "failed"; readonly reason: string } + | { readonly kind: "refunded"; readonly reason: string } + +type GiftCardProviderOrderRef = { + readonly providerOrderId: GiftCardProviderOrderId + readonly paymentRequest: string | null +} + +/** The port every vendor adapter implements. Pure vendor I/O; no Flash policy. */ +interface IGiftCardProvider { + readonly id: GiftCardProviderId + /** Full catalog across all countries. Called by the sync job only, never on a request path. */ + listProducts(): Promise + quote(args: { + product: GiftCardProduct + valueMinor: number + quantity: number + }): Promise + createOrder(args: { + product: GiftCardProduct + valueMinor: number + quantity: number + /** Our order id; passed to the vendor as a label/reference where supported. */ + reference: GiftCardOrderId + }): Promise + getOrder( + ref: GiftCardProviderOrderRef, + ): Promise +} + +type GiftCardOrderStatus = + (typeof import("./index").GiftCardOrderStatus)[keyof typeof import("./index").GiftCardOrderStatus] + +type GiftCardOrderStatusHistoryEntry = { + readonly status: GiftCardOrderStatus + readonly at: Date + readonly reason: string | null +} + +type GiftCardProductSnapshot = { + readonly name: string + readonly brand: string + readonly countryCode: string + readonly currency: string + readonly isOpenLoop: boolean + readonly logoUrl: string | null +} + +type GiftCardOrder = { + readonly id: GiftCardOrderId + readonly accountId: AccountId + readonly walletId: WalletId + readonly walletCurrency: WalletCurrency + readonly providerId: GiftCardProviderId + readonly providerProductId: string + readonly providerOrderId: GiftCardProviderOrderId | null + readonly productSnapshot: GiftCardProductSnapshot + readonly valueMinor: number + readonly currency: string + readonly quantity: number + readonly quoteSats: Satoshis + readonly invoiceSats: Satoshis | null + readonly paidSats: Satoshis | null + readonly paymentRequest: string | null + readonly paymentHash: string | null + /** IBEX transaction id from the pay response (`transaction.id`); the reconcile worker re-queries it. */ + readonly providerPaymentRef: string | null + readonly idempotencyKey: string + readonly status: GiftCardOrderStatus + readonly statusHistory: readonly GiftCardOrderStatusHistoryEntry[] + readonly claimCiphertext: string | null + readonly claimKeyId: string | null + readonly fulfilledAt: Date | null + readonly failureReason: string | null + readonly expiresAt: Date + readonly createdAt: Date + readonly updatedAt: Date +} + +type GiftCardLimitsMode = "off" | "log-only" | "enforce" diff --git a/src/domain/gift-cards/primitives.ts b/src/domain/gift-cards/primitives.ts new file mode 100644 index 000000000..dc44d770f --- /dev/null +++ b/src/domain/gift-cards/primitives.ts @@ -0,0 +1,93 @@ +import { GiftCardInvalidValueError, GiftCardProductNotFoundError } from "./errors" + +export const GIFT_CARD_PROVIDER_IDS: readonly GiftCardProviderId[] = [ + "bitcoinCompany", + "bitrefill", +] + +export const isGiftCardProviderId = (value: string): value is GiftCardProviderId => + (GIFT_CARD_PROVIDER_IDS as readonly string[]).includes(value) + +export const toGiftCardOrderId = (id: string): GiftCardOrderId => id as GiftCardOrderId + +export const toGiftCardProviderOrderId = (id: string): GiftCardProviderOrderId => + id as GiftCardProviderOrderId + +export const buildGiftCardProductId = ( + providerId: GiftCardProviderId, + providerProductId: string, +): GiftCardProductId => `${providerId}:${providerProductId}` as GiftCardProductId + +/** Parses `:`. Vendor ids may themselves contain ":". */ +export const parseGiftCardProductId = ( + raw: string, +): + | { providerId: GiftCardProviderId; providerProductId: string } + | GiftCardProductNotFoundError => { + const idx = raw.indexOf(":") + if (idx <= 0 || idx === raw.length - 1) { + return new GiftCardProductNotFoundError(`Malformed gift card product id`) + } + const providerId = raw.slice(0, idx) + if (!isGiftCardProviderId(providerId)) { + return new GiftCardProductNotFoundError(`Unknown gift card provider`) + } + return { providerId, providerProductId: raw.slice(idx + 1) } +} + +export const normalizeCountryCode = (raw: string): string => raw.trim().toUpperCase() + +/** + * Opaque catalog cursor: base64 of the product id. One codec, used by both the + * list use case (`endCursor` / `after`) and the GraphQL connection (per-edge + * cursors), so a cursor copied off any edge resumes in the right place. + */ +export const encodeGiftCardProductCursor = (productId: GiftCardProductId): string => + Buffer.from(productId, "utf8").toString("base64") + +export const decodeGiftCardProductCursor = (cursor: string): string => + Buffer.from(cursor, "base64").toString("utf8") + +/** + * Validates a requested face value (minor units) against the product's + * denomination rules. Integers only; fixed products must match exactly; + * variable products must sit inside [min, max]. + */ +export const checkedGiftCardValue = ( + product: Pick< + GiftCardProduct, + "denominationType" | "denominations" | "minValue" | "maxValue" + >, + valueMinor: number, +): number | GiftCardInvalidValueError => { + if (!Number.isSafeInteger(valueMinor) || valueMinor <= 0) { + return new GiftCardInvalidValueError( + "Gift card value must be a positive whole amount", + ) + } + if (product.denominationType === "fixed") { + return product.denominations.includes(valueMinor) + ? valueMinor + : new GiftCardInvalidValueError( + "Gift card value must be one of the listed denominations", + ) + } + if (product.minValue !== null && valueMinor < product.minValue) { + return new GiftCardInvalidValueError( + "Gift card value is below the minimum for this card", + ) + } + if (product.maxValue !== null && valueMinor > product.maxValue) { + return new GiftCardInvalidValueError( + "Gift card value is above the maximum for this card", + ) + } + return valueMinor +} + +export const checkedGiftCardQuantity = ( + quantity: number, +): number | GiftCardInvalidValueError => + Number.isSafeInteger(quantity) && quantity >= 1 && quantity <= 10 + ? quantity + : new GiftCardInvalidValueError("Quantity must be between 1 and 10") diff --git a/src/domain/rate-limit/errors.ts b/src/domain/rate-limit/errors.ts index 2e8cf1391..11056d7ae 100644 --- a/src/domain/rate-limit/errors.ts +++ b/src/domain/rate-limit/errors.ts @@ -21,6 +21,7 @@ export class OnChainAddressCreateRateLimiterExceededError extends RateLimiterExc export class InviteCreateRateLimiterExceededError extends RateLimiterExceededError {} export class InviteTargetRateLimiterExceededError extends RateLimiterExceededError {} export class FygaroCheckoutCreateRateLimiterExceededError extends RateLimiterExceededError {} +export class GiftCardPurchaseRateLimiterExceededError extends RateLimiterExceededError {} export class FygaroTopupAllowanceRateLimiterExceededError extends RateLimiterExceededError {} // ENG-573: per-account budget on payment-send *attempts* (every call to a send // mutation, including ones the send guard rejects), so a probe that walks the diff --git a/src/domain/rate-limit/index.ts b/src/domain/rate-limit/index.ts index a6c78ae3f..97249a32a 100644 --- a/src/domain/rate-limit/index.ts +++ b/src/domain/rate-limit/index.ts @@ -3,6 +3,7 @@ import { getFailedLoginAttemptPerIpLimits, getFailedLoginAttemptPerLoginIdentifierLimits, getFygaroCheckoutCreateAttemptLimits, + getGiftCardPurchaseAttemptLimits, getFygaroTopupAllowanceAttemptLimits, getInviteCreateAttemptLimits, getInviteTargetAttemptLimits, @@ -19,6 +20,7 @@ import { import { ConsentLogIpRateLimiterExceededError, FygaroCheckoutCreateRateLimiterExceededError, + GiftCardPurchaseRateLimiterExceededError, FygaroTopupAllowanceRateLimiterExceededError, InviteCreateRateLimiterExceededError, InviteTargetRateLimiterExceededError, @@ -45,6 +47,7 @@ export const RateLimitPrefix = { inviteCreate: "invite_daily", inviteTarget: "invite_target", fygaroCheckoutCreate: "fygaro_checkout_create", + giftCardPurchase: "gift_card_purchase", consentLog: "consent_log_ip", fygaroTopupAllowance: "fygaro_topup_allowance", paymentSend: "payment_send", @@ -114,6 +117,11 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = { limits: getFygaroCheckoutCreateAttemptLimits(), error: FygaroCheckoutCreateRateLimiterExceededError, }, + giftCardPurchase: { + key: RateLimitPrefix.giftCardPurchase, + limits: getFygaroCheckoutCreateAttemptLimits(), + error: GiftCardPurchaseRateLimiterExceededError, + }, // The read side of the same ERPNext dependency, and the CHEAPER one to abuse: // no amount argument, so nothing can short-circuit before the trailing-24h // list query runs. Its own key so a customer who has spent the mutation's diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index ae19fd02c..86bfdb7cf 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -1147,6 +1147,127 @@ export const mapError = (error: ApplicationError): CustomApolloError => { })` return new UnknownClientError({ message, logger: baseLogger }) + // Gift cards (ENG-574). Messages come from the domain error so the client + // sees the same wording the use case decided on. + case "GiftCardsDisabledError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARDS_DISABLED", + message, + }) + + case "GiftCardProviderUnavailableError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_PROVIDER_UNAVAILABLE", + message, + }) + + case "GiftCardProductNotFoundError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_PRODUCT_NOT_FOUND", + message, + }) + + case "GiftCardProductNotAvailableInCountryError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY", + message, + }) + + case "GiftCardInvalidValueError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_INVALID_VALUE", + message, + }) + + case "GiftCardLimitExceededError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_LIMIT_EXCEEDED", + message, + }) + + case "GiftCardLevelNotEligibleError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_LEVEL_NOT_ELIGIBLE", + message, + }) + + case "GiftCardQuoteMismatchError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_QUOTE_MISMATCH", + message, + }) + + case "GiftCardOrderNotFoundError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_ORDER_NOT_FOUND", + message, + }) + + case "GiftCardOrderStateError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_ORDER_STATE", + message, + }) + + case "GiftCardVendorRejectedOrderError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_VENDOR_REJECTED", + message, + }) + + case "GiftCardVendorUnavailableError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_VENDOR_UNAVAILABLE", + message, + }) + + case "GiftCardCatalogUnavailableError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_CATALOG_UNAVAILABLE", + message, + }) + + case "GiftCardClaimCryptoError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_CLAIM_UNAVAILABLE", + message, + }) + + case "GiftCardIdempotencyKeyReuseError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_IDEMPOTENCY_KEY_REUSE", + message, + }) + + case "GiftCardPurchaseRateLimiterExceededError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_PURCHASE_RATE_LIMITED", + message, + }) + + case "UnknownGiftCardError": + message = "Something went wrong with your gift card request" + return bridgeGqlError({ + code: "GIFT_CARD_UNKNOWN", + message, + }) + default: return assertUnreachable(errorName) } diff --git a/src/migrations/20260909120000-gift-card-orders-indexes.ts b/src/migrations/20260909120000-gift-card-orders-indexes.ts new file mode 100644 index 000000000..b0db83e00 --- /dev/null +++ b/src/migrations/20260909120000-gift-card-orders-indexes.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +/* eslint @typescript-eslint/no-var-requires: "off" */ + +/** + * Migration: indexes for the gift card orders collection (ENG-579) + * + * Background + * ---------- + * `giftcardorders` is new with the gift card provider feature. One document per + * purchase attempt; `status` moves through a fixed state machine via + * conditional updates (`src/services/mongoose/gift-card-orders.ts`). Three of + * the indexes below are correctness constraints, not just read paths: + * + * - `walletId_1_idempotencyKey_1` (unique): a client retry or two concurrent + * purchases with the same idempotency key must produce ONE order. The + * repository maps the E11000 on this index to + * `GiftCardOrderDuplicateKeyError` so the app layer returns the existing + * order instead of failing. + * - `providerId_1_providerOrderId_1` (unique, partial): one vendor order maps + * to one Flash order, so a vendor webhook or poll can never be attributed to + * two orders. `partialFilterExpression: { providerOrderId: { $type: + * "string" } }` rather than `sparse: true`: a sparse compound index still + * indexes a document when ANY of its keys is present, and `providerId` + * always is — so the second order still awaiting a vendor id + * (`providerOrderId: null`) would collide. + * - `id_1` (unique): `id` is our own UUID and the only id the API ever shows. + * + * The two remaining indexes serve the account history page + * (`accountId_1_createdAt_-1`) and the reconciler's stale-order sweep + * (`status_1_updatedAt_1`). + * + * Key specs and options here match `GiftCardOrderSchema` in + * `src/services/mongoose/schema.ts` exactly, so `syncIndexes()` on boot sees + * nothing to drop or rebuild. + * + * Rollout + * ------- + * New collection, so there is no duplicate data to repair and no ordering + * hazard beyond the usual one: the migrate Job runs before any pod on the new + * image boots (see 20260824120000-accounts-unique-npub for the full story). + * Idempotent: an existing index with the same name and spec is left alone; one + * with the same name but a different spec is dropped and recreated. + * + * Rollback (down) + * --------------- + * Drops the five indexes by name. Documents are kept — orders are financial + * records and must survive a rollback. + */ + +const COLLECTION = "giftcardorders" + +const INDEXES = [ + { keys: { id: 1 }, options: { name: "id_1", unique: true } }, + { + keys: { accountId: 1, createdAt: -1 }, + options: { name: "accountId_1_createdAt_-1" }, + }, + { + keys: { providerId: 1, providerOrderId: 1 }, + options: { + name: "providerId_1_providerOrderId_1", + unique: true, + partialFilterExpression: { providerOrderId: { $type: "string" } }, + }, + }, + { keys: { status: 1, updatedAt: 1 }, options: { name: "status_1_updatedAt_1" } }, + { + keys: { walletId: 1, idempotencyKey: 1 }, + options: { name: "walletId_1_idempotencyKey_1", unique: true }, + }, +] + +const sameSpec = (existing, desired) => + JSON.stringify(existing.key) === JSON.stringify(desired.keys) && + Boolean(existing.unique) === Boolean(desired.options.unique) && + JSON.stringify(existing.partialFilterExpression ?? null) === + JSON.stringify(desired.options.partialFilterExpression ?? null) + +module.exports = { + async up(db) { + const col = db.collection(COLLECTION) + + const exists = await db.listCollections({ name: COLLECTION }).toArray() + const existingIndexes = exists.length === 0 ? [] : await col.indexes() + + for (const desired of INDEXES) { + const current = existingIndexes.find((idx) => idx.name === desired.options.name) + if (current && sameSpec(current, desired)) { + console.log( + `[migration] ${COLLECTION}.${desired.options.name} already up to date.`, + ) + continue + } + if (current) { + await col.dropIndex(desired.options.name) + console.log( + `[migration] Dropped ${COLLECTION}.${desired.options.name} (spec differed).`, + ) + } + await col.createIndex(desired.keys, desired.options) + console.log(`[migration] Created ${COLLECTION}.${desired.options.name}.`) + } + }, + + async down(db) { + const col = db.collection(COLLECTION) + + const exists = await db.listCollections({ name: COLLECTION }).toArray() + if (exists.length === 0) return + + const existingIndexes = await col.indexes() + for (const desired of INDEXES) { + if (existingIndexes.some((idx) => idx.name === desired.options.name)) { + await col.dropIndex(desired.options.name) + console.log(`[migration] Dropped ${COLLECTION}.${desired.options.name}.`) + } + } + }, +} diff --git a/src/servers/cron.ts b/src/servers/cron.ts index 925a33e11..bab8012ef 100644 --- a/src/servers/cron.ts +++ b/src/servers/cron.ts @@ -1,6 +1,7 @@ -import { OnChain, Lightning, Wallets, Payments, Swap, Accounts } from "@app" +import { OnChain, Lightning, Wallets, Payments, Swap, Accounts, GiftCards } from "@app" +import { syncGiftCardCatalogs } from "@app/gift-cards/sync-catalog" -import { BridgeConfig, getCronConfig, TWO_MONTHS_IN_MS } from "@config" +import { BridgeConfig, GiftCardsConfig, getCronConfig, TWO_MONTHS_IN_MS } from "@config" import { ErrorLevel } from "@domain/shared" import { OperationInterruptedError } from "@domain/errors" @@ -96,6 +97,30 @@ const checkFygaroFloatJob = async () => { await checkFygaroTreasuryFloat() } +// Gift card fulfilment safety net (ENG-581): expires stale invoices, re-reads +// pending payments, polls the vendor for PAID orders and escalates to +// REFUND_REQUIRED after 24h. The trigger server runs the same function every +// 30s under the same Redis lock; this pass catches what that misses. Self-guards +// on GiftCardsConfig.enabled and never throws for a single bad order. +const reconcileGiftCardOrdersJob = async () => { + await GiftCards.reconcileGiftCardOrdersJob() +} + +// Gift card catalog refresh (ENG-578). Pulls each enabled vendor's catalog into +// the Redis read model the storefront serves from. The cron ticks far more often +// than the catalog needs refreshing, so the run honours +// giftCards.catalog.syncIntervalSeconds via a Redis marker and a second replica +// is held off by a distributed lock. Self-guards on GiftCardsConfig.enabled; +// syncGiftCardCatalogs never throws and one vendor failing does not stop the rest. +const syncGiftCardCatalogsJob = async () => { + if (!GiftCardsConfig.enabled) return + + const summaries = await syncGiftCardCatalogs({ + minIntervalSeconds: GiftCardsConfig.catalog.syncIntervalSeconds, + }) + logger.info({ summaries }, "gift card catalog sync finished") +} + // ID-verification evidence retention (docs/id-verification.md). Deletes // Spaces objects whose retention window has passed and stamps deleted_at on // the ERPNext evidence row. Dry-run by default (EVIDENCE_RETENTION_DRY_RUN); @@ -145,6 +170,8 @@ const main = async () => { reconcileBridgeDepositsJob, reconcileBridgeWithdrawalsJob, checkFygaroFloatJob, + syncGiftCardCatalogsJob, + reconcileGiftCardOrdersJob, evidenceRetentionJob, deleteExpiredPaymentFlows, deleteExpiredInvoices, diff --git a/src/servers/trigger.ts b/src/servers/trigger.ts index 90887b70e..d81370435 100644 --- a/src/servers/trigger.ts +++ b/src/servers/trigger.ts @@ -27,6 +27,7 @@ import { Wallets as WalletWithSpans, } from "@app" import { uploadBackup } from "@app/admin/backup" +import { startGiftCardReconcileInterval } from "@app/gift-cards/reconcile-orders" import { lnd1LoopConfig, lnd2LoopConfig } from "@app/swap/get-active-loopd" import * as Wallets from "@app/wallets" @@ -506,6 +507,11 @@ const main = () => { // activateLndHealthCheck() publishCurrentPrice() + // Gift card fulfilment worker (ENG-581): every 30s, under a Redis lock shared + // with the cron pass, so a PAID order reaches FULFILLED in seconds. No-op + // (returns null) while GiftCardsConfig.enabled is false. + startGiftCardReconcileInterval() + if (getSwapConfig().feeAccountingEnabled) listenerSwapMonitor() console.log("trigger server ready") diff --git a/src/services/alerts/ops-events.ts b/src/services/alerts/ops-events.ts index c25dd7ef2..da36156a3 100644 --- a/src/services/alerts/ops-events.ts +++ b/src/services/alerts/ops-events.ts @@ -22,7 +22,8 @@ import { * summary embed is emitted. */ -export type OpsFlow = "verification" | "upgrade" | "cashout" | "deposit" | "transfer" +export type OpsFlow = + "verification" | "upgrade" | "cashout" | "deposit" | "transfer" | "giftcard" export type OpsStatus = "success" | "pending" | "failed" @@ -53,6 +54,7 @@ const FLOW_EMOJI: Record = { cashout: "💸", deposit: "💰", transfer: "🔁", + giftcard: "🎁", } const MAX_QUEUE = 50 diff --git a/src/services/gift-cards/bitcoin-company/client.ts b/src/services/gift-cards/bitcoin-company/client.ts new file mode 100644 index 000000000..26b6fab9f --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/client.ts @@ -0,0 +1,743 @@ +import { randomUUID } from "crypto" + +import axios, { isAxiosError } from "axios" +import { z } from "zod" + +import { GiftCardsConfig } from "@config" + +import { + GiftCardError, + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" +import { ErrorLevel } from "@domain/shared" +import { baseLogger } from "@services/logger" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +import { + BitcoinCompanyApiError, + BitcoinCompanyAuthError, + BitcoinCompanyError, + BitcoinCompanyNetworkError, + BitcoinCompanyResponseShapeError, + BitcoinCompanyUnauthorizedError, +} from "./errors" +import { + CachedTokens, + VendorAuthTokens, + VendorProduct, + VendorPurchase, + VendorPurchasedProduct, + VendorQuote, + authTokensSchema, + cachedTokensSchema, + catalogPageSchema, + envelopeSchema, + purchaseResultSchema, + purchasedProductSchema, + quoteResultSchema, + vendorProductSchema, +} from "./schemas" + +/** + * Bitcoin Company HTTP client: transport, auth token lifecycle, retries, and + * the translation of every failure into a domain `GiftCard*` error. Nothing + * vendor-shaped escapes this file except the validated `Vendor*` types that + * `mapping.ts` consumes. + * + * Auth model (vendor): `POST /auth/login` returns an access token (1h) and a + * refresh token (21d). `GET /auth/refresh-token` with the refresh token as the + * bearer returns a new pair. We cache the pair in Redis so every pod shares + * one session, refresh when the access token has < 5 min left, and on a 401 + * refresh once and retry once. A failed refresh falls back to a fresh login. + * A short `SET NX` lock keeps concurrent pods from racing each other through + * login/refresh; losing the lock never blocks a request, it only means a + * duplicate login, which the vendor tolerates. + * + * Redis is deliberately OFF the critical path: a read or write failure is + * logged and the request proceeds with a fresh login. + */ + +type BitcoinCompanyConfig = GiftCardsConfig["providers"]["bitcoinCompany"] + +export const BITCOIN_COMPANY_AUTH_CACHE_KEY = "giftcards:auth:bitcoinCompany" +export const BITCOIN_COMPANY_AUTH_LOCK_KEY = "giftcards:auth:bitcoinCompany:lock" + +const PROVIDER = "bitcoinCompany" + +// Vendor-stated lifetimes. Access tokens are not decoded; if the vendor +// shortens the lifetime the 401 → refresh → retry path still covers it. +const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000 +const REFRESH_TOKEN_TTL_MS = 21 * 24 * 60 * 60 * 1000 +const ACCESS_REFRESH_MARGIN_MS = 5 * 60 * 1000 + +const AUTH_LOCK_TTL_MS = 10_000 +const AUTH_LOCK_POLL_MS = 100 +const AUTH_LOCK_MAX_POLLS = 50 + +/** Retries after the first attempt, for idempotent reads only. */ +export const RETRY_MAX = 3 +const RETRY_BASE_MS = 200 +const RETRY_JITTER_MS = 100 + +export const CATALOG_PAGE_SIZE = 500 +const CATALOG_MAX_PAGES = 100 + +const LOG_BODY_PREVIEW_CHARS = 2000 + +// ============ Log redaction ============ + +const SENSITIVE_LOG_KEYS: ReadonlySet = new Set([ + "authorization", + "accesstoken", + "refreshtoken", + "token", + "password", + "claimdata", + "codes", + "claimlink", + "barcodechars", + "barcodetype", +]) + +/** Replaces token, credential, and claim values anywhere in an object tree. */ +export const redactForLog = (value: unknown, depth = 0): unknown => { + if (depth > 8) return "[TRUNCATED]" + if (Array.isArray(value)) return value.map((item) => redactForLog(item, depth + 1)) + if (value && typeof value === "object") { + const out: Record = {} + for (const [key, item] of Object.entries(value)) { + out[key] = SENSITIVE_LOG_KEYS.has(key.toLowerCase()) + ? "[REDACTED]" + : redactForLog(item, depth + 1) + } + return out + } + return value +} + +const bodyPreview = (data: unknown): string => { + try { + return JSON.stringify(redactForLog(data)).slice(0, LOG_BODY_PREVIEW_CHARS) + } catch { + return "[unserialisable]" + } +} + +const errorName = (err: unknown): string => (err instanceof Error ? err.name : "unknown") + +// ============ Token store ============ + +export type ReleaseLock = () => Promise + +export interface TokenStore { + read(): Promise + write(tokens: CachedTokens): Promise + clear(): Promise + /** A release fn when acquired, null when another holder has the lock. */ + tryLock(): Promise +} + +// Compare-and-delete so a pod never releases a lock that expired and was +// re-acquired by someone else. +const RELEASE_LOCK_SCRIPT = + 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end' + +/** + * Redis-backed store. `@services/redis` opens a connection as a module side + * effect, so it is imported on first use rather than at load: unit tests and + * anything that merely imports the provider registry never touch Redis. + */ +export const RedisTokenStore = (): TokenStore => { + const client = async () => (await import("@services/redis")).redis + + return { + read: async () => { + const raw = await (await client()).get(BITCOIN_COMPANY_AUTH_CACHE_KEY) + if (!raw) return null + let json: unknown + try { + json = JSON.parse(raw) + } catch { + return null + } + const parsed = cachedTokensSchema.safeParse(json) + return parsed.success ? parsed.data : null + }, + write: async (tokens) => { + await ( + await client() + ).set( + BITCOIN_COMPANY_AUTH_CACHE_KEY, + JSON.stringify(tokens), + "PX", + REFRESH_TOKEN_TTL_MS, + ) + }, + clear: async () => { + await (await client()).del(BITCOIN_COMPANY_AUTH_CACHE_KEY) + }, + tryLock: async () => { + const redis = await client() + const nonce = randomUUID() + const acquired = await redis.set( + BITCOIN_COMPANY_AUTH_LOCK_KEY, + nonce, + "PX", + AUTH_LOCK_TTL_MS, + "NX", + ) + if (acquired !== "OK") return null + return async () => { + await redis.eval(RELEASE_LOCK_SCRIPT, 1, BITCOIN_COMPANY_AUTH_LOCK_KEY, nonce) + } + }, + } +} + +/** Single-process store for tests and scripts. */ +export const InMemoryTokenStore = (): TokenStore => { + let tokens: CachedTokens | null = null + let locked = false + return { + read: async () => tokens, + write: async (next) => { + tokens = next + }, + clear: async () => { + tokens = null + }, + tryLock: async () => { + if (locked) return null + locked = true + return async () => { + locked = false + } + }, + } +} + +// ============ Client ============ + +type HttpMethod = "GET" | "POST" + +type RequestSpec = { + op: string + method: HttpMethod + path: string + body?: unknown + /** Send the bearer token and treat a 401 as "refresh once, retry once". */ + auth: boolean +} + +type CallArgs = RequestSpec & { + /** Retry network errors and 5xx. Only for idempotent reads. */ + retry: boolean + schema: z.ZodType +} + +type ParseArgs = { + op: string + schema: z.ZodType + /** Auth responses are never previewed in logs, redaction or not. */ + logBody: boolean +} + +export type BitcoinCompanyClientDeps = { + getConfig?: () => BitcoinCompanyConfig + tokenStore?: TokenStore + /** Epoch millis. */ + now?: () => number + sleep?: (ms: number) => Promise + /** [0, 1). Injected so tests can pin the retry jitter. */ + random?: () => number +} + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +const extractVendorError = (data: unknown): string | null => { + if (!data || typeof data !== "object") return null + const { error, message } = data as { error?: unknown; message?: unknown } + if (typeof error === "string" && error.length > 0) return error + if (typeof message === "string" && message.length > 0) return message + if (Array.isArray(message) && message.every((m) => typeof m === "string")) { + return message.join("; ") + } + return null +} + +export class BitcoinCompanyClient { + private readonly getConfig: () => BitcoinCompanyConfig + private readonly tokenStore: TokenStore + private readonly now: () => number + private readonly sleep: (ms: number) => Promise + private readonly random: () => number + + constructor(deps: BitcoinCompanyClientDeps = {}) { + // Config is read per call, not at construction, so `@config` can be mocked + // after this module loads and config reloads are picked up. + this.getConfig = deps.getConfig ?? (() => GiftCardsConfig.providers.bitcoinCompany) + this.tokenStore = deps.tokenStore ?? RedisTokenStore() + this.now = deps.now ?? Date.now + this.sleep = deps.sleep ?? defaultSleep + this.random = deps.random ?? Math.random + } + + // ============ Public operations ============ + + /** + * Full catalog, paginated. Public endpoint, no auth. A product row that + * fails validation is skipped and counted, not fatal to the sync. + */ + async listProducts(): Promise { + const products: VendorProduct[] = [] + let skipped = 0 + + for (let page = 0; page < CATALOG_MAX_PAGES; page++) { + const offset = page * CATALOG_PAGE_SIZE + const result = await this.call({ + op: "listProducts", + method: "GET", + path: `/giftcards?size=${CATALOG_PAGE_SIZE}&offset=${offset}`, + auth: false, + retry: true, + schema: catalogPageSchema, + }) + if (result instanceof Error) return result + + for (const row of result.svs) { + const parsed = vendorProductSchema.safeParse(row) + if (parsed.success) { + products.push(parsed.data) + } else { + skipped++ + } + } + + if (result.svs.length < CATALOG_PAGE_SIZE) break + } + + if (skipped > 0) { + baseLogger.warn( + { provider: PROVIDER, op: "listProducts", skipped, kept: products.length }, + "Bitcoin Company catalog rows failed validation and were skipped", + ) + } + return products + } + + async quoteCard(args: { + productId: string + /** Major units of the product currency. */ + cardValue: number + quantity: number + }): Promise { + return this.call({ + op: "quote", + method: "POST", + path: "/svs/quote-card", + body: { + productId: args.productId, + cardValue: args.cardValue, + quantity: args.quantity, + purchaseType: "Lightning", + }, + auth: true, + retry: false, + schema: quoteResultSchema, + }) + } + + /** Never retried: a retry could mint a second invoice for the same order. */ + async purchase(args: { + productId: string + /** Major units of the product currency. */ + cardValue: number + quantity: number + /** Our order id, stored by the vendor as the order label. */ + label: string + }): Promise { + return this.call({ + op: "createOrder", + method: "POST", + path: "/giftcards/purchase/bitcoin", + body: { + productId: args.productId, + cardValue: args.cardValue, + quantity: args.quantity, + useUsdBalance: false, + useSatsBalance: false, + label: args.label, + }, + auth: true, + retry: false, + schema: purchaseResultSchema, + }) + } + + /** Idempotent read despite being a POST; retried like a GET. */ + async invoiceStatus(invoice: string): Promise { + return this.call({ + op: "getOrder", + method: "POST", + path: "/giftcards/invoice-status", + body: { invoice }, + auth: true, + retry: true, + schema: purchasedProductSchema, + }) + } + + // ============ Request pipeline ============ + + private async call(args: CallArgs): Promise { + addAttributesToCurrentSpan({ "giftcard.provider": PROVIDER, "giftcard.op": args.op }) + try { + const data = await this.sendWithAuth(args) + return this.parseEnvelope({ op: args.op, schema: args.schema, logBody: true }, data) + } catch (err) { + return this.toGiftCardError(args.op, err) + } + } + + private async sendWithAuth(args: CallArgs): Promise { + if (!args.auth) return this.sendWithRetry(args, null) + + const token = await this.getAccessToken() + try { + return await this.sendWithRetry(args, token) + } catch (err) { + if (!(err instanceof BitcoinCompanyUnauthorizedError)) throw err + baseLogger.info( + { provider: PROVIDER, op: args.op }, + "Bitcoin Company rejected the access token; refreshing and retrying once", + ) + const fresh = await this.getAccessToken({ rejected: token }) + // A second 401 propagates: the session is broken, not merely stale. + return this.sendWithRetry(args, fresh) + } + } + + private async sendWithRetry( + args: CallArgs, + token: string | null, + ): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await this.sendOnce(args, token) + } catch (err) { + const retryable = + err instanceof BitcoinCompanyNetworkError || + (err instanceof BitcoinCompanyApiError && err.status >= 500) + if (!args.retry || !retryable || attempt >= RETRY_MAX) throw err + + const delayMs = + RETRY_BASE_MS * 2 ** attempt + Math.floor(this.random() * RETRY_JITTER_MS) + baseLogger.warn( + { + provider: PROVIDER, + op: args.op, + attempt: attempt + 1, + delayMs, + reason: err.name, + }, + "Bitcoin Company call failed; retrying", + ) + await this.sleep(delayMs) + } + } + } + + private async sendOnce(spec: RequestSpec, token: string | null): Promise { + const config = this.getConfig() + const headers: Record = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if (token) headers["Authorization"] = `Bearer ${token}` + + const requestConfig = { + headers, + timeout: config.timeoutMs, + // Resolve every status so 4xx/5xx are handled below, not as thrown AxiosErrors. + validateStatus: () => true, + } + const url = `${config.baseUrl.replace(/\/+$/, "")}${spec.path}` + + let response: { status: number; data: unknown } + try { + response = + spec.method === "GET" + ? await axios.get(url, requestConfig) + : await axios.post(url, spec.body ?? {}, requestConfig) + } catch (err) { + // With validateStatus above, an AxiosError without a response is a + // connection, DNS, or timeout failure. + if (isAxiosError(err) && !err.response) { + throw new BitcoinCompanyNetworkError(err.code ?? err.message) + } + throw err + } + + if (response.status === 401 && spec.auth) { + throw new BitcoinCompanyUnauthorizedError(`${spec.op}: HTTP 401`) + } + if (response.status >= 400) { + throw new BitcoinCompanyApiError( + `${spec.op}: HTTP ${response.status}`, + response.status, + extractVendorError(response.data), + ) + } + return response.data + } + + private parseEnvelope(args: ParseArgs, data: unknown): T { + let issues: { path: string; code: string }[] + + const envelope = envelopeSchema.safeParse(data) + // zod drops absent keys, so `in` distinguishes a missing result from an explicit null. + if (envelope.success && "result" in envelope.data) { + const { result, statusCode, error } = envelope.data + if (result === null) { + // Some vendor errors ride inside a 2xx envelope with a null result. + throw new BitcoinCompanyApiError( + `${args.op}: empty result`, + statusCode ?? 200, + error ?? null, + ) + } + const parsed = args.schema.safeParse(result) + if (parsed.success) return parsed.data + issues = parsed.error.issues.map((issue) => ({ + path: ["result", ...issue.path].join("."), + code: issue.code, + })) + } else if (envelope.success) { + issues = [{ path: "result", code: "missing" }] + } else { + issues = envelope.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })) + } + + baseLogger.warn( + { + provider: PROVIDER, + op: args.op, + issues: issues.slice(0, 5), + ...(args.logBody ? { body: bodyPreview(data) } : {}), + }, + "Bitcoin Company response failed validation", + ) + throw new BitcoinCompanyResponseShapeError(`${args.op}: unexpected response shape`) + } + + private toGiftCardError(op: string, err: unknown): GiftCardError { + const attributes = { "giftcard.provider": PROVIDER, "giftcard.op": op } + + if (err instanceof BitcoinCompanyApiError) { + recordExceptionInCurrentSpan({ error: err, level: ErrorLevel.Warn, attributes }) + baseLogger.warn( + { provider: PROVIDER, op, status: err.status, vendorError: err.vendorError }, + "Bitcoin Company rejected the request", + ) + if ( + op === "createOrder" && + err.status >= 400 && + err.status < 500 && + err.vendorError + ) { + return new GiftCardVendorRejectedOrderError(err.vendorError) + } + return new GiftCardVendorUnavailableError() + } + + if (err instanceof BitcoinCompanyError) { + recordExceptionInCurrentSpan({ error: err, level: err.level, attributes }) + baseLogger.error( + { provider: PROVIDER, op, reason: err.name, detail: err.message }, + "Bitcoin Company call failed", + ) + return new GiftCardVendorUnavailableError() + } + + recordExceptionInCurrentSpan({ + error: err, + level: ErrorLevel.Critical, + attributes, + fallbackMsg: "Unexpected error in Bitcoin Company client", + }) + baseLogger.error( + { provider: PROVIDER, op, reason: errorName(err) }, + "Bitcoin Company client threw unexpectedly", + ) + return new GiftCardVendorUnavailableError() + } + + // ============ Auth ============ + + private async getAccessToken(opts: { rejected?: string | null } = {}): Promise { + const cached = await this.readTokens() + if (cached && this.isUsable(cached, opts.rejected)) return cached.accessToken + + return this.withAuthLock(async () => { + // Another pod may have refreshed while we waited for the lock. + const latest = await this.readTokens() + if (latest && this.isUsable(latest, opts.rejected)) return latest.accessToken + + const refreshToken = latest?.refreshToken ?? cached?.refreshToken ?? null + if (refreshToken) { + const refreshed = await this.refresh(refreshToken) + if (refreshed) return refreshed.accessToken + } + return (await this.login()).accessToken + }) + } + + private isUsable(tokens: CachedTokens, rejected?: string | null): boolean { + if (rejected && tokens.accessToken === rejected) return false + return tokens.accessExpiresAt - this.now() > ACCESS_REFRESH_MARGIN_MS + } + + /** Not retried: a rotated refresh token makes a replay actively harmful. */ + private async refresh(refreshToken: string): Promise { + try { + const data = await this.sendOnce( + { op: "refreshToken", method: "GET", path: "/auth/refresh-token", auth: false }, + refreshToken, + ) + const pair = this.parseEnvelope( + { op: "refreshToken", schema: authTokensSchema, logBody: false }, + data, + ) + const tokens = await this.storeTokens(pair) + baseLogger.info( + { provider: PROVIDER, op: "refreshToken" }, + "Bitcoin Company access token refreshed", + ) + return tokens + } catch (err) { + baseLogger.warn( + { provider: PROVIDER, op: "refreshToken", reason: errorName(err) }, + "Bitcoin Company token refresh failed; falling back to login", + ) + return null + } + } + + /** Not retried: never replay credentials. */ + private async login(): Promise { + const { email, password } = this.getConfig() + if (!email || !password) { + throw new BitcoinCompanyAuthError("Bitcoin Company credentials are not configured") + } + + let data: unknown + try { + data = await this.sendOnce( + { + op: "login", + method: "POST", + path: "/auth/login", + body: { email, password }, + auth: false, + }, + null, + ) + } catch (err) { + throw new BitcoinCompanyAuthError(`Bitcoin Company login failed: ${errorName(err)}`) + } + + const pair = this.parseEnvelope( + { op: "login", schema: authTokensSchema, logBody: false }, + data, + ) + const tokens = await this.storeTokens(pair) + baseLogger.info( + { provider: PROVIDER, op: "login" }, + "Bitcoin Company login succeeded", + ) + return tokens + } + + private async storeTokens(pair: VendorAuthTokens): Promise { + const tokens: CachedTokens = { + accessToken: pair.accessToken, + refreshToken: pair.refreshToken, + accessExpiresAt: this.now() + ACCESS_TOKEN_TTL_MS, + } + try { + await this.tokenStore.write(tokens) + } catch (err) { + baseLogger.warn( + { provider: PROVIDER, reason: errorName(err) }, + "Bitcoin Company token cache write failed; continuing without cache", + ) + } + return tokens + } + + private async readTokens(): Promise { + try { + return await this.tokenStore.read() + } catch (err) { + baseLogger.warn( + { provider: PROVIDER, reason: errorName(err) }, + "Bitcoin Company token cache read failed; treating as empty", + ) + return null + } + } + + /** + * Best-effort cross-pod mutual exclusion. Waits up to ~5s for the holder; + * a lock-store failure or exhausted wait proceeds unlocked with a warning. + */ + private async withAuthLock(fn: () => Promise): Promise { + let release: ReleaseLock | null = null + let storeFailed = false + + for (let poll = 0; poll < AUTH_LOCK_MAX_POLLS; poll++) { + try { + release = await this.tokenStore.tryLock() + } catch (err) { + storeFailed = true + baseLogger.warn( + { provider: PROVIDER, reason: errorName(err) }, + "Bitcoin Company auth lock unavailable; proceeding without it", + ) + break + } + if (release) break + await this.sleep(AUTH_LOCK_POLL_MS) + } + + if (!release && !storeFailed) { + baseLogger.warn( + { provider: PROVIDER }, + "Bitcoin Company auth lock wait exhausted; proceeding without it", + ) + } + + try { + return await fn() + } finally { + if (release) { + try { + await release() + } catch (err) { + baseLogger.warn( + { provider: PROVIDER, reason: errorName(err) }, + "Bitcoin Company auth lock release failed; it will expire on its own", + ) + } + } + } + } +} diff --git a/src/services/gift-cards/bitcoin-company/errors.ts b/src/services/gift-cards/bitcoin-company/errors.ts new file mode 100644 index 000000000..ee350ff73 --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/errors.ts @@ -0,0 +1,38 @@ +import { DomainError, ErrorLevel } from "@domain/shared" + +/** + * Transport-level errors for the Bitcoin Company client. They never leave the + * adapter: `client.ts` catches every one of them and maps it to the domain + * `GiftCard*` error the port returns, so nothing here needs an `error-map.ts` + * entry and none of these names can collide with `ApplicationErrors`. + */ +export class BitcoinCompanyError extends DomainError { + readonly level: ErrorLevel = ErrorLevel.Warn +} + +/** Non-2xx status, or a 2xx envelope carrying `error` with a null `result`. */ +export class BitcoinCompanyApiError extends BitcoinCompanyError { + constructor( + message: string, + readonly status: number, + readonly vendorError: string | null, + ) { + super(message) + } +} + +/** 401 on an authenticated call. Tells the client to refresh and retry once. */ +export class BitcoinCompanyUnauthorizedError extends BitcoinCompanyError {} + +/** Connection refused/reset, DNS failure, or timeout: no response was received. */ +export class BitcoinCompanyNetworkError extends BitcoinCompanyError {} + +/** The response did not validate against the schema we expect. */ +export class BitcoinCompanyResponseShapeError extends BitcoinCompanyError { + readonly level: ErrorLevel = ErrorLevel.Critical +} + +/** Could not obtain an access token: credentials missing or login rejected. */ +export class BitcoinCompanyAuthError extends BitcoinCompanyError { + readonly level: ErrorLevel = ErrorLevel.Critical +} diff --git a/src/services/gift-cards/bitcoin-company/index.ts b/src/services/gift-cards/bitcoin-company/index.ts new file mode 100644 index 000000000..31670b4a3 --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/index.ts @@ -0,0 +1,165 @@ +import { GiftCardError, GiftCardVendorUnavailableError } from "@domain/gift-cards" +import { baseLogger } from "@services/logger" +import { + addAttributesToCurrentSpan, + wrapAsyncFunctionsToRunInSpan, +} from "@services/tracing" + +import { getRegisteredGiftCardProvider, registerGiftCardProvider } from "../registry" + +import { BitcoinCompanyClient, BitcoinCompanyClientDeps } from "./client" +import { + BITCOIN_COMPANY_PROVIDER_ID, + ProductMappingSkipReason, + isProductMappingSkip, + mapVendorOrderStatus, + mapVendorProduct, + mapVendorPurchase, + mapVendorQuote, + toMajorUnits, +} from "./mapping" + +export * from "./client" +export * from "./errors" +export * from "./mapping" +export * from "./schemas" + +/** + * The Bitcoin Company `IGiftCardProvider`. Pure vendor I/O: money conversion, + * status translation, and error mapping live here and in `mapping.ts`; Flash + * policy (fees, limits, open-loop gating) belongs to the app layer. + */ + +export type BitcoinCompanyProviderDeps = { + client?: BitcoinCompanyClient + /** Used only when `client` is not supplied. */ + clientDeps?: BitcoinCompanyClientDeps + now?: () => Date +} + +const spanAttributes = (op: string) => ({ + "giftcard.provider": BITCOIN_COMPANY_PROVIDER_ID, + "giftcard.op": op, +}) + +export const BitcoinCompanyProvider = ( + deps: BitcoinCompanyProviderDeps = {}, +): IGiftCardProvider => { + const client = deps.client ?? new BitcoinCompanyClient(deps.clientDeps) + const now = deps.now ?? (() => new Date()) + + const listProducts = async (): Promise => { + addAttributesToCurrentSpan(spanAttributes("listProducts")) + const rows = await client.listProducts() + if (rows instanceof Error) return rows + + const products: GiftCardProduct[] = [] + const skipped: Partial> = {} + for (const row of rows) { + const mapped = mapVendorProduct(row) + if (isProductMappingSkip(mapped)) { + skipped[mapped.skipped] = (skipped[mapped.skipped] ?? 0) + 1 + continue + } + products.push(mapped) + } + + baseLogger.info( + { + provider: BITCOIN_COMPANY_PROVIDER_ID, + op: "listProducts", + received: rows.length, + mapped: products.length, + skipped, + }, + "Bitcoin Company catalog mapped", + ) + return products + } + + const quote = async ({ + product, + valueMinor, + quantity, + }: { + product: GiftCardProduct + valueMinor: number + quantity: number + }): Promise => { + addAttributesToCurrentSpan(spanAttributes("quote")) + const vendor = await client.quoteCard({ + productId: product.providerProductId, + cardValue: toMajorUnits(valueMinor), + quantity, + }) + if (vendor instanceof Error) return vendor + return mapVendorQuote({ product, valueMinor, quantity, vendor, now: now() }) + } + + const createOrder = async ({ + product, + valueMinor, + quantity, + reference, + }: { + product: GiftCardProduct + valueMinor: number + quantity: number + reference: GiftCardOrderId + }): Promise => { + addAttributesToCurrentSpan(spanAttributes("createOrder")) + const vendor = await client.purchase({ + productId: product.providerProductId, + cardValue: toMajorUnits(valueMinor), + quantity, + label: reference, + }) + if (vendor instanceof Error) return vendor + return mapVendorPurchase(vendor, now()) + } + + const getOrder = async ( + ref: GiftCardProviderOrderRef, + ): Promise => { + addAttributesToCurrentSpan(spanAttributes("getOrder")) + // The vendor keys order status by invoice, not by order id. + if (!ref.paymentRequest) { + return new GiftCardVendorUnavailableError("status lookup requires payment request") + } + const vendor = await client.invoiceStatus(ref.paymentRequest) + if (vendor instanceof Error) return vendor + + const { status, warning } = mapVendorOrderStatus(vendor) + if (warning) { + baseLogger.warn( + { + provider: BITCOIN_COMPANY_PROVIDER_ID, + op: "getOrder", + providerOrderId: ref.providerOrderId, + vendorStatus: vendor.status, + }, + warning, + ) + } + return status + } + + return { + id: BITCOIN_COMPANY_PROVIDER_ID, + ...wrapAsyncFunctionsToRunInSpan({ + namespace: "services.gift-cards.bitcoin-company", + fns: { listProducts, quote, createOrder, getOrder }, + }), + } +} + +/** + * Idempotent: registers the provider only if the registry does not already + * hold one under this id, so re-imports and repeated calls never double up. + */ +export const registerBitcoinCompanyProvider = (): void => { + if (getRegisteredGiftCardProvider(BITCOIN_COMPANY_PROVIDER_ID)) return + registerGiftCardProvider(BitcoinCompanyProvider()) +} + +registerBitcoinCompanyProvider() diff --git a/src/services/gift-cards/bitcoin-company/mapping.ts b/src/services/gift-cards/bitcoin-company/mapping.ts new file mode 100644 index 000000000..e4935e7ad --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/mapping.ts @@ -0,0 +1,218 @@ +import { toSats } from "@domain/bitcoin" +import { + buildGiftCardProductId, + normalizeCountryCode, + toGiftCardProviderOrderId, +} from "@domain/gift-cards" + +import { + VendorClaimData, + VendorProduct, + VendorPurchase, + VendorPurchasedProduct, + VendorQuote, +} from "./schemas" + +/** + * Pure vendor → domain mapping. No I/O, no logging: functions that need to + * warn return a `warning` string and let the caller decide where it goes. + */ + +export const BITCOIN_COMPANY_PROVIDER_ID: GiftCardProviderId = "bitcoinCompany" + +export const QUOTE_TTL_MS = 60 * 1000 +export const ORDER_TTL_MS = 15 * 60 * 1000 + +/** Vendor money is in major units of the product currency; the domain is minor units. */ +export const toMinorUnits = (major: number): number => Math.round(major * 100) +export const toMajorUnits = (minor: number): number => minor / 100 + +// Two regional-indicator symbols make one flag emoji. +const TRAILING_FLAG = /\s*[\u{1F1E6}-\u{1F1FF}]{2}\s*$/u +// "(US)" / "(USA)". Deliberately narrow so "(Digital)" and similar survive. +const TRAILING_COUNTRY_PAREN = /\s*\([A-Za-z]{2,3}\)\s*$/ + +/** "Visa 🇺🇸 (US)" → "Visa". Falls back to the trimmed name if stripping would empty it. */ +export const stripBrand = (name: string): string => { + const trimmed = name.trim() + let out = trimmed + for (;;) { + const next = out.replace(TRAILING_FLAG, "").replace(TRAILING_COUNTRY_PAREN, "").trim() + if (next === out) break + out = next + } + return out.length > 0 ? out : trimmed +} + +export const mapDenominationType = (raw: string): GiftCardDenominationType | null => { + switch (raw) { + case "Fixed": + return "fixed" + case "Variable": + case "VariableNoCents": + return "variable" + default: + return null + } +} + +export type ProductMappingSkipReason = + "no-country" | "unknown-denomination-type" | "no-denominations" + +export type ProductMappingSkip = { readonly skipped: ProductMappingSkipReason } + +export const isProductMappingSkip = ( + value: GiftCardProduct | ProductMappingSkip, +): value is ProductMappingSkip => "skipped" in value + +const toMinorDenominations = (major: readonly number[]): number[] => + [ + ...new Set(major.map(toMinorUnits).filter((d) => Number.isSafeInteger(d) && d > 0)), + ].sort((a, b) => a - b) + +export const mapVendorProduct = ( + product: VendorProduct, +): GiftCardProduct | ProductMappingSkip => { + const rawCountry = product.countries.find((c) => c.trim().length > 0) + if (!rawCountry) return { skipped: "no-country" } + + const denominationType = mapDenominationType(product.denominationType) + if (!denominationType) return { skipped: "unknown-denomination-type" } + + const denominations = toMinorDenominations(product.denominations) + if (denominations.length === 0) return { skipped: "no-denominations" } + + const logoUrl = product.logo || product.panelImg || null + + return { + id: buildGiftCardProductId(BITCOIN_COMPANY_PROVIDER_ID, product.id), + providerId: BITCOIN_COMPANY_PROVIDER_ID, + providerProductId: product.id, + name: product.name.trim(), + brand: stripBrand(product.name), + countryCode: normalizeCountryCode(rawCountry), + currency: product.currency.trim().toUpperCase(), + denominationType, + denominations: denominationType === "fixed" ? denominations : [], + minValue: denominationType === "variable" ? denominations[0] : null, + maxValue: + denominationType === "variable" ? denominations[denominations.length - 1] : null, + isOpenLoop: product.isOpenLoop, + categories: product.categories ?? [], + logoUrl, + // Vendor `terms` is free text, not a URL. + termsUrl: null, + rewardBps: Math.round(product.satsBackPercentage * 100), + inStock: product.stock !== 0, + } +} + +export const mapVendorQuote = ({ + product, + valueMinor, + quantity, + vendor, + now, +}: { + product: GiftCardProduct + valueMinor: number + quantity: number + vendor: VendorQuote + now: Date +}): GiftCardQuote => ({ + productId: product.id, + valueMinor, + currency: product.currency, + quantity, + fiatCostMinor: toMinorUnits(vendor.fiatCost), + satsCost: toSats(Math.round(vendor.satsCost)), + rewardSats: toSats(Math.round(vendor.satsBack)), + bitcoinPriceMinor: toMinorUnits(vendor.bitcoinPrice), + expiresAt: new Date(now.getTime() + QUOTE_TTL_MS), +}) + +export const mapVendorPurchase = ( + vendor: VendorPurchase, + now: Date, +): GiftCardProviderOrder => ({ + providerOrderId: toGiftCardProviderOrderId(String(vendor.uuid)), + paymentRequest: vendor.invoice, + amountSats: toSats(Math.round(vendor.amount)), + expiresAt: new Date(now.getTime() + ORDER_TTL_MS), +}) + +/** Returns null when there is nothing a customer could redeem. */ +export const mapVendorClaim = ( + claimData: VendorClaimData | null | undefined, +): GiftCardClaim | null => { + if (!claimData) return null + const codes: GiftCardClaimCode[] = (claimData.codes ?? []) + .filter((c) => c.value.trim().length > 0) + .map((c) => ({ label: c.label ?? null, value: c.value })) + const claimLink = claimData.claimLink || null + if (codes.length === 0 && claimLink === null) return null + const barcode: GiftCardBarcode | null = + claimData.barcodeChars && claimData.barcodeType + ? { chars: claimData.barcodeChars, type: claimData.barcodeType } + : null + return { codes, claimLink, barcode } +} + +type VendorStatusBucket = GiftCardProviderOrderStatus["kind"] + +// Keys are lower-cased so a casing change on the vendor side does not turn a +// known status into an "unknown" one. +const VENDOR_STATUS_TABLE: Readonly> = { + unpaid: "awaitingPayment", + underpaid: "awaitingPayment", + confirming: "awaitingPayment", + pending: "paidPendingFulfillment", + senttofulfillment: "paidPendingFulfillment", + completed: "fulfilled", + sent: "fulfilled", + claimed: "fulfilled", + shipped: "fulfilled", + expired: "failed", + cancelled: "failed", + refunded: "refunded", + disputed: "refunded", + clawedback: "refunded", +} + +export const KNOWN_VENDOR_STATUSES: readonly string[] = Object.keys(VENDOR_STATUS_TABLE) + +/** + * Vendor status → port status. Never reports `fulfilled` without something the + * customer can redeem, and never guesses at an unknown status: both fall back + * to `paidPendingFulfillment` with a warning for the caller to log. + */ +export const mapVendorOrderStatus = ( + vendor: VendorPurchasedProduct, +): { status: GiftCardProviderOrderStatus; warning: string | null } => { + const vendorStatus = vendor.status.trim() + const bucket = VENDOR_STATUS_TABLE[vendorStatus.toLowerCase()] + + switch (bucket) { + case "awaitingPayment": + case "paidPendingFulfillment": + return { status: { kind: bucket }, warning: null } + case "failed": + case "refunded": + return { status: { kind: bucket, reason: vendorStatus }, warning: null } + case "fulfilled": { + const claim = mapVendorClaim(vendor.claimData) + if (!claim) { + return { + status: { kind: "paidPendingFulfillment" }, + warning: `Bitcoin Company reported "${vendorStatus}" without claim data; holding as pending`, + } + } + return { status: { kind: "fulfilled", claim }, warning: null } + } + default: + return { + status: { kind: "paidPendingFulfillment" }, + warning: `Bitcoin Company returned unknown order status "${vendorStatus}"; holding as pending`, + } + } +} diff --git a/src/services/gift-cards/bitcoin-company/schemas.ts b/src/services/gift-cards/bitcoin-company/schemas.ts new file mode 100644 index 000000000..3c4d5c133 --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/schemas.ts @@ -0,0 +1,123 @@ +import { z } from "zod" + +/** + * Zod validators for Bitcoin Company responses. Every response is validated + * before it is mapped; a shape mismatch becomes `GiftCardVendorUnavailableError` + * at the client boundary rather than a thrown TypeError deep in mapping code. + * + * `z.object` strips unknown keys, which is what we want: vendor fields we do + * not model never travel past the adapter (and never reach a log line). + */ + +/** Numbers the vendor may serialise as strings. Rejects null/empty rather than coercing to 0. */ +const numeric = z.union([ + z.number(), + z + .string() + .trim() + .regex(/^-?\d+(\.\d+)?$/) + .transform(Number), +]) + +/** + * `{ statusCode, result: T | null, error: string | null }` around every endpoint. + * `result` is validated separately by the client against the per-endpoint + * schema; keeping it `unknown` here sidesteps zod's generic-object inference + * and lets the client tell "missing result" from "wrong-shaped result". + */ +export const envelopeSchema = z.object({ + statusCode: z.number().optional(), + result: z.unknown(), + error: z.string().nullable().optional(), +}) + +export const authTokensSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), +}) +export type VendorAuthTokens = z.infer + +/** What we keep in Redis under `giftcards:auth:bitcoinCompany`. */ +export const cachedTokensSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + accessExpiresAt: z.number().int(), +}) +export type CachedTokens = z.infer + +export const vendorProductSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().nullish(), + countries: z.array(z.string()), + currency: z.string().min(1), + currencySymbol: z.string().nullish(), + denominationType: z.string(), + // Major units of `currency`. + denominations: z.array(numeric), + defaultDenoms: z.array(numeric).nullish(), + isOpenLoop: z.boolean(), + isPhysical: z.boolean().nullish(), + categories: z.array(z.string()).nullish(), + logo: z.string().nullish(), + panelImg: z.string().nullish(), + terms: z.string().nullish(), + satsBackPercentage: numeric, + stock: numeric, + productType: z.string().nullish(), + paymentTypes: z.array(z.string()).nullish(), + resellingEnabled: z.boolean().nullish(), + deliveryTypes: z.array(z.string()).nullish(), +}) +export type VendorProduct = z.infer + +/** + * One catalog page. Rows are kept as `unknown` and validated one at a time by + * the client so a single malformed product is skipped instead of failing the + * whole sync. + */ +export const catalogPageSchema = z.object({ + svs: z.array(z.unknown()), +}) + +export const quoteResultSchema = z.object({ + // Major units of the product currency. + fiatCost: numeric, + satsCost: numeric, + satsBack: numeric, + bitcoinPrice: numeric, + quoteMode: z.string().nullish(), + customerDiscountFiat: numeric.nullish(), +}) +export type VendorQuote = z.infer + +export const purchaseResultSchema = z.object({ + invoice: z.string().min(1), + address: z.string().nullish(), + amount: numeric, + orderId: z.union([z.string(), z.number()]), + uuid: z.union([z.string(), z.number()]), + satsBack: numeric.nullish(), +}) +export type VendorPurchase = z.infer + +export const claimDataSchema = z.object({ + codes: z + .array( + z.object({ + label: z.string().nullish(), + value: z.string(), + }), + ) + .nullish(), + claimLink: z.string().nullish(), + barcodeChars: z.string().nullish(), + barcodeType: z.string().nullish(), +}) +export type VendorClaimData = z.infer + +export const purchasedProductSchema = z.object({ + status: z.string(), + claimData: claimDataSchema.nullish(), +}) +export type VendorPurchasedProduct = z.infer diff --git a/src/services/gift-cards/catalog-cache.ts b/src/services/gift-cards/catalog-cache.ts new file mode 100644 index 000000000..4c35f2e48 --- /dev/null +++ b/src/services/gift-cards/catalog-cache.ts @@ -0,0 +1,243 @@ +import { GiftCardsConfig } from "@config" + +import { CacheUndefinedError } from "@domain/cache" +import { toSeconds } from "@domain/primitives" +import { + GiftCardCatalogUnavailableError, + GiftCardProductNotFoundError, + normalizeCountryCode, +} from "@domain/gift-cards" +import { baseLogger } from "@services/logger" +import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" + +// Loaded on first use rather than at import time. `@services/cache` constructs +// a Redis client as a module side effect; this module is imported by every +// gift card use-case, and eagerly importing it would drag a live Redis +// connection into each of their unit tests. Same posture as +// `@services/fygaro/checkout-intent-store`. +const cacheService = async () => (await import("@services/cache")).RedisCacheService() + +/** + * Redis-backed read model of each vendor's catalog. + * + * The sync job (`syncGiftCardCatalogs`) is the only writer; every request path + * reads. Layout: + * + * giftcards:catalog:: -> { syncedAt: ISO, products: GiftCardProduct[] } + * giftcards:catalog::countries -> string[] (upper-case ISO 3166-1 alpha-2) + * giftcards:product: -> GiftCardProduct + * + * Every key lives for `catalog.staleAfterSeconds`, NOT `catalog.ttlSeconds`. + * The two are deliberately different: a catalog older than `ttlSeconds` is + * served with `stale: true` so a vendor outage degrades to "slightly old + * prices" instead of "no gift cards"; past `staleAfterSeconds` Redis drops the + * key and the read becomes `GiftCardCatalogUnavailableError`. The countries + * index is written last so a sync that dies half-way never advertises a + * country whose catalog key was not written. + */ +export type CatalogRead = { + products: GiftCardProduct[] + syncedAt: Date + stale: boolean +} + +type StoredCatalog = { + syncedAt: string + products: GiftCardProduct[] +} + +export const giftCardCatalogKey = (providerId: GiftCardProviderId, countryCode: string) => + `giftcards:catalog:${providerId}:${countryCode}` + +export const giftCardCountriesKey = (providerId: GiftCardProviderId) => + `giftcards:catalog:${providerId}:countries` + +export const giftCardProductKey = (productId: GiftCardProductId) => + `giftcards:product:${productId}` + +const retentionSeconds = (): Seconds => + toSeconds(GiftCardsConfig.catalog.staleAfterSeconds) + +const staleAfterMs = (): number => GiftCardsConfig.catalog.ttlSeconds * 1000 + +const isStoredCatalog = (value: unknown): value is StoredCatalog => + typeof value === "object" && + value !== null && + typeof (value as StoredCatalog).syncedAt === "string" && + Array.isArray((value as StoredCatalog).products) + +const parseSyncedAt = (raw: string): Date | null => { + const date = new Date(raw) + return Number.isNaN(date.getTime()) ? null : date +} + +export const GiftCardCatalogCache = () => { + const write = async ({ + providerId, + products, + syncedAt, + }: { + providerId: GiftCardProviderId + products: GiftCardProduct[] + syncedAt: Date + }): Promise<{ countries: string[] } | GiftCardCatalogUnavailableError> => { + const cache = await cacheService() + const ttlSecs = retentionSeconds() + + const byCountry = new Map() + for (const product of products) { + const cc = normalizeCountryCode(product.countryCode) + const group = byCountry.get(cc) + if (group) group.push(product) + else byCountry.set(cc, [product]) + } + const countries = [...byCountry.keys()].sort() + + for (const cc of countries) { + const group = byCountry.get(cc) as GiftCardProduct[] + const stored: StoredCatalog = { syncedAt: syncedAt.toISOString(), products: group } + + const catalogWrite = await cache.set({ + key: giftCardCatalogKey(providerId, cc), + value: stored, + ttlSecs, + }) + if (catalogWrite instanceof Error) { + baseLogger.error( + { providerId, countryCode: cc, err: catalogWrite }, + "gift card catalog write failed", + ) + return new GiftCardCatalogUnavailableError(catalogWrite.message) + } + + const productWrites = await Promise.all( + group.map((product) => + cache.set({ + key: giftCardProductKey(product.id), + value: product, + ttlSecs, + }), + ), + ) + const failed = productWrites.find( + (res): res is CacheServiceError => res instanceof Error, + ) + if (failed) { + baseLogger.error( + { providerId, countryCode: cc, err: failed }, + "gift card product write failed", + ) + return new GiftCardCatalogUnavailableError(failed.message) + } + } + + // Last on purpose: see the module comment. + const indexWrite = await cache.set({ + key: giftCardCountriesKey(providerId), + value: countries, + ttlSecs, + }) + if (indexWrite instanceof Error) { + baseLogger.error( + { providerId, err: indexWrite }, + "gift card countries index write failed", + ) + return new GiftCardCatalogUnavailableError(indexWrite.message) + } + + return { countries } + } + + const read = async ({ + providerId, + countryCode, + }: { + providerId: GiftCardProviderId + countryCode: string + }): Promise => { + const cache = await cacheService() + const cc = normalizeCountryCode(countryCode) + + const stored = await cache.get({ key: giftCardCatalogKey(providerId, cc) }) + if (stored instanceof Error) { + if (!(stored instanceof CacheUndefinedError)) { + baseLogger.warn( + { providerId, countryCode: cc, err: stored }, + "gift card catalog read failed", + ) + } + return new GiftCardCatalogUnavailableError() + } + + if (!isStoredCatalog(stored)) { + baseLogger.error( + { providerId, countryCode: cc }, + "gift card catalog record has an unexpected shape", + ) + return new GiftCardCatalogUnavailableError() + } + + const syncedAt = parseSyncedAt(stored.syncedAt) + if (!syncedAt) { + baseLogger.error( + { providerId, countryCode: cc, syncedAt: stored.syncedAt }, + "gift card catalog record has an unparseable syncedAt", + ) + return new GiftCardCatalogUnavailableError() + } + + return { + products: stored.products, + syncedAt, + stale: Date.now() - syncedAt.getTime() > staleAfterMs(), + } + } + + const readProduct = async ( + productId: GiftCardProductId, + ): Promise< + GiftCardProduct | GiftCardProductNotFoundError | GiftCardCatalogUnavailableError + > => { + const cache = await cacheService() + + const stored = await cache.get({ + key: giftCardProductKey(productId), + }) + if (stored instanceof CacheUndefinedError) return new GiftCardProductNotFoundError() + if (stored instanceof Error) { + baseLogger.warn({ productId, err: stored }, "gift card product read failed") + return new GiftCardCatalogUnavailableError() + } + return stored + } + + const countries = async ( + providerId: GiftCardProviderId, + ): Promise => { + const cache = await cacheService() + + const stored = await cache.get({ key: giftCardCountriesKey(providerId) }) + if (stored instanceof Error) { + if (!(stored instanceof CacheUndefinedError)) { + baseLogger.warn( + { providerId, err: stored }, + "gift card countries index read failed", + ) + } + return new GiftCardCatalogUnavailableError() + } + if (!Array.isArray(stored) || !stored.every((cc) => typeof cc === "string")) { + baseLogger.error( + { providerId }, + "gift card countries index has an unexpected shape", + ) + return new GiftCardCatalogUnavailableError() + } + return stored + } + + return wrapAsyncFunctionsToRunInSpan({ + namespace: "services.gift-cards.catalog-cache", + fns: { write, read, readProduct, countries }, + }) +} diff --git a/src/services/gift-cards/claim-crypto.ts b/src/services/gift-cards/claim-crypto.ts new file mode 100644 index 000000000..9c6c038ad --- /dev/null +++ b/src/services/gift-cards/claim-crypto.ts @@ -0,0 +1,168 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto" + +import { GiftCardsConfig } from "@config" + +import { GiftCardClaimCryptoError } from "@domain/gift-cards" + +/** + * At-rest encryption for gift card claim data (codes, claim links, barcodes). + * + * AES-256-GCM. Ciphertext layout, base64-encoded: + * + * version (1 byte, 0x01) | iv (12) | authTag (16) | data + * + * `keyId` is the first 16 hex chars of sha256(rawKeyBytes) and is stored next + * to every ciphertext so a future multi-key map can pick the right key during + * rotation. Today there is exactly one key; a mismatch is reported, never + * guessed around. + * + * Config is read at call time so tests can mock `@config`. Error messages are + * fixed strings: no plaintext, no key bytes, no driver text. + */ + +const VERSION = 0x01 +const IV_LENGTH = 12 +const TAG_LENGTH = 16 +const KEY_LENGTH = 32 +const KEY_ID_LENGTH = 16 +const ALGORITHM = "aes-256-gcm" + +const HEX_KEY = /^[0-9a-fA-F]{64}$/ +const BASE64_KEY = /^[A-Za-z0-9+/]+={0,2}$/ + +const loadKey = (): Buffer | GiftCardClaimCryptoError => { + const raw = GiftCardsConfig?.claimDataEncryptionKey + if (typeof raw !== "string" || raw.trim().length === 0) { + return new GiftCardClaimCryptoError( + "Gift card claim encryption key is not configured", + ) + } + const value = raw.trim() + + if (HEX_KEY.test(value)) return Buffer.from(value, "hex") + + if (BASE64_KEY.test(value)) { + const decoded = Buffer.from(value, "base64") + if (decoded.length === KEY_LENGTH) return decoded + } + + return new GiftCardClaimCryptoError( + "Gift card claim encryption key must be 32 bytes (64 hex chars or base64)", + ) +} + +const keyIdFor = (key: Buffer): string => + createHash("sha256").update(key).digest("hex").slice(0, KEY_ID_LENGTH) + +const isClaimCode = (value: unknown): value is GiftCardClaimCode => + typeof value === "object" && + value !== null && + typeof (value as GiftCardClaimCode).value === "string" && + ((value as GiftCardClaimCode).label === null || + typeof (value as GiftCardClaimCode).label === "string") + +const isBarcode = (value: unknown): value is GiftCardBarcode => + typeof value === "object" && + value !== null && + typeof (value as GiftCardBarcode).chars === "string" && + typeof (value as GiftCardBarcode).type === "string" + +const parseClaim = (value: unknown): GiftCardClaim | null => { + if (typeof value !== "object" || value === null) return null + const candidate = value as Record + if (!Array.isArray(candidate.codes) || !candidate.codes.every(isClaimCode)) return null + if (candidate.claimLink !== null && typeof candidate.claimLink !== "string") return null + if (candidate.barcode !== null && !isBarcode(candidate.barcode)) return null + return { + codes: candidate.codes.map((code) => ({ label: code.label, value: code.value })), + claimLink: candidate.claimLink, + barcode: candidate.barcode + ? { chars: candidate.barcode.chars, type: candidate.barcode.type } + : null, + } +} + +/** Id of the key currently configured; stored on the order at fulfilment time. */ +export const giftCardClaimKeyId = (): string | GiftCardClaimCryptoError => { + const key = loadKey() + if (key instanceof Error) return key + return keyIdFor(key) +} + +export const encryptGiftCardClaim = ( + claim: GiftCardClaim, +): { ciphertext: string; keyId: string } | GiftCardClaimCryptoError => { + const key = loadKey() + if (key instanceof Error) return key + + try { + const iv = randomBytes(IV_LENGTH) + const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }) + cipher.setAAD(Buffer.from([VERSION])) + const plaintext = Buffer.from(JSON.stringify(claim), "utf8") + const data = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const authTag = cipher.getAuthTag() + const ciphertext = Buffer.concat([ + Buffer.from([VERSION]), + iv, + authTag, + data, + ]).toString("base64") + return { ciphertext, keyId: keyIdFor(key) } + } catch { + return new GiftCardClaimCryptoError("Could not encrypt gift card details") + } +} + +export const decryptGiftCardClaim = ({ + ciphertext, + keyId, +}: { + ciphertext: string + keyId: string +}): GiftCardClaim | GiftCardClaimCryptoError => { + const key = loadKey() + if (key instanceof Error) return key + + if (keyId !== keyIdFor(key)) { + return new GiftCardClaimCryptoError("key rotated") + } + + let packed: Buffer + try { + packed = Buffer.from(ciphertext, "base64") + } catch { + return new GiftCardClaimCryptoError("Gift card claim ciphertext is malformed") + } + if (packed.length < 1 + IV_LENGTH + TAG_LENGTH) { + return new GiftCardClaimCryptoError("Gift card claim ciphertext is malformed") + } + if (packed[0] !== VERSION) { + return new GiftCardClaimCryptoError("Unsupported gift card claim ciphertext version") + } + + const iv = packed.subarray(1, 1 + IV_LENGTH) + const authTag = packed.subarray(1 + IV_LENGTH, 1 + IV_LENGTH + TAG_LENGTH) + const data = packed.subarray(1 + IV_LENGTH + TAG_LENGTH) + + let plaintext: string + try { + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }) + decipher.setAAD(Buffer.from([VERSION])) + decipher.setAuthTag(authTag) + plaintext = Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8") + } catch { + return new GiftCardClaimCryptoError() + } + + let parsed: unknown + try { + parsed = JSON.parse(plaintext) + } catch { + return new GiftCardClaimCryptoError() + } + + const claim = parseClaim(parsed) + if (!claim) return new GiftCardClaimCryptoError() + return claim +} diff --git a/src/services/gift-cards/index.ts b/src/services/gift-cards/index.ts new file mode 100644 index 000000000..3d3c1eaf0 --- /dev/null +++ b/src/services/gift-cards/index.ts @@ -0,0 +1,8 @@ +// Barrel. Importing this module registers every adapter with the registry. +// Adapters expose an idempotent `register*Provider()`; calling them here keeps +// the registry free of vendor knowledge while satisfying import/no-unassigned-import. +import { registerBitcoinCompanyProvider } from "./bitcoin-company" + +registerBitcoinCompanyProvider() + +export * from "./registry" diff --git a/src/services/gift-cards/registry.ts b/src/services/gift-cards/registry.ts new file mode 100644 index 000000000..90e8d7dc6 --- /dev/null +++ b/src/services/gift-cards/registry.ts @@ -0,0 +1,64 @@ +import { GiftCardsConfig } from "@config" + +import { + GiftCardProviderUnavailableError, + normalizeCountryCode, +} from "@domain/gift-cards" + +/** + * Provider registry. Adapters call `registerGiftCardProvider` at module load + * (from `src/services/gift-cards/index.ts`), so adding a vendor is one import + * line plus config. Routing is config-driven: `routing.byCountry[cc]`, else + * `routing.default`; a provider is eligible only when `providers..enabled`. + * + * Config is read at call time, not module load, so tests can mock `@config`. + */ +const providers = new Map() + +export const registerGiftCardProvider = (provider: IGiftCardProvider): void => { + providers.set(provider.id, provider) +} + +export const getRegisteredGiftCardProvider = ( + id: GiftCardProviderId, +): IGiftCardProvider | undefined => providers.get(id) + +export const isGiftCardProviderEnabled = (id: GiftCardProviderId): boolean => + GiftCardsConfig.enabled === true && GiftCardsConfig.providers[id]?.enabled === true + +/** Registered AND enabled providers, for the catalog sync job. */ +export const enabledGiftCardProviders = (): IGiftCardProvider[] => + [...providers.values()].filter((p) => isGiftCardProviderEnabled(p.id)) + +export const resolveGiftCardProviderIdForCountry = ( + countryCode: string, +): GiftCardProviderId | GiftCardProviderUnavailableError => { + const cc = normalizeCountryCode(countryCode) + const routed = GiftCardsConfig.routing.byCountry[cc] ?? GiftCardsConfig.routing.default + if (!isGiftCardProviderEnabled(routed)) return new GiftCardProviderUnavailableError() + return routed +} + +export const getGiftCardProviderForCountry = ( + countryCode: string, +): IGiftCardProvider | GiftCardProviderUnavailableError => { + const id = resolveGiftCardProviderIdForCountry(countryCode) + if (id instanceof Error) return id + const provider = providers.get(id) + if (!provider) return new GiftCardProviderUnavailableError() + return provider +} + +/** Enabled provider by id, for orders that already know their provider. */ +export const getEnabledGiftCardProvider = ( + id: GiftCardProviderId, +): IGiftCardProvider | GiftCardProviderUnavailableError => { + if (!isGiftCardProviderEnabled(id)) return new GiftCardProviderUnavailableError() + const provider = providers.get(id) + if (!provider) return new GiftCardProviderUnavailableError() + return provider +} + +export const __resetGiftCardProvidersForTest = (): void => { + providers.clear() +} diff --git a/src/services/mongoose/gift-card-orders.mapping.ts b/src/services/mongoose/gift-card-orders.mapping.ts new file mode 100644 index 000000000..db317fa73 --- /dev/null +++ b/src/services/mongoose/gift-card-orders.mapping.ts @@ -0,0 +1,119 @@ +import { RepositoryError } from "@domain/errors" +import { + GIFT_CARD_TRANSITIONS, + GiftCardOrderStateError, + canTransitionGiftCardOrder, + toGiftCardOrderId, + toGiftCardProviderOrderId, +} from "@domain/gift-cards" + +/** + * Pure helpers for the gift card orders repository: record → domain mapping, + * transition validation, and duplicate-key attribution. No mongoose import, so + * all of it is unit-testable without a database. + */ + +/** Default name mongoose gives the `{ walletId: 1, idempotencyKey: 1 }` unique index. */ +export const GIFT_CARD_ORDER_IDEMPOTENCY_INDEX = "walletId_1_idempotencyKey_1" + +/** + * `create` collided on `{ walletId, idempotencyKey }` — a client retry or two + * concurrent purchases with the same key. Kept distinct from the generic + * `DuplicateKeyForPersistError` so the app layer can look the existing order up + * and return it instead of failing the purchase. + */ +export class GiftCardOrderDuplicateKeyError extends RepositoryError {} + +/** + * True only when an E11000 names the idempotency index. Any other duplicate + * (e.g. `providerId_1_providerOrderId_1`) is a real bug and must keep surfacing + * as `DuplicateKeyForPersistError` through `parseRepositoryError`. + */ +export const isGiftCardOrderIdempotencyDuplicate = (err: unknown): boolean => { + if (err === null || err === undefined) return false + + const keyPattern = (err as { keyPattern?: Record }).keyPattern + const code = (err as { code?: unknown }).code + if ( + (code === 11000 || code === "11000") && + keyPattern && + "walletId" in keyPattern && + "idempotencyKey" in keyPattern + ) { + return true + } + + const message = err instanceof Error ? err.message : String(err) + return ( + /E11000 duplicate key error/.test(message) && + message.includes(`index: ${GIFT_CARD_ORDER_IDEMPOTENCY_INDEX}`) + ) +} + +/** + * Every `from` must be allowed to move to `to` per `GIFT_CARD_TRANSITIONS`. + * Checked before touching Mongo so an illegal request never becomes a write. + */ +export const checkGiftCardOrderTransition = ( + from: readonly GiftCardOrderStatus[], + to: GiftCardOrderStatus, +): true | GiftCardOrderStateError => { + if (from.length === 0) { + return new GiftCardOrderStateError("transition needs at least one source status") + } + if (!(to in GIFT_CARD_TRANSITIONS)) { + return new GiftCardOrderStateError(`unknown target status ${String(to)}`) + } + for (const status of from) { + if (!(status in GIFT_CARD_TRANSITIONS)) { + return new GiftCardOrderStateError(`unknown source status ${String(status)}`) + } + if (!canTransitionGiftCardOrder(status, to)) { + return new GiftCardOrderStateError(`cannot move ${status} → ${to}`) + } + } + return true +} + +export const toDomain = (record: GiftCardOrderRecord): GiftCardOrder => ({ + id: toGiftCardOrderId(record.id), + accountId: record.accountId as AccountId, + walletId: record.walletId as WalletId, + walletCurrency: record.walletCurrency as WalletCurrency, + providerId: record.providerId as GiftCardProviderId, + providerProductId: record.providerProductId, + providerOrderId: record.providerOrderId + ? toGiftCardProviderOrderId(record.providerOrderId) + : null, + productSnapshot: { + name: record.productSnapshot.name, + brand: record.productSnapshot.brand, + countryCode: record.productSnapshot.countryCode, + currency: record.productSnapshot.currency, + isOpenLoop: Boolean(record.productSnapshot.isOpenLoop), + logoUrl: record.productSnapshot.logoUrl ?? null, + }, + valueMinor: record.valueMinor, + currency: record.currency, + quantity: record.quantity, + quoteSats: record.quoteSats as Satoshis, + invoiceSats: (record.invoiceSats ?? null) as Satoshis | null, + paidSats: (record.paidSats ?? null) as Satoshis | null, + paymentRequest: record.paymentRequest ?? null, + paymentHash: record.paymentHash ?? null, + providerPaymentRef: record.providerPaymentRef ?? null, + idempotencyKey: record.idempotencyKey, + status: record.status as GiftCardOrderStatus, + statusHistory: (record.statusHistory ?? []).map((entry) => ({ + status: entry.status as GiftCardOrderStatus, + at: entry.at, + reason: entry.reason ?? null, + })), + claimCiphertext: record.claimCiphertext ?? null, + claimKeyId: record.claimKeyId ?? null, + fulfilledAt: record.fulfilledAt ?? null, + failureReason: record.failureReason ?? null, + expiresAt: record.expiresAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, +}) diff --git a/src/services/mongoose/gift-card-orders.ts b/src/services/mongoose/gift-card-orders.ts new file mode 100644 index 000000000..c5098289a --- /dev/null +++ b/src/services/mongoose/gift-card-orders.ts @@ -0,0 +1,294 @@ +import { randomUUID } from "crypto" + +import { RepositoryError } from "@domain/errors" +import { + GiftCardOrderNotFoundError, + GiftCardOrderStateError, + GiftCardOrderStatus, +} from "@domain/gift-cards" + +import { parseRepositoryError } from "./utils" +import { GiftCardOrders } from "./schema" +import { + GiftCardOrderDuplicateKeyError, + checkGiftCardOrderTransition, + isGiftCardOrderIdempotencyDuplicate, + toDomain, +} from "./gift-card-orders.mapping" + +export { + GiftCardOrderDuplicateKeyError, + GIFT_CARD_ORDER_IDEMPOTENCY_INDEX, +} from "./gift-card-orders.mapping" + +export type NewGiftCardOrderArgs = { + accountId: AccountId + walletId: WalletId + walletCurrency: WalletCurrency + providerId: GiftCardProviderId + providerProductId: string + productSnapshot: GiftCardProductSnapshot + valueMinor: number + currency: string + quantity: number + quoteSats: Satoshis + idempotencyKey: string + expiresAt: Date +} + +export type GiftCardOrderPatch = Partial< + Pick< + GiftCardOrder, + | "providerOrderId" + | "paymentRequest" + | "invoiceSats" + | "paidSats" + | "paymentHash" + | "providerPaymentRef" + | "claimCiphertext" + | "claimKeyId" + | "fulfilledAt" + | "failureReason" + | "expiresAt" + > +> + +export type GiftCardOrderTransitionArgs = { + id: GiftCardOrderId + from: GiftCardOrderStatus[] + to: GiftCardOrderStatus + reason?: string + patch?: GiftCardOrderPatch +} + +export interface IGiftCardOrdersRepository { + create(args: NewGiftCardOrderArgs): Promise + findById( + id: GiftCardOrderId, + ): Promise + findByIdempotencyKey(args: { + walletId: WalletId + idempotencyKey: string + }): Promise + findByProviderOrderId(args: { + providerId: GiftCardProviderId + providerOrderId: GiftCardProviderOrderId + }): Promise + listByAccount(args: { + accountId: AccountId + limit: number + before?: Date + }): Promise + listByStatus(args: { + statuses: GiftCardOrderStatus[] + updatedBefore?: Date + limit: number + }): Promise + transition( + args: GiftCardOrderTransitionArgs, + ): Promise< + GiftCardOrder | GiftCardOrderStateError | GiftCardOrderNotFoundError | RepositoryError + > +} + +/** + * Persistence for gift card orders. Every status move goes through + * `transition`, which is a single conditional `findOneAndUpdate` on the current + * status — the DB is the arbiter when two workers race, and the loser gets a + * `GiftCardOrderStateError` instead of silently overwriting. + * + * Functions return errors; they never throw. + */ +export const GiftCardOrdersRepository = (): IGiftCardOrdersRepository => { + const create = async ( + args: NewGiftCardOrderArgs, + ): Promise => { + try { + const now = new Date() + const record = await GiftCardOrders.create({ + id: randomUUID(), + accountId: args.accountId, + walletId: args.walletId, + walletCurrency: args.walletCurrency, + providerId: args.providerId, + providerProductId: args.providerProductId, + providerOrderId: null, + productSnapshot: { + name: args.productSnapshot.name, + brand: args.productSnapshot.brand, + countryCode: args.productSnapshot.countryCode, + currency: args.productSnapshot.currency, + isOpenLoop: args.productSnapshot.isOpenLoop, + logoUrl: args.productSnapshot.logoUrl, + }, + valueMinor: args.valueMinor, + currency: args.currency, + quantity: args.quantity, + quoteSats: args.quoteSats, + invoiceSats: null, + paidSats: null, + paymentRequest: null, + paymentHash: null, + providerPaymentRef: null, + idempotencyKey: args.idempotencyKey, + status: GiftCardOrderStatus.Created, + statusHistory: [{ status: GiftCardOrderStatus.Created, at: now, reason: null }], + claimCiphertext: null, + claimKeyId: null, + fulfilledAt: null, + failureReason: null, + expiresAt: args.expiresAt, + createdAt: now, + updatedAt: now, + }) + return toDomain(record) + } catch (err) { + if (isGiftCardOrderIdempotencyDuplicate(err)) { + return new GiftCardOrderDuplicateKeyError( + "A gift card order with this idempotency key already exists for this wallet", + ) + } + return parseRepositoryError(err) + } + } + + const findById = async ( + id: GiftCardOrderId, + ): Promise => { + try { + const record = await GiftCardOrders.findOne({ id: { $eq: id } }) + if (!record) return new GiftCardOrderNotFoundError() + return toDomain(record) + } catch (err) { + return parseRepositoryError(err) + } + } + + const findByIdempotencyKey = async ({ + walletId, + idempotencyKey, + }: { + walletId: WalletId + idempotencyKey: string + }): Promise => { + try { + const record = await GiftCardOrders.findOne({ + walletId: { $eq: walletId }, + idempotencyKey: { $eq: idempotencyKey }, + }) + if (!record) return new GiftCardOrderNotFoundError() + return toDomain(record) + } catch (err) { + return parseRepositoryError(err) + } + } + + const findByProviderOrderId = async ({ + providerId, + providerOrderId, + }: { + providerId: GiftCardProviderId + providerOrderId: GiftCardProviderOrderId + }): Promise => { + try { + const record = await GiftCardOrders.findOne({ + providerId: { $eq: providerId }, + providerOrderId: { $eq: providerOrderId }, + }) + if (!record) return new GiftCardOrderNotFoundError() + return toDomain(record) + } catch (err) { + return parseRepositoryError(err) + } + } + + /** Newest first; `before` pages by `createdAt` (exclusive). */ + const listByAccount = async ({ + accountId, + limit, + before, + }: { + accountId: AccountId + limit: number + before?: Date + }): Promise => { + try { + const records = await GiftCardOrders.find({ + accountId: { $eq: accountId }, + ...(before ? { createdAt: { $lt: before } } : {}), + }) + .sort({ createdAt: -1 }) + .limit(limit) + return records.map(toDomain) + } catch (err) { + return parseRepositoryError(err) + } + } + + /** Oldest `updatedAt` first — the reconciler works the stalest orders first. */ + const listByStatus = async ({ + statuses, + updatedBefore, + limit, + }: { + statuses: GiftCardOrderStatus[] + updatedBefore?: Date + limit: number + }): Promise => { + try { + const records = await GiftCardOrders.find({ + status: { $in: statuses }, + ...(updatedBefore ? { updatedAt: { $lt: updatedBefore } } : {}), + }) + .sort({ updatedAt: 1 }) + .limit(limit) + return records.map(toDomain) + } catch (err) { + return parseRepositoryError(err) + } + } + + const transition = async ({ + id, + from, + to, + reason, + patch = {}, + }: GiftCardOrderTransitionArgs): Promise< + GiftCardOrder | GiftCardOrderStateError | GiftCardOrderNotFoundError | RepositoryError + > => { + const allowed = checkGiftCardOrderTransition(from, to) + if (allowed instanceof Error) return allowed + + try { + const now = new Date() + const record = await GiftCardOrders.findOneAndUpdate( + { id: { $eq: id }, status: { $in: from } }, + { + $set: { ...patch, status: to, updatedAt: now }, + $push: { statusHistory: { status: to, at: now, reason: reason ?? null } }, + }, + { new: true }, + ) + if (record) return toDomain(record) + + // No document matched the conditional update: either the order does not + // exist or it has already moved on. Read it back to tell the caller which. + const current = await GiftCardOrders.findOne({ id: { $eq: id } }) + if (!current) return new GiftCardOrderNotFoundError() + return new GiftCardOrderStateError(`cannot move ${current.status} → ${to}`) + } catch (err) { + return parseRepositoryError(err) + } + } + + return { + create, + findById, + findByIdempotencyKey, + findByProviderOrderId, + listByAccount, + listByStatus, + transition, + } +} diff --git a/src/services/mongoose/index.ts b/src/services/mongoose/index.ts index e0cb83509..9fbc567be 100644 --- a/src/services/mongoose/index.ts +++ b/src/services/mongoose/index.ts @@ -9,3 +9,4 @@ export * from "./wallet-on-chain-addresses" export * from "./wallet-onchain-pending-receive" export * from "./merchants" export * from "./cash-wallet-cutover" +export * from "./gift-card-orders" diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 766c729e3..c7edba6cc 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -7,6 +7,7 @@ import { Levels, } from "@config" import { AccountStatus, UsernameRegex } from "@domain/accounts" +import { GiftCardOrderStatus } from "@domain/gift-cards" import { WalletIdRegex, WalletType } from "@domain/wallets" import { WalletCurrency } from "@domain/shared" import mongoose from "mongoose" @@ -930,3 +931,93 @@ export const BridgeWithdrawal = mongoose.model( "BridgeWithdrawal", BridgeWithdrawalSchema, ) + +// ── Gift card orders (ENG-579) ────────────────────────────────────────────── +// One document per purchase attempt. `status` only ever moves through +// `GIFT_CARD_TRANSITIONS`, and every move is a conditional findOneAndUpdate on +// the current status (see gift-card-orders.ts), so two workers racing on the +// same order cannot both win. `claimCiphertext` is the AES-GCM-sealed bearer +// data; the toJSON transform below drops it so a stray log line or GraphQL +// spread cannot leak it. + +const GiftCardOrderStatusHistorySchema = new Schema( + { + status: { type: String, enum: Object.values(GiftCardOrderStatus), required: true }, + at: { type: Date, required: true }, + reason: { type: String, default: null }, + }, + { _id: false }, +) + +const GiftCardProductSnapshotSchema = new Schema( + { + name: { type: String, required: true }, + brand: { type: String, required: true }, + countryCode: { type: String, required: true }, + currency: { type: String, required: true }, + isOpenLoop: { type: Boolean, required: true }, + logoUrl: { type: String, default: null }, + }, + { _id: false }, +) + +export const GiftCardOrderSchema = new Schema( + { + id: { type: String, required: true }, + accountId: { type: String, required: true }, + walletId: { type: String, required: true }, + walletCurrency: { type: String, required: true }, + providerId: { type: String, required: true }, + providerProductId: { type: String, required: true }, + providerOrderId: { type: String, default: null }, + productSnapshot: { type: GiftCardProductSnapshotSchema, required: true }, + valueMinor: { type: Number, required: true }, + currency: { type: String, required: true }, + quantity: { type: Number, required: true }, + quoteSats: { type: Number, required: true }, + invoiceSats: { type: Number, default: null }, + paidSats: { type: Number, default: null }, + paymentRequest: { type: String, default: null }, + paymentHash: { type: String, default: null }, + providerPaymentRef: { type: String, default: null }, + idempotencyKey: { type: String, required: true }, + status: { type: String, enum: Object.values(GiftCardOrderStatus), required: true }, + statusHistory: { + type: [GiftCardOrderStatusHistorySchema], + required: true, + default: [], + }, + claimCiphertext: { type: String, default: null }, + claimKeyId: { type: String, default: null }, + fulfilledAt: { type: Date, default: null }, + failureReason: { type: String, default: null }, + expiresAt: { type: Date, required: true }, + createdAt: { type: Date, required: true, default: Date.now }, + updatedAt: { type: Date, required: true, default: Date.now }, + }, + { + toJSON: { + transform: (_doc, ret) => { + delete ret.claimCiphertext + return ret + }, + }, + }, +) + +GiftCardOrderSchema.index({ id: 1 }, { unique: true }) +GiftCardOrderSchema.index({ accountId: 1, createdAt: -1 }) +// `partialFilterExpression`, not `sparse`: a sparse compound index still indexes +// a document when ANY of its keys is present, and `providerId` always is — so +// the second order awaiting a vendor id (`providerOrderId: null`) would collide. +GiftCardOrderSchema.index( + { providerId: 1, providerOrderId: 1 }, + { unique: true, partialFilterExpression: { providerOrderId: { $type: "string" } } }, +) +GiftCardOrderSchema.index({ status: 1, updatedAt: 1 }) +GiftCardOrderSchema.index({ walletId: 1, idempotencyKey: 1 }, { unique: true }) + +export const GiftCardOrders = mongoose.model( + "GiftCardOrder", + GiftCardOrderSchema, +) diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 43c4e3027..6e18f1a7b 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -277,3 +277,50 @@ type WalletOnChainPendingReceiveRecord = { displayPriceCurrency: string createdAt: Date } + +// Gift card orders (ENG-579). Money is in minor units of `currency`; sats are +// plain numbers on the wire and branded as `Satoshis` by the mapper. +interface GiftCardOrderStatusHistoryRecord { + status: string + at: Date + reason?: string | null +} + +interface GiftCardProductSnapshotRecord { + name: string + brand: string + countryCode: string + currency: string + isOpenLoop: boolean + logoUrl?: string | null +} + +interface GiftCardOrderRecord { + id: string + accountId: string + walletId: string + walletCurrency: string + providerId: string + providerProductId: string + providerOrderId?: string | null + productSnapshot: GiftCardProductSnapshotRecord + valueMinor: number + currency: string + quantity: number + quoteSats: number + invoiceSats?: number | null + paidSats?: number | null + paymentRequest?: string | null + paymentHash?: string | null + providerPaymentRef?: string | null + idempotencyKey: string + status: string + statusHistory: GiftCardOrderStatusHistoryRecord[] + claimCiphertext?: string | null + claimKeyId?: string | null + fulfilledAt?: Date | null + failureReason?: string | null + expiresAt: Date + createdAt: Date + updatedAt: Date +} diff --git a/test/flash/integration/gift-cards/gift-card-orders.spec.ts b/test/flash/integration/gift-cards/gift-card-orders.spec.ts new file mode 100644 index 000000000..7d9f74d7d --- /dev/null +++ b/test/flash/integration/gift-cards/gift-card-orders.spec.ts @@ -0,0 +1,299 @@ +/** + * The gift card orders repository is index- and race-shaped, and a mocked + * model cannot report on either: the unit specs assert the query `transition` + * issues and the options the schema declares, but nothing there executes + * against a database. These run against the real collection so they can + * return "no" about the claims the code makes: + * + * - a conditional findOneAndUpdate on `status` means exactly one of two + * racing transitions from the same state wins; the other gets + * `GiftCardOrderStateError`, not a silent overwrite + * (`src/services/mongoose/gift-card-orders.ts`). + * - `{ walletId, idempotencyKey }` is unique, and the repository turns that + * E11000 into `GiftCardOrderDuplicateKeyError`. + * - `{ providerId, providerOrderId }` is a PARTIAL unique index: two orders + * still awaiting a vendor id (`providerOrderId: null`) must both insert. A + * sparse index would fail this (`schema.ts`, migration + * 20260909120000-gift-card-orders-indexes). + * - `toJSON` drops `claimCiphertext`. + * + * Lives under test/flash/integration/gift-cards because the integration jest + * config ignores test/flash/integration/services/*. + * + * Run: TEST=test/flash/integration/gift-cards yarn test:integration + */ +import { randomUUID } from "crypto" + +import { DuplicateKeyForPersistError } from "@domain/errors" +import { + GiftCardOrderNotFoundError, + GiftCardOrderStateError, + GiftCardOrderStatus, + toGiftCardProviderOrderId, +} from "@domain/gift-cards" +import { + GiftCardOrderDuplicateKeyError, + GiftCardOrdersRepository, + NewGiftCardOrderArgs, +} from "@services/mongoose" +import { GiftCardOrders } from "@services/mongoose/schema" + +const repo = GiftCardOrdersRepository() + +const newArgs = ( + overrides: Partial = {}, +): NewGiftCardOrderArgs => ({ + accountId: randomUUID() as AccountId, + walletId: randomUUID() as WalletId, + walletCurrency: "USD" as WalletCurrency, + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: null, + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234 as Satoshis, + idempotencyKey: randomUUID(), + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + ...overrides, +}) + +const unwrap = (value: T): Exclude => { + if (value instanceof Error) throw value + return value as Exclude +} + +describe("GiftCardOrdersRepository (mongo)", () => { + beforeAll(async () => { + // The integration setup boots with syncIndexes(); make the requirement + // explicit so a future setup change cannot silently turn these into + // tests of an index-less collection. + await GiftCardOrders.syncIndexes() + }) + + it("creates in CREATED and is found again by idempotency key", async () => { + const args = newArgs() + + const created = unwrap(await repo.create(args)) + expect(created.status).toBe(GiftCardOrderStatus.Created) + expect(created.statusHistory).toHaveLength(1) + expect(created.statusHistory[0].status).toBe(GiftCardOrderStatus.Created) + expect(created.providerOrderId).toBeNull() + expect(created.claimCiphertext).toBeNull() + + const found = unwrap( + await repo.findByIdempotencyKey({ + walletId: args.walletId, + idempotencyKey: args.idempotencyKey, + }), + ) + expect(found.id).toBe(created.id) + + const byId = unwrap(await repo.findById(created.id)) + expect(byId.id).toBe(created.id) + + // Same key on a different wallet is a different order. + expect( + await repo.findByIdempotencyKey({ + walletId: randomUUID() as WalletId, + idempotencyKey: args.idempotencyKey, + }), + ).toBeInstanceOf(GiftCardOrderNotFoundError) + }) + + it("rejects a second create with the same wallet + idempotency key", async () => { + const args = newArgs() + unwrap(await repo.create(args)) + + const second = await repo.create(args) + + expect(second).toBeInstanceOf(GiftCardOrderDuplicateKeyError) + }) + + it("lets exactly one of two racing transitions from CREATED win", async () => { + const created = unwrap(await repo.create(newArgs())) + const providerOrderId = toGiftCardProviderOrderId(`tbc_${randomUUID()}`) + + const [a, b] = await Promise.all([ + repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + reason: "worker a", + patch: { + providerOrderId, + paymentRequest: "lnbc-a", + invoiceSats: 41300 as Satoshis, + }, + }), + repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + reason: "worker b", + patch: { paymentRequest: "lnbc-b", invoiceSats: 41301 as Satoshis }, + }), + ]) + + const winners = [a, b].filter((r) => !(r instanceof Error)) + const losers = [a, b].filter((r) => r instanceof Error) + expect(winners).toHaveLength(1) + expect(losers).toHaveLength(1) + expect(losers[0]).toBeInstanceOf(GiftCardOrderStateError) + expect((losers[0] as Error).message).toBe( + "cannot move INVOICE_ISSUED → INVOICE_ISSUED", + ) + + // The document holds exactly one INVOICE_ISSUED history entry — the loser + // did not get to $push. + const after = unwrap(await repo.findById(created.id)) + expect(after.status).toBe(GiftCardOrderStatus.InvoiceIssued) + expect( + after.statusHistory.filter((h) => h.status === GiftCardOrderStatus.InvoiceIssued), + ).toHaveLength(1) + expect(after.statusHistory).toHaveLength(2) + expect(after.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime()) + }) + + it("reports a non-existent order as not found on transition", async () => { + const result = await repo.transition({ + id: randomUUID() as GiftCardOrderId, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.Failed, + }) + + expect(result).toBeInstanceOf(GiftCardOrderNotFoundError) + }) + + it("accepts several orders per provider with no vendor id, but one per vendor id", async () => { + // Two orders with providerOrderId: null for the same provider — a sparse + // compound index would have rejected the second one. + const first = unwrap(await repo.create(newArgs())) + const second = unwrap(await repo.create(newArgs())) + expect(first.providerOrderId).toBeNull() + expect(second.providerOrderId).toBeNull() + + const providerOrderId = toGiftCardProviderOrderId(`tbc_${randomUUID()}`) + unwrap( + await repo.transition({ + id: first.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + patch: { providerOrderId }, + }), + ) + + const found = unwrap( + await repo.findByProviderOrderId({ providerId: "bitcoinCompany", providerOrderId }), + ) + expect(found.id).toBe(first.id) + + // Same vendor id on a second order is a real bug: generic duplicate, not + // the idempotency error. + const collision = await repo.transition({ + id: second.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + patch: { providerOrderId }, + }) + expect(collision).toBeInstanceOf(DuplicateKeyForPersistError) + expect(collision).not.toBeInstanceOf(GiftCardOrderDuplicateKeyError) + }) + + it("lists an account newest first and pages by createdAt", async () => { + const accountId = randomUUID() as AccountId + const a = unwrap(await repo.create(newArgs({ accountId }))) + await new Promise((resolve) => setTimeout(resolve, 5)) + const b = unwrap(await repo.create(newArgs({ accountId }))) + await new Promise((resolve) => setTimeout(resolve, 5)) + const c = unwrap(await repo.create(newArgs({ accountId }))) + + const page1 = unwrap(await repo.listByAccount({ accountId, limit: 2 })) + expect(page1.map((o) => o.id)).toEqual([c.id, b.id]) + + const page2 = unwrap( + await repo.listByAccount({ accountId, limit: 2, before: page1[1].createdAt }), + ) + expect(page2.map((o) => o.id)).toEqual([a.id]) + }) + + it("lists by status oldest updatedAt first, bounded by updatedBefore", async () => { + const created = unwrap(await repo.create(newArgs())) + const issued = unwrap( + await repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + }), + ) + + const stale = unwrap( + await repo.listByStatus({ + statuses: [GiftCardOrderStatus.InvoiceIssued], + updatedBefore: new Date(issued.updatedAt.getTime() + 1), + limit: 1000, + }), + ) + expect(stale.map((o) => o.id)).toContain(issued.id) + for (let i = 1; i < stale.length; i++) { + expect(stale[i].updatedAt.getTime()).toBeGreaterThanOrEqual( + stale[i - 1].updatedAt.getTime(), + ) + } + + const none = unwrap( + await repo.listByStatus({ + statuses: [GiftCardOrderStatus.InvoiceIssued], + updatedBefore: new Date(0), + limit: 10, + }), + ) + expect(none).toEqual([]) + }) + + it("serialises without claimCiphertext", async () => { + const created = unwrap(await repo.create(newArgs())) + unwrap( + await repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + }), + ) + unwrap( + await repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.Paid, + }), + ) + const fulfilled = unwrap( + await repo.transition({ + id: created.id, + from: [GiftCardOrderStatus.Paid], + to: GiftCardOrderStatus.Fulfilled, + patch: { + claimCiphertext: "SEALED-CLAIM", + claimKeyId: "0123456789abcdef", + fulfilledAt: new Date(), + }, + }), + ) + // The repository hands the ciphertext to the app layer for decryption... + expect(fulfilled.claimCiphertext).toBe("SEALED-CLAIM") + + // ...but the document cannot serialise it. + const doc = await GiftCardOrders.findOne({ id: created.id }) + expect(doc).not.toBeNull() + expect(JSON.stringify(doc)).not.toContain("SEALED-CLAIM") + expect(doc?.toJSON()).not.toHaveProperty("claimCiphertext") + expect(doc?.toJSON().claimKeyId).toBe("0123456789abcdef") + }) +}) diff --git a/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts b/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts new file mode 100644 index 000000000..a3139ea53 --- /dev/null +++ b/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts @@ -0,0 +1,536 @@ +import { RepositoryError } from "@domain/errors" +import { + GiftCardLevelNotEligibleError, + GiftCardLimitExceededError, + GiftCardsDisabledError, + UnknownGiftCardError, +} from "@domain/gift-cards" + +import { + authorizeGiftCardPurchase, + GiftCardRejectionReasons, + releaseGiftCardReservation, +} from "@app/gift-cards/authorize-purchase" + +import { + ACCOUNT_ID, + DAY_MS, + makeAccount, + makeGiftCardsConfig, + makeOrder, + makeProduct, + NOW_MS, +} from "./fixtures" + +const mockListByAccount = jest.fn() +const mockNotifyOpsEvent = jest.fn() +const mockZadd = jest.fn() +const mockSet = jest.fn() +const mockZremrangebyscore = jest.fn() +const mockZrange = jest.fn() +const mockGet = jest.fn() +const mockZrem = jest.fn() +const mockDel = jest.fn() + +let mockConfig = makeGiftCardsConfig() + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockConfig + }, +})) +jest.mock("@services/mongoose", () => ({ + GiftCardOrdersRepository: () => ({ + listByAccount: (...a: unknown[]) => mockListByAccount(...a), + }), +})) +jest.mock("@services/redis", () => ({ + redis: { + zadd: (...a: unknown[]) => mockZadd(...a), + set: (...a: unknown[]) => mockSet(...a), + zremrangebyscore: (...a: unknown[]) => mockZremrangebyscore(...a), + zrange: (...a: unknown[]) => mockZrange(...a), + get: (...a: unknown[]) => mockGet(...a), + zrem: (...a: unknown[]) => mockZrem(...a), + del: (...a: unknown[]) => mockDel(...a), + }, +})) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...a: unknown[]) => mockNotifyOpsEvent(...a), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +const HOUR_MS = 60 * 60 * 1000 + +const authorize = (overrides: Record = {}) => + authorizeGiftCardPurchase({ + account: makeAccount(), + product: makeProduct(), + valueMinor: 2500, + quantity: 1, + nowMs: NOW_MS, + ...overrides, + } as Parameters[0]) + +const withMode = (mode: GiftCardLimitsMode) => { + mockConfig = makeGiftCardsConfig({ limits: { ...makeGiftCardsConfig().limits, mode } }) +} + +const priorOrder = ( + valueMinor: number, + ageMs: number, + status: GiftCardOrderStatus = "FULFILLED", +) => + makeOrder({ + id: `prior-${valueMinor}-${ageMs}` as GiftCardOrderId, + valueMinor, + status, + createdAt: new Date(NOW_MS - ageMs), + }) + +const reservation = (amountMinor: number, id = "res-open") => [ + `${amountMinor}:${id}`, + String(NOW_MS + DAY_MS), +] + +beforeEach(() => { + jest.clearAllMocks() + mockConfig = makeGiftCardsConfig() + mockListByAccount.mockResolvedValue([]) + mockZadd.mockResolvedValue(1) + mockSet.mockResolvedValue("OK") + mockZremrangebyscore.mockResolvedValue(0) + mockZrange.mockResolvedValue([]) + mockGet.mockResolvedValue(null) + mockZrem.mockResolvedValue(1) + mockDel.mockResolvedValue(1) +}) + +const expectRejected = ( + res: Awaited>, + reason: string, + errorClass: new (...args: never[]) => Error, +) => { + expect(res.authorized).toBe(false) + if (res.authorized) return + expect(res.reason).toBe(reason) + expect(res.error).toBeInstanceOf(errorClass) +} + +describe("authorizeGiftCardPurchase", () => { + describe("levels x amounts x modes", () => { + const cases: Array<{ + level: number + valueMinor: number + quantity?: number + expected: "ok" | string + }> = [ + { level: 0, valueMinor: 500, expected: GiftCardRejectionReasons.levelNotEligible }, + { level: 1, valueMinor: 2500, expected: "ok" }, + { level: 1, valueMinor: 20_000, expected: "ok" }, + { level: 1, valueMinor: 20_001, expected: GiftCardRejectionReasons.perCardCap }, + // quantity multiplies into the per-order total + { + level: 1, + valueMinor: 10_000, + quantity: 3, + expected: GiftCardRejectionReasons.perCardCap, + }, + { level: 2, valueMinor: 50_000, expected: "ok" }, + { level: 2, valueMinor: 150_000, expected: GiftCardRejectionReasons.perCardCap }, + { level: 3, valueMinor: 200_000, expected: "ok" }, + { level: 3, valueMinor: 250_000, expected: GiftCardRejectionReasons.perCardCap }, + ] + + for (const mode of ["enforce", "log-only"] as const) { + for (const c of cases) { + it(`${mode}: level ${c.level} buying ${c.valueMinor} x${c.quantity ?? 1} -> ${c.expected}`, async () => { + withMode(mode) + const product = makeProduct({ maxValue: null }) + const res = await authorize({ + account: makeAccount({ level: c.level }), + product, + valueMinor: c.valueMinor, + quantity: c.quantity ?? 1, + }) + + const wouldReject = c.expected !== "ok" + const rejected = wouldReject && mode === "enforce" + + expect(res.authorized).toBe(!rejected) + expect(res.authorized ? null : res.reason).toBe(rejected ? c.expected : null) + // Only an enforced rejection skips the hold: log-only still consumes + // the allowance for the order it is about to let through. + expect(mockZadd).toHaveBeenCalledTimes(rejected ? 0 : 1) + expect(mockNotifyOpsEvent).toHaveBeenCalledTimes(wouldReject ? 1 : 0) + const reported = mockNotifyOpsEvent.mock.calls[0]?.[0] + expect(reported?.flow ?? null).toBe(wouldReject ? "giftcard" : null) + expect(reported?.phase ?? null).toBe( + wouldReject ? (mode === "enforce" ? "rejected" : "would-reject") : null, + ) + expect(reported?.meta?.reason ?? null).toBe(wouldReject ? c.expected : null) + }) + } + } + + it("off: authorises without evaluating anything", async () => { + withMode("off") + const res = await authorize({ + account: makeAccount({ level: 0 }), + valueMinor: 9_999_999, + }) + expect(res).toEqual({ authorized: true, reservationId: null }) + expect(mockListByAccount).not.toHaveBeenCalled() + expect(mockZadd).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + + it("level 0 is rejected with the level error even when minAccountLevel is 0", async () => { + mockConfig = makeGiftCardsConfig({ + limits: { ...makeGiftCardsConfig().limits, minAccountLevel: 0 }, + }) + const res = await authorize({ account: makeAccount({ level: 0 }) }) + expectRejected( + res, + GiftCardRejectionReasons.levelNotEligible, + GiftCardLevelNotEligibleError, + ) + }) + + it("an undefined level is level 0", async () => { + const res = await authorize({ account: makeAccount({ level: undefined }) }) + expectRejected( + res, + GiftCardRejectionReasons.levelNotEligible, + GiftCardLevelNotEligibleError, + ) + }) + + it("the vendor per-card cap applies when it is tighter than the level cap", async () => { + const base = makeGiftCardsConfig() + mockConfig = makeGiftCardsConfig({ + limits: { + ...base.limits, + perLevel: { + ...base.limits.perLevel, + level3: { perCardCents: 500_000, dailyCents: 5_000_000 }, + }, + }, + }) + const res = await authorize({ + account: makeAccount({ level: 3 }), + product: makeProduct({ maxValue: null }), + valueMinor: 250_000, + }) + expectRejected( + res, + GiftCardRejectionReasons.vendorCardCap, + GiftCardLimitExceededError, + ) + }) + }) + + describe("account age", () => { + it("rejects an account younger than minAccountAgeHours", async () => { + const res = await authorize({ + account: makeAccount({ createdAt: new Date(NOW_MS - 23 * HOUR_MS) }), + }) + expectRejected( + res, + GiftCardRejectionReasons.accountTooNew, + GiftCardLimitExceededError, + ) + }) + + it("allows an account exactly minAccountAgeHours old", async () => { + const res = await authorize({ + account: makeAccount({ createdAt: new Date(NOW_MS - 24 * HOUR_MS) }), + }) + expect(res.authorized).toBe(true) + }) + }) + + describe("open-loop cards", () => { + it("refuses when allowOpenLoop is off, regardless of level", async () => { + mockConfig = makeGiftCardsConfig({ allowOpenLoop: false }) + const res = await authorize({ + account: makeAccount({ level: 3 }), + product: makeProduct({ isOpenLoop: true }), + }) + expectRejected( + res, + GiftCardRejectionReasons.openLoopNotAllowed, + GiftCardsDisabledError, + ) + }) + + it("refuses level 1 even when allowOpenLoop is on", async () => { + const res = await authorize({ + account: makeAccount({ level: 1 }), + product: makeProduct({ isOpenLoop: true }), + }) + expectRejected( + res, + GiftCardRejectionReasons.openLoopNotAllowed, + GiftCardLevelNotEligibleError, + ) + }) + + it("allows level 2 when allowOpenLoop is on", async () => { + const res = await authorize({ + account: makeAccount({ level: 2 }), + product: makeProduct({ isOpenLoop: true }), + }) + expect(res.authorized).toBe(true) + }) + + it("applies the tighter open-loop vendor card cap", async () => { + const res = await authorize({ + account: makeAccount({ level: 3 }), + product: makeProduct({ isOpenLoop: true, maxValue: null }), + valueMinor: 150_000, // < level3 perCard 200_000, > open-loop vendor cap 100_000 + }) + expectRejected( + res, + GiftCardRejectionReasons.vendorCardCap, + GiftCardLimitExceededError, + ) + }) + }) + + describe("velocity", () => { + it("rejects the Nth order in an hour once maxOrdersPerHour is reached", async () => { + mockListByAccount.mockResolvedValue([ + priorOrder(500, 5 * 60 * 1000), + priorOrder(500, 10 * 60 * 1000, "FAILED"), // attempts count, even failed ones + priorOrder(500, 20 * 60 * 1000), + priorOrder(500, 30 * 60 * 1000), + priorOrder(500, 59 * 60 * 1000), + ]) + const res = await authorize() + expectRejected(res, GiftCardRejectionReasons.velocity, GiftCardLimitExceededError) + }) + + it("orders older than an hour do not count", async () => { + mockListByAccount.mockResolvedValue([ + priorOrder(500, 5 * 60 * 1000), + priorOrder(500, 10 * 60 * 1000), + priorOrder(500, 20 * 60 * 1000), + priorOrder(500, 30 * 60 * 1000), + priorOrder(500, 61 * 60 * 1000), + ]) + const res = await authorize() + expect(res.authorized).toBe(true) + }) + }) + + describe("daily cap", () => { + it("sums the trailing 24h of non-failed orders", async () => { + mockListByAccount.mockResolvedValue([ + priorOrder(20_000, 2 * HOUR_MS), + priorOrder(20_000, 20 * HOUR_MS, "PAID"), + ]) + // 40_000 spent + 15_000 new > level1 daily 50_000 + const res = await authorize({ valueMinor: 15_000 }) + expectRejected(res, GiftCardRejectionReasons.dailyCap, GiftCardLimitExceededError) + }) + + it("ignores FAILED, PAYMENT_FAILED and EXPIRED orders", async () => { + mockListByAccount.mockResolvedValue([ + priorOrder(20_000, 2 * HOUR_MS, "FAILED"), + priorOrder(20_000, 3 * HOUR_MS, "PAYMENT_FAILED"), + priorOrder(20_000, 4 * HOUR_MS, "EXPIRED"), + ]) + const res = await authorize({ valueMinor: 15_000 }) + expect(res.authorized).toBe(true) + }) + + it("ignores orders older than 24h", async () => { + mockListByAccount.mockResolvedValue([priorOrder(40_000, 25 * HOUR_MS)]) + const res = await authorize({ valueMinor: 15_000 }) + expect(res.authorized).toBe(true) + }) + + it("counts REFUND_REQUIRED orders: the money left", async () => { + mockListByAccount.mockResolvedValue([ + priorOrder(40_000, 2 * HOUR_MS, "REFUND_REQUIRED"), + ]) + const res = await authorize({ valueMinor: 15_000 }) + expectRejected(res, GiftCardRejectionReasons.dailyCap, GiftCardLimitExceededError) + }) + + it("counts live Redis reservations from concurrent authorisations", async () => { + mockListByAccount.mockResolvedValue([priorOrder(30_000, 2 * HOUR_MS)]) + mockZrange.mockResolvedValue(reservation(15_000)) + // 30_000 + 15_000 held + 10_000 new = 55_000 > 50_000 + const res = await authorize({ valueMinor: 10_000 }) + expectRejected(res, GiftCardRejectionReasons.dailyCap, GiftCardLimitExceededError) + // Expired holds are pruned before the read. + expect(mockZremrangebyscore).toHaveBeenCalledWith( + `giftcards:reservations:${ACCOUNT_ID}`, + "-inf", + NOW_MS, + ) + }) + + it("applies the vendor daily cap when the level cap is higher", async () => { + const base = makeGiftCardsConfig() + mockConfig = makeGiftCardsConfig({ + limits: { + ...base.limits, + perLevel: { + ...base.limits.perLevel, + level3: { perCardCents: 200_000, dailyCents: 2_000_000 }, + }, + }, + }) + mockListByAccount.mockResolvedValue([priorOrder(990_000, 2 * HOUR_MS)]) + const res = await authorize({ + account: makeAccount({ level: 3 }), + product: makeProduct({ maxValue: null }), + valueMinor: 20_000, + }) + expectRejected( + res, + GiftCardRejectionReasons.vendorDailyCap, + GiftCardLimitExceededError, + ) + }) + }) + + describe("reservations", () => { + it("writes a 24h hold on pass and returns its id", async () => { + const res = await authorize({ valueMinor: 2500, quantity: 2 }) + expect(res.authorized).toBe(true) + if (!res.authorized) return + + const [indexKey, score, member] = mockZadd.mock.calls[0] + expect(indexKey).toBe(`giftcards:reservations:${ACCOUNT_ID}`) + expect(score).toBe(NOW_MS + DAY_MS) + expect(member).toBe(`5000:${res.reservationId}`) + + const [key, value, ex, ttl] = mockSet.mock.calls[0] + expect(key).toBe(`giftcards:reservation:${ACCOUNT_ID}:${res.reservationId}`) + expect(value).toBe("5000") + expect(ex).toBe("EX") + expect(ttl).toBe(DAY_MS / 1000) + }) + + it("releaseGiftCardReservation removes the hold from both keys", async () => { + mockGet.mockResolvedValue("5000") + await releaseGiftCardReservation(ACCOUNT_ID, "res-1") + expect(mockZrem).toHaveBeenCalledWith( + `giftcards:reservations:${ACCOUNT_ID}`, + "5000:res-1", + ) + expect(mockDel).toHaveBeenCalledWith(`giftcards:reservation:${ACCOUNT_ID}:res-1`) + }) + + it("releasing a null id is a no-op", async () => { + await releaseGiftCardReservation(ACCOUNT_ID, null) + expect(mockGet).not.toHaveBeenCalled() + expect(mockDel).not.toHaveBeenCalled() + }) + + it("release never throws when Redis is down", async () => { + mockGet.mockRejectedValue(new Error("ECONNREFUSED")) + await expect( + releaseGiftCardReservation(ACCOUNT_ID, "res-1"), + ).resolves.toBeUndefined() + }) + }) + + describe("limits unavailable", () => { + it("enforce: a repository fault rejects with a critical error", async () => { + mockListByAccount.mockResolvedValue(new RepositoryError("mongo down")) + const res = await authorize() + expectRejected( + res, + GiftCardRejectionReasons.limitsUnavailable, + UnknownGiftCardError, + ) + expect(mockZadd).not.toHaveBeenCalled() + }) + + it("enforce: a Redis read fault rejects with a critical error", async () => { + mockZrange.mockRejectedValue(new Error("ECONNREFUSED")) + const res = await authorize() + expectRejected( + res, + GiftCardRejectionReasons.limitsUnavailable, + UnknownGiftCardError, + ) + }) + + it("log-only: a repository fault allows and reports would-reject", async () => { + withMode("log-only") + mockListByAccount.mockResolvedValue(new RepositoryError("mongo down")) + const res = await authorize() + expect(res.authorized).toBe(true) + expect(mockNotifyOpsEvent).toHaveBeenCalledTimes(1) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + phase: "would-reject", + status: "pending", + meta: { reason: GiftCardRejectionReasons.limitsUnavailable }, + }) + }) + + it("enforce: a reservation write fault rejects", async () => { + mockZadd.mockRejectedValue(new Error("ECONNREFUSED")) + const res = await authorize() + expectRejected( + res, + GiftCardRejectionReasons.limitsUnavailable, + UnknownGiftCardError, + ) + }) + + it("log-only: a reservation write fault allows with no reservation id", async () => { + withMode("log-only") + mockZadd.mockRejectedValue(new Error("ECONNREFUSED")) + const res = await authorize() + expect(res).toEqual({ authorized: true, reservationId: null }) + }) + }) + + describe("would-reject event", () => { + it("carries the account, the display amount, the reason and the level", async () => { + withMode("log-only") + const res = await authorize({ + account: makeAccount({ level: 1 }), + product: makeProduct({ maxValue: null }), + valueMinor: 25_000, + }) + expect(res.authorized).toBe(true) + expect(mockNotifyOpsEvent).toHaveBeenCalledWith({ + flow: "giftcard", + phase: "would-reject", + status: "pending", + accountId: ACCOUNT_ID, + amount: { value: "250.00", currency: "USD" }, + error: "GiftCardLimitExceededError", + meta: { + reason: GiftCardRejectionReasons.perCardCap, + level: "1", + productId: "bitcoinCompany:amazon-us", + }, + }) + }) + + it("enforce: reports a rejected event with status failed", async () => { + const res = await authorize({ account: makeAccount({ level: 0 }) }) + expect(res.authorized).toBe(false) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + phase: "rejected", + status: "failed", + error: "GiftCardLevelNotEligibleError", + }) + }) + }) +}) diff --git a/test/flash/unit/app/gift-cards/fixtures.ts b/test/flash/unit/app/gift-cards/fixtures.ts new file mode 100644 index 000000000..8630a9cef --- /dev/null +++ b/test/flash/unit/app/gift-cards/fixtures.ts @@ -0,0 +1,256 @@ +// Shared fixtures for the gift-card app-layer specs. Not a spec itself (no +// `.spec.ts` suffix), so jest never tries to run it. + +import { GiftCardOrderNotFoundError, GiftCardOrderStateError } from "@domain/gift-cards" + +export const ACCOUNT_ID = "6a8203ce490716aa69381454" as AccountId +export const WALLET_ID = "6a8203ce490716aa69381455" as WalletId +export const NOW_MS = 1_700_000_000_000 + +const HOUR_MS = 60 * 60 * 1000 +export const DAY_MS = 24 * HOUR_MS + +export const makeProduct = ( + overrides: Partial = {}, +): GiftCardProduct => ({ + id: "bitcoinCompany:amazon-us" as GiftCardProductId, + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + name: "Amazon US", + brand: "Amazon", + countryCode: "US", + currency: "USD", + denominationType: "variable", + denominations: [], + minValue: 500, + maxValue: 50_000, + isOpenLoop: false, + categories: ["shopping"], + logoUrl: null, + termsUrl: null, + rewardBps: 100, + inStock: true, + ...overrides, +}) + +export const makeAccount = (overrides: Record = {}): Account => + ({ + id: ACCOUNT_ID, + level: 1, + createdAt: new Date(NOW_MS - 7 * DAY_MS), + kratosUserId: "kratos-1", + displayCurrency: "USD", + username: "jaceth2009", + ...overrides, + }) as unknown as Account + +export const makeWallet = (overrides: Record = {}): Wallet => + ({ + id: WALLET_ID, + accountId: ACCOUNT_ID, + currency: "BTC", + type: "checking", + ...overrides, + }) as unknown as Wallet + +export const makeOrder = (overrides: Partial = {}): GiftCardOrder => { + const status = overrides.status ?? "CREATED" + return { + id: "order-1" as GiftCardOrderId, + accountId: ACCOUNT_ID, + walletId: WALLET_ID, + walletCurrency: "BTC", + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + providerOrderId: null, + productSnapshot: { + name: "Amazon US", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: null, + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 40_000 as Satoshis, + invoiceSats: null, + paidSats: null, + paymentRequest: null, + paymentHash: null, + providerPaymentRef: null, + idempotencyKey: "idem-1", + status, + statusHistory: [{ status, at: new Date(NOW_MS), reason: null }], + claimCiphertext: null, + claimKeyId: null, + fulfilledAt: null, + failureReason: null, + expiresAt: new Date(NOW_MS + 15 * 60 * 1000), + createdAt: new Date(NOW_MS), + updatedAt: new Date(NOW_MS), + ...overrides, + } +} + +export const makeLimitsConfig = (overrides: Record = {}) => ({ + mode: "enforce" as GiftCardLimitsMode, + minAccountLevel: 1, + minAccountAgeHours: 24, + maxOrdersPerHour: 5, + vendorDailyCapCents: 1_000_000, + vendorOpenLoopCardCapCents: 100_000, + vendorClosedLoopCardCapCents: 200_000, + perLevel: { + level0: { perCardCents: 0, dailyCents: 0 }, + level1: { perCardCents: 20_000, dailyCents: 50_000 }, + level2: { perCardCents: 100_000, dailyCents: 250_000 }, + level3: { perCardCents: 200_000, dailyCents: 1_000_000 }, + }, + ...overrides, +}) + +export const makeGiftCardsConfig = (overrides: Record = {}) => ({ + enabled: true, + allowOpenLoop: true, + feeBps: 0, + claimDataEncryptionKey: "", + quoteToleranceBps: 100, + routing: { default: "bitcoinCompany", byCountry: {} as Record }, + providers: { + bitcoinCompany: { + enabled: true, + baseUrl: "https://api.dev.thebitcoincompany.com", + email: "", + password: "", + referralCode: "", + timeoutMs: 10_000, + }, + bitrefill: { + enabled: false, + baseUrl: "", + apiId: "", + apiSecret: "", + webhookSecret: "", + timeoutMs: 10_000, + }, + }, + catalog: { syncIntervalSeconds: 21_600, ttlSeconds: 21_600, staleAfterSeconds: 86_400 }, + limits: makeLimitsConfig(), + ...overrides, +}) + +/** + * In-memory `GiftCardOrdersRepository` with the real transition semantics: + * `from` must include the current status, the patch is applied, the history + * grows, `updatedAt` moves. Every method is a `jest.fn` so specs can assert + * calls or override one method for a single test. + */ +export const makeFakeOrdersRepo = () => { + const store = new Map() + let seq = 0 + + const now = () => new Date(Date.now()) + + const repo = { + store, + seed: (order: GiftCardOrder) => { + store.set(order.id, order) + return order + }, + create: jest.fn(async (args: Record) => { + seq += 1 + const order = makeOrder({ + ...(args as Partial), + id: `order-${seq}` as GiftCardOrderId, + status: "CREATED", + statusHistory: [{ status: "CREATED", at: now(), reason: null }], + createdAt: now(), + updatedAt: now(), + }) + store.set(order.id, order) + return order + }), + findById: jest.fn( + async (id: string) => store.get(id) ?? new GiftCardOrderNotFoundError(), + ), + findByIdempotencyKey: jest.fn( + async ({ + walletId, + idempotencyKey, + }: { + walletId: string + idempotencyKey: string + }) => + [...store.values()].find( + (o) => o.walletId === walletId && o.idempotencyKey === idempotencyKey, + ) ?? new GiftCardOrderNotFoundError(), + ), + findByProviderOrderId: jest.fn( + async ({ providerOrderId }: { providerOrderId: string }) => + [...store.values()].find((o) => o.providerOrderId === providerOrderId) ?? + new GiftCardOrderNotFoundError(), + ), + listByAccount: jest.fn( + async ({ + accountId, + limit, + before, + }: { + accountId: string + limit: number + before?: Date + }) => + [...store.values()] + .filter((o) => o.accountId === accountId) + .filter((o) => !before || o.createdAt < before) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, limit), + ), + listByStatus: jest.fn( + async ({ statuses, limit }: { statuses: GiftCardOrderStatus[]; limit: number }) => + [...store.values()].filter((o) => statuses.includes(o.status)).slice(0, limit), + ), + transition: jest.fn( + async ({ + id, + from, + to, + reason, + patch, + }: { + id: string + from: GiftCardOrderStatus[] + to: GiftCardOrderStatus + reason?: string + patch?: Partial + }) => { + const current = store.get(id) + if (!current) return new GiftCardOrderNotFoundError() + if (!from.includes(current.status)) { + return new GiftCardOrderStateError(`${current.status} -> ${to} not allowed`) + } + const next: GiftCardOrder = { + ...current, + ...patch, + status: to, + statusHistory: [ + ...current.statusHistory, + { status: to, at: now(), reason: reason ?? null }, + ], + updatedAt: now(), + } + store.set(id, next) + return next + }, + ), + } + return repo +} + +export type FakeOrdersRepo = ReturnType + +/** Every string that went through a mock, for "the claim never leaked" assertions. */ +export const allMockCallText = (...mocks: jest.Mock[]): string => + mocks.map((m) => JSON.stringify(m.mock.calls)).join("\n") diff --git a/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts b/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts new file mode 100644 index 000000000..978ce92c2 --- /dev/null +++ b/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts @@ -0,0 +1,144 @@ +import { CouldNotFindUserFromIdError } from "@domain/errors" +import { + GiftCardProviderUnavailableError, + GiftCardsDisabledError, +} from "@domain/gift-cards" + +import { + giftCardsMasterGate, + resolveAccountCountryCode, + resolveAccountCountryCodeOrUnknown, + UNKNOWN_COUNTRY_CODE, +} from "@app/gift-cards/gift-cards-master-gate" + +import { makeAccount, makeGiftCardsConfig } from "./fixtures" + +const mockFindUserById = jest.fn() +const mockResolvePhoneCountries = jest.fn() + +let mockConfig = makeGiftCardsConfig() + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockConfig + }, +})) +jest.mock("@services/mongoose", () => ({ + UsersRepository: () => ({ findById: (...a: unknown[]) => mockFindUserById(...a) }), +})) +jest.mock("@app/bridge/kyc-gate", () => ({ + resolvePhoneCountries: (...a: unknown[]) => mockResolvePhoneCountries(...a), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +const withConfig = (overrides: Record) => { + mockConfig = makeGiftCardsConfig(overrides) +} + +beforeEach(() => { + jest.clearAllMocks() + mockConfig = makeGiftCardsConfig() + mockFindUserById.mockResolvedValue({ id: "kratos-1", phone: "+18765550100" }) + mockResolvePhoneCountries.mockReturnValue({ countries: ["JM"], source: "lookup" }) +}) + +describe("giftCardsMasterGate", () => { + it("opens with the routed provider", () => { + expect(giftCardsMasterGate("US")).toEqual({ ok: true, providerId: "bitcoinCompany" }) + }) + + it("closes with GiftCardsDisabledError when the rail is off", () => { + withConfig({ enabled: false }) + expect(giftCardsMasterGate("US")).toEqual({ + ok: false, + error: expect.any(GiftCardsDisabledError), + }) + }) + + it("closes with GiftCardProviderUnavailableError when the routed provider is disabled", () => { + const base = makeGiftCardsConfig() + withConfig({ + providers: { + ...base.providers, + bitcoinCompany: { ...base.providers.bitcoinCompany, enabled: false }, + }, + }) + expect(giftCardsMasterGate("US")).toEqual({ + ok: false, + error: expect.any(GiftCardProviderUnavailableError), + }) + }) + + it("routes by country, case-insensitively", () => { + const base = makeGiftCardsConfig() + withConfig({ + routing: { default: "bitcoinCompany", byCountry: { JM: "bitrefill" } }, + providers: { + ...base.providers, + bitrefill: { ...base.providers.bitrefill, enabled: true }, + }, + }) + expect(giftCardsMasterGate("jm")).toEqual({ ok: true, providerId: "bitrefill" }) + expect(giftCardsMasterGate("US")).toEqual({ ok: true, providerId: "bitcoinCompany" }) + }) + + it("a country routed to a disabled provider is unavailable even though the default is on", () => { + withConfig({ routing: { default: "bitcoinCompany", byCountry: { JM: "bitrefill" } } }) + expect(giftCardsMasterGate("JM")).toEqual({ + ok: false, + error: expect.any(GiftCardProviderUnavailableError), + }) + }) + + it("the unknown-country sentinel and an empty string both take the default route", () => { + expect(giftCardsMasterGate(UNKNOWN_COUNTRY_CODE)).toEqual({ + ok: true, + providerId: "bitcoinCompany", + }) + expect(giftCardsMasterGate("")).toEqual({ ok: true, providerId: "bitcoinCompany" }) + }) +}) + +describe("resolveAccountCountryCode", () => { + it("returns the single phone country", async () => { + await expect(resolveAccountCountryCode(makeAccount())).resolves.toBe("JM") + expect(mockFindUserById).toHaveBeenCalledWith("kratos-1") + }) + + it("returns null for an ambiguous calling-code set", async () => { + mockResolvePhoneCountries.mockReturnValue({ + countries: ["US", "CA", "JM"], + source: "calling-code", + callingCode: "1", + }) + await expect(resolveAccountCountryCode(makeAccount())).resolves.toBeNull() + }) + + it("returns null when nothing resolves", async () => { + mockResolvePhoneCountries.mockReturnValue({ countries: [], source: "none" }) + await expect(resolveAccountCountryCode(makeAccount())).resolves.toBeNull() + }) + + it("returns null when the user cannot be loaded", async () => { + mockFindUserById.mockResolvedValue(new CouldNotFindUserFromIdError()) + await expect(resolveAccountCountryCode(makeAccount())).resolves.toBeNull() + }) + + it("never throws", async () => { + mockFindUserById.mockRejectedValue(new Error("mongo down")) + await expect(resolveAccountCountryCode(makeAccount())).resolves.toBeNull() + }) + + it("OrUnknown maps null to the routing sentinel", async () => { + mockResolvePhoneCountries.mockReturnValue({ countries: [], source: "none" }) + await expect(resolveAccountCountryCodeOrUnknown(makeAccount())).resolves.toBe( + UNKNOWN_COUNTRY_CODE, + ) + }) +}) diff --git a/test/flash/unit/app/gift-cards/list-products.spec.ts b/test/flash/unit/app/gift-cards/list-products.spec.ts new file mode 100644 index 000000000..313a09b7c --- /dev/null +++ b/test/flash/unit/app/gift-cards/list-products.spec.ts @@ -0,0 +1,429 @@ +const mockGiftCardsConfig: { allowOpenLoop: boolean } = { allowOpenLoop: false } +const mockNotifyOpsEvent = jest.fn() +const mockResolveProvider = jest.fn() +const mockRead = jest.fn() +const mockReadProduct = jest.fn() + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockGiftCardsConfig + }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: unknown }) => fns, + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...args: unknown[]) => mockNotifyOpsEvent(...args), +})) + +jest.mock("@services/gift-cards", () => ({ + resolveGiftCardProviderIdForCountry: (...args: unknown[]) => + mockResolveProvider(...args), +})) + +jest.mock("@services/gift-cards/catalog-cache", () => ({ + GiftCardCatalogCache: () => ({ + read: (...args: unknown[]) => mockRead(...args), + readProduct: (...args: unknown[]) => mockReadProduct(...args), + }), +})) + +import { + __resetGiftCardStaleEventsForTest, + GIFT_CARD_LIST_DEFAULT_FIRST, + GIFT_CARD_LIST_MAX_FIRST, + GIFT_CARD_STALE_EVENT_COALESCE_MS, + getGiftCardProduct, + listGiftCardProducts, +} from "@app/gift-cards/list-products" +import { + GiftCardCatalogUnavailableError, + GiftCardProductNotFoundError, + GiftCardProviderUnavailableError, +} from "@domain/gift-cards" + +const NOW_MS = 1_800_000_000_000 + +const product = (id: string, over: Partial = {}): GiftCardProduct => ({ + id: `bitcoinCompany:${id}` as GiftCardProductId, + providerId: "bitcoinCompany", + providerProductId: id, + name: id, + brand: id, + countryCode: "US", + currency: "USD", + denominationType: "fixed", + denominations: [2500], + minValue: null, + maxValue: null, + isOpenLoop: false, + categories: [], + logoUrl: null, + termsUrl: null, + rewardBps: 0, + inStock: true, + ...over, +}) + +const AMAZON_25 = product("amz-25", { + brand: "Amazon", + name: "Amazon $25", + categories: ["Shopping", "Retail"], +}) +const AMAZON_50 = product("amz-50", { + brand: "Amazon", + name: "Amazon $50", + categories: ["Shopping"], +}) +const VISA = product("visa", { + brand: "Visa", + name: "Visa Prepaid", + isOpenLoop: true, + categories: ["Prepaid"], +}) +const UBER = product("uber", { brand: "Uber", name: "Uber Eats", categories: ["Food"] }) +const SOLD_OUT = product("app-store", { + brand: "Apple", + name: "App Store", + inStock: false, +}) +// Lower-case brand on purpose: sorting must not put it after every capitalised brand. +const NETFLIX = product("netflix", { + brand: "netflix", + name: "Netflix", + categories: ["Entertainment"], +}) + +// Deliberately out of order so the sort has work to do. +const CATALOG = [UBER, VISA, NETFLIX, SOLD_OUT, AMAZON_50, AMAZON_25] + +const ids = (res: unknown): string[] => { + if (res instanceof Error) throw res + return (res as { products: GiftCardProduct[] }).products.map((p) => p.id) +} + +const cursorFor = (p: GiftCardProduct): string => + Buffer.from(p.id, "utf8").toString("base64") + +const catalogRead = (over: { products?: GiftCardProduct[]; stale?: boolean } = {}) => ({ + products: over.products ?? CATALOG, + syncedAt: new Date(NOW_MS - 1000), + stale: over.stale ?? false, +}) + +let nowSpy: jest.SpyInstance + +beforeEach(() => { + jest.clearAllMocks() + __resetGiftCardStaleEventsForTest() + mockGiftCardsConfig.allowOpenLoop = false + nowSpy = jest.spyOn(Date, "now").mockReturnValue(NOW_MS) + mockResolveProvider.mockImplementation((cc: string) => { + if (cc === "US") return "bitcoinCompany" + if (cc === "JM") return "bitrefill" + return new GiftCardProviderUnavailableError() + }) + mockRead.mockResolvedValue(catalogRead()) + mockReadProduct.mockResolvedValue(AMAZON_25) +}) + +afterEach(() => { + nowSpy.mockRestore() +}) + +describe("listGiftCardProducts", () => { + describe("filtering", () => { + it("hides out-of-stock products", async () => { + const res = await listGiftCardProducts({ countryCode: "US" }) + + expect(ids(res)).not.toContain(SOLD_OUT.id) + }) + + it("hides open-loop cards while giftCards.allowOpenLoop is off", async () => { + const res = await listGiftCardProducts({ countryCode: "US" }) + + expect(ids(res)).not.toContain(VISA.id) + }) + + it("shows open-loop cards once giftCards.allowOpenLoop is on", async () => { + mockGiftCardsConfig.allowOpenLoop = true + + const res = await listGiftCardProducts({ countryCode: "US" }) + + expect(ids(res)).toContain(VISA.id) + }) + + it("matches category exactly, ignoring case", async () => { + expect( + ids(await listGiftCardProducts({ countryCode: "US", category: "shopping" })), + ).toEqual([AMAZON_25.id, AMAZON_50.id]) + expect( + ids(await listGiftCardProducts({ countryCode: "US", category: "RETAIL" })), + ).toEqual([AMAZON_25.id]) + // Exact, not substring: "shop" is not a category anyone has. + expect( + ids(await listGiftCardProducts({ countryCode: "US", category: "shop" })), + ).toEqual([]) + }) + + it("searches name OR brand by case-insensitive substring", async () => { + expect( + ids(await listGiftCardProducts({ countryCode: "US", search: "AMAZ" })), + ).toEqual([AMAZON_25.id, AMAZON_50.id]) + // "Eats" is only in the name, "Uber" is in both. + expect( + ids(await listGiftCardProducts({ countryCode: "US", search: "eats" })), + ).toEqual([UBER.id]) + expect( + ids(await listGiftCardProducts({ countryCode: "US", search: "zzz" })), + ).toEqual([]) + }) + + it("treats blank category and search as absent", async () => { + const all = ids(await listGiftCardProducts({ countryCode: "US" })) + + expect( + ids(await listGiftCardProducts({ countryCode: "US", category: " " })), + ).toEqual(all) + expect(ids(await listGiftCardProducts({ countryCode: "US", search: "" }))).toEqual( + all, + ) + }) + + it("applies category and search together", async () => { + const res = await listGiftCardProducts({ + countryCode: "US", + category: "shopping", + search: "$50", + }) + + expect(ids(res)).toEqual([AMAZON_50.id]) + }) + }) + + describe("ordering", () => { + it("sorts by brand, then name, then id, ignoring case on the human fields", async () => { + const res = await listGiftCardProducts({ countryCode: "US" }) + + expect(ids(res)).toEqual([AMAZON_25.id, AMAZON_50.id, NETFLIX.id, UBER.id]) + }) + + it("breaks a brand+name tie on id so the order is total", async () => { + const twinA = product("twin-a", { brand: "Twin", name: "Twin" }) + const twinB = product("twin-b", { brand: "Twin", name: "Twin" }) + mockRead.mockResolvedValue(catalogRead({ products: [twinB, twinA] })) + + const res = await listGiftCardProducts({ countryCode: "US" }) + + expect(ids(res)).toEqual([twinA.id, twinB.id]) + }) + }) + + describe("pagination", () => { + it("pages forward with an opaque cursor and reports whether more follow", async () => { + const page1 = await listGiftCardProducts({ countryCode: "US", first: 3 }) + if (page1 instanceof Error) throw page1 + + expect(ids(page1)).toEqual([AMAZON_25.id, AMAZON_50.id, NETFLIX.id]) + expect(page1.hasNextPage).toBe(true) + expect(page1.endCursor).toBe(cursorFor(NETFLIX)) + + const page2 = await listGiftCardProducts({ + countryCode: "US", + first: 3, + after: page1.endCursor as string, + }) + if (page2 instanceof Error) throw page2 + + expect(ids(page2)).toEqual([UBER.id]) + expect(page2.hasNextPage).toBe(false) + // The last page still names its last row so a client can resume later. + expect(page2.endCursor).toBe(cursorFor(UBER)) + }) + + it("walks the whole list with no gaps and no repeats", async () => { + const seen: string[] = [] + let after: string | undefined + for (let guard = 0; guard < 10; guard++) { + const page = await listGiftCardProducts({ countryCode: "US", first: 1, after }) + if (page instanceof Error) throw page + seen.push(...page.products.map((p) => p.id)) + if (!page.hasNextPage) break + after = page.endCursor as string + } + + expect(seen).toEqual([AMAZON_25.id, AMAZON_50.id, NETFLIX.id, UBER.id]) + }) + + it("returns an empty page with a null cursor when nothing matches", async () => { + const res = await listGiftCardProducts({ countryCode: "US", search: "zzz" }) + if (res instanceof Error) throw res + + expect(res).toEqual({ + products: [], + hasNextPage: false, + endCursor: null, + stale: false, + }) + }) + + it("restarts from the top on a cursor whose product has left the catalog", async () => { + // A repeat is survivable; a silent gap is not. Guessing a position for an + // id we no longer hold would risk the gap. + const gone = Buffer.from("bitcoinCompany:gone", "utf8").toString("base64") + + const res = await listGiftCardProducts({ countryCode: "US", first: 2, after: gone }) + + expect(ids(res)).toEqual([AMAZON_25.id, AMAZON_50.id]) + }) + + it("defaults `first` to 50 and caps it at 200", async () => { + const many = Array.from({ length: 250 }, (_, i) => + product(`p-${String(i).padStart(3, "0")}`), + ) + mockRead.mockResolvedValue(catalogRead({ products: many })) + + expect(GIFT_CARD_LIST_DEFAULT_FIRST).toBe(50) + expect(GIFT_CARD_LIST_MAX_FIRST).toBe(200) + + expect(ids(await listGiftCardProducts({ countryCode: "US" }))).toHaveLength(50) + expect( + ids(await listGiftCardProducts({ countryCode: "US", first: 1000 })), + ).toHaveLength(200) + for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + ids(await listGiftCardProducts({ countryCode: "US", first: bad })), + ).toHaveLength(50) + } + // Fractional counts are floored, not rejected. + expect( + ids(await listGiftCardProducts({ countryCode: "US", first: 2.9 })), + ).toHaveLength(2) + }) + }) + + describe("routing", () => { + it("normalises the country before resolving the provider and reading the cache", async () => { + await listGiftCardProducts({ countryCode: " us " }) + + expect(mockResolveProvider).toHaveBeenCalledWith("US") + expect(mockRead).toHaveBeenCalledWith({ + providerId: "bitcoinCompany", + countryCode: "US", + }) + }) + + it("returns GiftCardProviderUnavailableError for a country no enabled provider serves", async () => { + const res = await listGiftCardProducts({ countryCode: "XX" }) + + expect(res).toBeInstanceOf(GiftCardProviderUnavailableError) + expect(mockRead).not.toHaveBeenCalled() + }) + + it("passes a catalog-unavailable error straight through", async () => { + const unavailable = new GiftCardCatalogUnavailableError() + mockRead.mockResolvedValue(unavailable) + + const res = await listGiftCardProducts({ countryCode: "US" }) + + expect(res).toBe(unavailable) + }) + }) + + describe("stale catalog", () => { + it("serves a stale catalog, flags it, and posts ONE ops event per provider per 10 minutes", async () => { + mockRead.mockResolvedValue(catalogRead({ stale: true })) + + const first = await listGiftCardProducts({ countryCode: "US" }) + const second = await listGiftCardProducts({ countryCode: "US", search: "uber" }) + if (first instanceof Error || second instanceof Error) throw new Error("unexpected") + + expect(first.stale).toBe(true) + expect(first.products).toHaveLength(4) + expect(second.stale).toBe(true) + expect(mockNotifyOpsEvent).toHaveBeenCalledTimes(1) + expect(mockNotifyOpsEvent).toHaveBeenCalledWith({ + flow: "giftcard", + phase: "catalog-stale", + status: "pending", + meta: { providerId: "bitcoinCompany", countryCode: "US" }, + }) + + // Window closed: the next stale read posts again. + nowSpy.mockReturnValue(NOW_MS + GIFT_CARD_STALE_EVENT_COALESCE_MS) + await listGiftCardProducts({ countryCode: "US" }) + expect(mockNotifyOpsEvent).toHaveBeenCalledTimes(2) + }) + + it("coalesces per provider, not globally", async () => { + mockRead.mockResolvedValue(catalogRead({ stale: true })) + + await listGiftCardProducts({ countryCode: "US" }) + await listGiftCardProducts({ countryCode: "JM" }) + + expect(mockNotifyOpsEvent).toHaveBeenCalledTimes(2) + expect(mockNotifyOpsEvent.mock.calls.map((c) => c[0].meta.providerId)).toEqual([ + "bitcoinCompany", + "bitrefill", + ]) + }) + + it("posts nothing for a fresh catalog", async () => { + const res = await listGiftCardProducts({ countryCode: "US" }) + if (res instanceof Error) throw res + + expect(res.stale).toBe(false) + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + }) +}) + +describe("getGiftCardProduct", () => { + it("returns the cached product for a well-formed id", async () => { + const res = await getGiftCardProduct(AMAZON_25.id) + + expect(res).toEqual(AMAZON_25) + expect(mockReadProduct).toHaveBeenCalledWith(AMAZON_25.id) + }) + + it("rejects a malformed id before touching the cache", async () => { + for (const bad of ["amz-25", "bitcoinCompany:", ":amz-25", "acme:amz-25"]) { + const res = await getGiftCardProduct(bad as GiftCardProductId) + expect(res).toBeInstanceOf(GiftCardProductNotFoundError) + } + expect(mockReadProduct).not.toHaveBeenCalled() + }) + + it("passes a cache miss through as GiftCardProductNotFoundError", async () => { + const miss = new GiftCardProductNotFoundError() + mockReadProduct.mockResolvedValue(miss) + + expect(await getGiftCardProduct(AMAZON_25.id)).toBe(miss) + }) + + it("passes a catalog-unavailable error through unchanged", async () => { + const unavailable = new GiftCardCatalogUnavailableError() + mockReadProduct.mockResolvedValue(unavailable) + + expect(await getGiftCardProduct(AMAZON_25.id)).toBe(unavailable) + }) + + it("answers not-found for an open-loop card while allowOpenLoop is off", async () => { + // Same answer as a card that never existed: a distinct "withheld" answer + // would tell a client exactly which ids become reachable when the flag flips. + mockReadProduct.mockResolvedValue(VISA) + + expect(await getGiftCardProduct(VISA.id)).toBeInstanceOf(GiftCardProductNotFoundError) + + mockGiftCardsConfig.allowOpenLoop = true + expect(await getGiftCardProduct(VISA.id)).toEqual(VISA) + }) +}) diff --git a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts new file mode 100644 index 000000000..1832b9c4d --- /dev/null +++ b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts @@ -0,0 +1,636 @@ +import { CouldNotFindWalletFromIdError, InvalidIdempotencyKeyError } from "@domain/errors" +import { + GiftCardIdempotencyKeyReuseError, + GiftCardInvalidValueError, + GiftCardLevelNotEligibleError, + GiftCardProductNotAvailableInCountryError, + GiftCardQuoteMismatchError, + GiftCardsDisabledError, + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" +import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" +import { ValidationError } from "@domain/shared" + +const mockFindAccountById = jest.fn() +const mockFindWalletById = jest.fn() +const mockGetGiftCardProduct = jest.fn() +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockAuthorize = jest.fn() +const mockReleaseReservation = jest.fn() +const mockGetProvider = jest.fn() +const mockQuote = jest.fn() +const mockCreateOrder = jest.fn() +const mockGetOrder = jest.fn() +const mockPayInvoice = jest.fn() +const mockConsumeLimiter = jest.fn() +const mockDecodeInvoice = jest.fn() +const mockEncrypt = jest.fn() +const mockNotifyOpsEvent = jest.fn() +const mockSendFulfilledPush = jest.fn() +const mockAuthorizeSend = jest.fn() +const mockGateSend = jest.fn() +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + +let repo: FakeOrdersRepo +let mockConfig = makeGiftCardsConfig() +let mockSendGuardMode = "off" + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockConfig + }, + getSendGuardMode: () => mockSendGuardMode, +})) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ + findById: (...a: unknown[]) => mockFindAccountById(...a), + }), + WalletsRepository: () => ({ findById: (...a: unknown[]) => mockFindWalletById(...a) }), + GiftCardOrdersRepository: () => repo, +})) +jest.mock("@app/gift-cards/list-products", () => ({ + getGiftCardProduct: (...a: unknown[]) => mockGetGiftCardProduct(...a), +})) +jest.mock("@app/gift-cards/gift-cards-master-gate", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), +})) +jest.mock("@app/gift-cards/authorize-purchase", () => ({ + authorizeGiftCardPurchase: (...a: unknown[]) => mockAuthorize(...a), + releaseGiftCardReservation: (...a: unknown[]) => mockReleaseReservation(...a), +})) +jest.mock("@services/gift-cards/registry", () => ({ + getEnabledGiftCardProvider: (...a: unknown[]) => mockGetProvider(...a), +})) +jest.mock("@services/gift-cards/claim-crypto", () => ({ + encryptGiftCardClaim: (...a: unknown[]) => mockEncrypt(...a), +})) +jest.mock("@app/gift-cards/send-fulfilled-notification", () => ({ + sendGiftCardFulfilledNotificationBestEffort: (...a: unknown[]) => + mockSendFulfilledPush(...a), +})) +jest.mock("@app/payments/pay-invoice-via-ibex", () => ({ + payLnInvoiceViaIbex: (...a: unknown[]) => mockPayInvoice(...a), +})) +jest.mock("@app/payments/authorize-send", () => ({ + authorizeSend: (...a: unknown[]) => mockAuthorizeSend(...a), + gateSend: (...a: unknown[]) => mockGateSend(...a), + SendRejectionReasons: { undecodableInvoice: "undecodable-invoice" }, +})) +jest.mock("@services/rate-limit", () => ({ + consumeLimiter: (...a: unknown[]) => mockConsumeLimiter(...a), +})) +jest.mock("@domain/rate-limit", () => ({ + RateLimitConfig: { giftCardPurchase: { key: "gift_card_purchase" } }, +})) +jest.mock("@domain/payments", () => ({ + LnPaymentRequestNonZeroAmountRequiredError: class extends Error {}, +})) +jest.mock("@domain/bitcoin/lightning", () => ({ + ...jest.requireActual("@domain/bitcoin/lightning"), + decodeInvoice: (...a: unknown[]) => mockDecodeInvoice(...a), +})) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...a: unknown[]) => mockNotifyOpsEvent(...a), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { + info: (...a: unknown[]) => mockLogger.info(...a), + warn: (...a: unknown[]) => mockLogger.warn(...a), + error: (...a: unknown[]) => mockLogger.error(...a), + child: () => mockLogger, + }, +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { purchaseGiftCard } from "@app/gift-cards/purchase-gift-card" + +import { + ACCOUNT_ID, + allMockCallText, + makeAccount, + makeFakeOrdersRepo, + makeGiftCardsConfig, + makeOrder, + makeProduct, + makeWallet, + WALLET_ID, + type FakeOrdersRepo, +} from "./fixtures" + +const PAYMENT_REQUEST = "lnbc401u1p-vendor-invoice" +const PAYMENT_HASH = "a".repeat(64) +const CLAIM_CODE = "CLAIM-CODE-SECRET-9f8e7d" +const CLAIM: GiftCardClaim = { + codes: [{ label: "Code", value: CLAIM_CODE }], + claimLink: null, + barcode: null, +} + +const VENDOR_ORDER = { + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + paymentRequest: PAYMENT_REQUEST, + amountSats: 40_100 as Satoshis, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), +} + +const decoded = (sats: number) => ({ + paymentHash: PAYMENT_HASH, + paymentAmount: { amount: BigInt(sats), currency: "BTC" }, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), +}) + +const purchase = (overrides: Record = {}) => + purchaseGiftCard({ + accountId: ACCOUNT_ID, + walletId: WALLET_ID, + productId: "bitcoinCompany:amazon-us", + valueMinor: 2500, + quantity: 1, + idempotencyKey: "idem-1", + ...overrides, + }) + +const opsPhases = () => mockNotifyOpsEvent.mock.calls.map((c) => c[0].phase) + +beforeEach(() => { + jest.clearAllMocks() + repo = makeFakeOrdersRepo() + mockConfig = makeGiftCardsConfig() + mockSendGuardMode = "off" + + mockConsumeLimiter.mockResolvedValue(true) + mockFindAccountById.mockResolvedValue(makeAccount()) + mockFindWalletById.mockResolvedValue(makeWallet()) + mockResolveCountry.mockResolvedValue("US") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockGetGiftCardProduct.mockResolvedValue(makeProduct()) + mockQuote.mockResolvedValue({ + productId: "bitcoinCompany:amazon-us", + valueMinor: 2500, + currency: "USD", + quantity: 1, + fiatCostMinor: 2500, + satsCost: 40_000, + rewardSats: 400, + bitcoinPriceMinor: null, + expiresAt: new Date(Date.now() + 60_000), + }) + mockCreateOrder.mockResolvedValue(VENDOR_ORDER) + mockGetOrder.mockResolvedValue({ kind: "fulfilled", claim: CLAIM }) + mockGetProvider.mockReturnValue({ + id: "bitcoinCompany", + quote: mockQuote, + createOrder: mockCreateOrder, + getOrder: mockGetOrder, + }) + mockAuthorize.mockResolvedValue({ authorized: true, reservationId: "res-1" }) + mockReleaseReservation.mockResolvedValue(undefined) + mockDecodeInvoice.mockReturnValue(decoded(40_100)) + mockPayInvoice.mockResolvedValue(PaymentSendStatus.Success) + mockEncrypt.mockReturnValue({ ciphertext: "ENCRYPTED", keyId: "k1" }) + mockSendFulfilledPush.mockResolvedValue(undefined) +}) + +describe("purchaseGiftCard", () => { + it("happy path: PAID, then FULFILLED on the first poll", async () => { + const res = await purchase() + if (res instanceof Error) throw res + + expect(res.status).toBe("FULFILLED") + expect(res.providerOrderId).toBe("tbc-123") + expect(res.paymentRequest).toBe(PAYMENT_REQUEST) + expect(res.paymentHash).toBe(PAYMENT_HASH) + expect(res.invoiceSats).toBe(40_100) + expect(res.paidSats).toBe(40_100) + expect(res.claimCiphertext).toBe("ENCRYPTED") + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "CREATED", + "INVOICE_ISSUED", + "PAID", + "FULFILLED", + ]) + + expect(mockCreateOrder).toHaveBeenCalledWith({ + product: makeProduct(), + valueMinor: 2500, + quantity: 1, + reference: res.id, + }) + + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + const payArgs = mockPayInvoice.mock.calls[0][0] + expect(payArgs).toMatchObject({ + paymentRequest: PAYMENT_REQUEST, + senderWalletId: WALLET_ID, + idempotencyKey: `giftcard:${res.id}`, + // Bound to the order as well as the invoice: a different order can never + // replay this one's cached success. + requestFingerprint: `ln|${PAYMENT_REQUEST}|giftcard|${res.id}`, + }) + expect(payArgs.senderAccount.id).toBe(ACCOUNT_ID) + expect(typeof payArgs.authorize).toBe("function") + + expect(mockGetOrder).toHaveBeenCalledWith({ + providerOrderId: "tbc-123", + paymentRequest: PAYMENT_REQUEST, + }) + expect(opsPhases()).toEqual(["order-created", "order-paid", "order-fulfilled"]) + expect(mockNotifyOpsEvent.mock.calls[1][0]).toMatchObject({ + flow: "giftcard", + status: "success", + accountId: ACCOUNT_ID, + amount: { value: "25.00", currency: "USD" }, + meta: { + orderId: res.id, + providerId: "bitcoinCompany", + productId: "bitcoinCompany:amazon-us", + }, + }) + }) + + it("the claim never appears in ops events, logs or the stored order", async () => { + const res = await purchase() + if (res instanceof Error) throw res + expect(JSON.stringify(repo.store.get(res.id))).not.toContain(CLAIM_CODE) + expect( + allMockCallText( + mockNotifyOpsEvent, + mockLogger.info, + mockLogger.warn, + mockLogger.error, + mockSendFulfilledPush, + mockPayInvoice, + ), + ).not.toContain(CLAIM_CODE) + }) + + it("returns PAID when the first poll says the vendor is still working", async () => { + mockGetOrder.mockResolvedValue({ kind: "paidPendingFulfillment" }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(opsPhases()).toEqual(["order-created", "order-paid"]) + }) + + it("returns PAID when the first poll errors: the worker finishes", async () => { + mockGetOrder.mockResolvedValue(new GiftCardVendorUnavailableError()) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + }) + + it("AlreadyPaid from the idempotent send is PAID", async () => { + // A replayed status comes back deserialized, not as the constant. + mockPayInvoice.mockResolvedValue({ value: "already_paid" }) + mockGetOrder.mockResolvedValue({ kind: "paidPendingFulfillment" }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + }) + + it("releases the reservation once the order row exists", async () => { + const res = await purchase() + if (res instanceof Error) throw res + expect(mockReleaseReservation).toHaveBeenCalledWith(ACCOUNT_ID, "res-1") + // After create, not before: the hold covers the authorize→create window. + const createOrder = repo.create.mock.invocationCallOrder[0] + const release = mockReleaseReservation.mock.invocationCallOrder[0] + expect(release).toBeGreaterThan(createOrder) + }) + + describe("providerPaymentRef", () => { + const ibexResponds = (status: { value: string }) => + mockPayInvoice.mockImplementation( + async (args: { onResponse?: (r: unknown) => void }) => { + args.onResponse?.({ transaction: { id: "ibex-tx-1" } }) + return status + }, + ) + + it("records the IBEX transaction id on a PAID order", async () => { + ibexResponds(PaymentSendStatus.Success) + mockGetOrder.mockResolvedValue({ kind: "paidPendingFulfillment" }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) + + it("records it on a PAYMENT_PENDING order so the worker can re-query IBEX", async () => { + ibexResponds(PaymentSendStatus.Pending) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) + + it("records it on a PAYMENT_FAILED order", async () => { + ibexResponds(PaymentSendStatus.Failure) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_FAILED") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) + + it("stays null when IBEX was not called (replay) or sent no id", async () => { + mockPayInvoice.mockImplementation( + async (args: { onResponse?: (r: unknown) => void }) => { + args.onResponse?.({ transaction: {} }) + return PaymentSendStatus.Pending + }, + ) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.providerPaymentRef).toBeNull() + }) + }) + + describe("idempotency", () => { + it("replays the existing order for the same key and parameters", async () => { + const existing = repo.seed(makeOrder({ status: "PAID", idempotencyKey: "idem-1" })) + const res = await purchase() + expect(res).toBe(existing) + expect(mockMasterGate).not.toHaveBeenCalled() + expect(repo.create).not.toHaveBeenCalled() + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("refuses the same key with different parameters", async () => { + repo.seed(makeOrder({ status: "PAID", idempotencyKey: "idem-1", valueMinor: 5000 })) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardIdempotencyKeyReuseError) + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("refuses the same key for a different product", async () => { + repo.seed( + makeOrder({ + status: "PAID", + idempotencyKey: "idem-1", + providerProductId: "netflix-us", + }), + ) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardIdempotencyKeyReuseError) + }) + + it("the same key from a different wallet is a different order", async () => { + repo.seed( + makeOrder({ + status: "PAID", + idempotencyKey: "idem-1", + walletId: "other-wallet" as WalletId, + }), + ) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + expect(repo.create).toHaveBeenCalledTimes(1) + }) + + it("a create that loses the unique-index race replays the winner", async () => { + repo.create.mockImplementationOnce(async () => { + const winner = makeOrder({ + id: "order-winner" as GiftCardOrderId, + status: "PAID", + }) + repo.seed(winner) + return new (jest.requireActual("@domain/errors").RepositoryError)("duplicate key") + }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.id).toBe("order-winner") + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockReleaseReservation).toHaveBeenCalledWith(ACCOUNT_ID, "res-1") + }) + + it("rejects a blank idempotency key", async () => { + const res = await purchase({ idempotencyKey: " " }) + expect(res).toBeInstanceOf(InvalidIdempotencyKeyError) + expect(mockFindAccountById).not.toHaveBeenCalled() + }) + }) + + describe("refusals before any order exists", () => { + // Every refusal in this block must happen before a row, a vendor order or + // a payment exists — checked once here rather than retyped per test. + const expectNoOrderWork = () => { + expect(repo.create).not.toHaveBeenCalled() + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + } + afterEach(expectNoOrderWork) + + it("rate limited", async () => { + mockConsumeLimiter.mockResolvedValue(new GiftCardPurchaseRateLimiterExceededError()) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardPurchaseRateLimiterExceededError) + expect(mockFindAccountById).not.toHaveBeenCalled() + }) + + it("a limiter store fault falls through", async () => { + mockConsumeLimiter.mockResolvedValue(new Error("redis down")) + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardsDisabledError) + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it("wallet not found", async () => { + mockFindWalletById.mockResolvedValue(new CouldNotFindWalletFromIdError()) + const res = await purchase() + expect(res).toBeInstanceOf(CouldNotFindWalletFromIdError) + }) + + it("wallet not owned by the account", async () => { + mockFindWalletById.mockResolvedValue(makeWallet({ accountId: "someone-else" })) + const res = await purchase() + expect(res).toBeInstanceOf(ValidationError) + expect(mockMasterGate).not.toHaveBeenCalled() + }) + + it("gate closed", async () => { + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardsDisabledError) + expect(mockMasterGate).toHaveBeenCalledWith("US") + expect(mockGetGiftCardProduct).not.toHaveBeenCalled() + }) + + it("product from a provider not routed for the account's country", async () => { + mockGetGiftCardProduct.mockResolvedValue( + makeProduct({ + providerId: "bitrefill", + id: "bitrefill:amazon-us" as GiftCardProductId, + }), + ) + const res = await purchase({ productId: "bitrefill:amazon-us" }) + expect(res).toBeInstanceOf(GiftCardProductNotAvailableInCountryError) + }) + + it("invalid value for the product", async () => { + const res = await purchase({ valueMinor: 100 }) // below minValue 500 + expect(res).toBeInstanceOf(GiftCardInvalidValueError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("invalid quantity", async () => { + const res = await purchase({ quantity: 11 }) + expect(res).toBeInstanceOf(GiftCardInvalidValueError) + }) + + it("limits rejection", async () => { + mockAuthorize.mockResolvedValue({ + authorized: false, + error: new GiftCardLevelNotEligibleError(), + reason: "level-not-eligible", + }) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardLevelNotEligibleError) + expect(mockQuote).toHaveBeenCalledTimes(1) + }) + }) + + describe("vendor order", () => { + it("vendor rejection -> FAILED, no payment, error returned", async () => { + mockCreateOrder.mockResolvedValue( + new GiftCardVendorRejectedOrderError("out of stock"), + ) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardVendorRejectedOrderError) + const order = [...repo.store.values()][0] + expect(order.status).toBe("FAILED") + expect(order.failureReason).toContain("GiftCardVendorRejectedOrderError") + expect(mockPayInvoice).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created", "order-failed"]) + }) + + it("vendor 5xx -> FAILED, the unavailable error is passed through", async () => { + mockCreateOrder.mockResolvedValue(new GiftCardVendorUnavailableError()) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardVendorUnavailableError) + expect([...repo.store.values()][0].status).toBe("FAILED") + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("an undecodable vendor invoice -> FAILED, no payment", async () => { + mockDecodeInvoice.mockReturnValue(new Error("bad bolt11")) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardVendorRejectedOrderError) + expect([...repo.store.values()][0].status).toBe("FAILED") + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("invoice above quote tolerance -> FAILED + GiftCardQuoteMismatchError, no payment", async () => { + // quote 40_000, tolerance 100bps -> max 40_400 + mockDecodeInvoice.mockReturnValue(decoded(40_401)) + mockCreateOrder.mockResolvedValue({ ...VENDOR_ORDER, amountSats: 40_401 }) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardQuoteMismatchError) + const order = [...repo.store.values()][0] + expect(order.status).toBe("FAILED") + expect(order.failureReason).toContain("quote-mismatch") + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("invoice exactly at tolerance is paid", async () => { + mockDecodeInvoice.mockReturnValue(decoded(40_400)) + mockCreateOrder.mockResolvedValue({ ...VENDOR_ORDER, amountSats: 40_400 }) + const res = await purchase() + if (res instanceof Error) throw res + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + }) + + it("a vendor-stated amount above the decoded one is also checked", async () => { + mockDecodeInvoice.mockReturnValue(decoded(40_000)) + mockCreateOrder.mockResolvedValue({ ...VENDOR_ORDER, amountSats: 45_000 }) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardQuoteMismatchError) + }) + + it("order expiry is capped at the vendor invoice expiry", async () => { + const soon = new Date(Date.now() + 2 * 60 * 1000) + mockCreateOrder.mockResolvedValue({ ...VENDOR_ORDER, expiresAt: soon }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.expiresAt).toEqual(soon) + }) + }) + + describe("payment outcomes", () => { + it("Failure -> PAYMENT_FAILED order returned, no vendor poll", async () => { + mockPayInvoice.mockResolvedValue(PaymentSendStatus.Failure) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_FAILED") + expect(mockGetOrder).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created", "order-failed"]) + expect(mockNotifyOpsEvent.mock.calls[1][0].meta.reason).toBe("payment-failed") + }) + + it("an error from the send -> PAYMENT_FAILED, the error is returned", async () => { + const sendError = new ValidationError("insufficient balance") + mockPayInvoice.mockResolvedValue(sendError) + const res = await purchase() + expect(res).toBe(sendError) + const order = [...repo.store.values()][0] + expect(order.status).toBe("PAYMENT_FAILED") + expect(order.failureReason).toContain("ValidationError") + expect(mockGetOrder).not.toHaveBeenCalled() + }) + + it("Pending -> PAYMENT_PENDING, no vendor poll", async () => { + mockPayInvoice.mockResolvedValue(PaymentSendStatus.Pending) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(mockGetOrder).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created", "payment-pending"]) + }) + }) + + describe("send guard hook", () => { + it("is a no-op when the send guard is off", async () => { + await purchase() + const { authorize } = mockPayInvoice.mock.calls[0][0] + await expect(authorize()).resolves.toBe(true) + expect(mockAuthorizeSend).not.toHaveBeenCalled() + }) + + it("decodes the invoice and judges the decoded sats as a lightning send", async () => { + mockSendGuardMode = "log-only" + mockAuthorizeSend.mockResolvedValue(true) + await purchase() + const { authorize } = mockPayInvoice.mock.calls[0][0] + await expect(authorize()).resolves.toBe(true) + expect(mockAuthorizeSend).toHaveBeenCalledWith({ + senderAccount: expect.objectContaining({ id: ACCOUNT_ID }), + senderWalletId: WALLET_ID, + amount: { currency: "BTC", sats: BigInt(40_100) }, + kind: "lightning", + }) + }) + + it("routes an undecodable invoice through gateSend", async () => { + mockSendGuardMode = "enforce" + await purchase() + mockDecodeInvoice.mockReturnValue(new Error("bad")) + mockGateSend.mockResolvedValue(new ValidationError("undecodable")) + const { authorize } = mockPayInvoice.mock.calls[0][0] + const out = await authorize() + expect(out).toBeInstanceOf(ValidationError) + expect(mockGateSend).toHaveBeenCalledWith( + expect.objectContaining({ reason: "undecodable-invoice", kind: "lightning" }), + ) + }) + }) +}) diff --git a/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts new file mode 100644 index 000000000..19a8c3bb2 --- /dev/null +++ b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts @@ -0,0 +1,191 @@ +import { CouldNotFindAccountFromIdError } from "@domain/errors" +import { + GiftCardInvalidValueError, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, + GiftCardProviderUnavailableError, + GiftCardsDisabledError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" + +import { quoteGiftCard } from "@app/gift-cards/quote-gift-card" + +import { ACCOUNT_ID, makeAccount, makeProduct } from "./fixtures" + +const mockFindAccountById = jest.fn() +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockGetGiftCardProduct = jest.fn() +const mockGetProvider = jest.fn() +const mockQuote = jest.fn() + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ + findById: (...a: unknown[]) => mockFindAccountById(...a), + }), +})) +jest.mock("@app/gift-cards/gift-cards-master-gate", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), +})) +jest.mock("@app/gift-cards/list-products", () => ({ + getGiftCardProduct: (...a: unknown[]) => mockGetGiftCardProduct(...a), +})) +jest.mock("@services/gift-cards/registry", () => ({ + getEnabledGiftCardProvider: (...a: unknown[]) => mockGetProvider(...a), +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +const PRODUCT = makeProduct() // variable, 500..50_000 minor, bitcoinCompany +const QUOTE: GiftCardQuote = { + productId: PRODUCT.id, + valueMinor: 2500, + currency: "USD", + quantity: 1, + fiatCostMinor: 2500, + satsCost: 40_000 as Satoshis, + rewardSats: 400 as Satoshis, + bitcoinPriceMinor: 6_250_000, + expiresAt: new Date("2026-09-10T12:00:00Z"), +} + +const quote = (overrides: Record = {}) => + quoteGiftCard({ + accountId: ACCOUNT_ID, + productId: PRODUCT.id, + valueMinor: 2500, + quantity: 1, + ...overrides, + }) + +beforeEach(() => { + jest.clearAllMocks() + mockFindAccountById.mockResolvedValue(makeAccount()) + mockResolveCountry.mockResolvedValue("US") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockGetGiftCardProduct.mockResolvedValue(PRODUCT) + mockGetProvider.mockReturnValue({ id: "bitcoinCompany", quote: mockQuote }) + mockQuote.mockResolvedValue(QUOTE) +}) + +describe("quoteGiftCard", () => { + it("returns the vendor's quote for the checked value and quantity", async () => { + const result = await quote({ valueMinor: 2500, quantity: 2 }) + + expect(result).toBe(QUOTE) + expect(mockGetProvider).toHaveBeenCalledWith("bitcoinCompany") + expect(mockQuote).toHaveBeenCalledWith({ + product: PRODUCT, + valueMinor: 2500, + quantity: 2, + }) + }) + + it("gates on the ACCOUNT's routing country, resolved from the account it loaded", async () => { + const account = makeAccount() + mockFindAccountById.mockResolvedValue(account) + mockResolveCountry.mockResolvedValue("JM") + + await quote() + + expect(mockFindAccountById).toHaveBeenCalledWith(ACCOUNT_ID) + expect(mockResolveCountry).toHaveBeenCalledWith(account) + expect(mockMasterGate).toHaveBeenCalledWith("JM") + }) + + it("propagates an account lookup failure before touching the catalog", async () => { + mockFindAccountById.mockResolvedValue(new CouldNotFindAccountFromIdError()) + + const result = await quote() + + expect(result).toBeInstanceOf(CouldNotFindAccountFromIdError) + expect(mockGetGiftCardProduct).not.toHaveBeenCalled() + }) + + it("returns the gate's error when the rail is closed, without reading the catalog", async () => { + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + const result = await quote() + + expect(result).toBeInstanceOf(GiftCardsDisabledError) + expect(mockGetGiftCardProduct).not.toHaveBeenCalled() + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("propagates a product lookup failure", async () => { + mockGetGiftCardProduct.mockResolvedValue(new GiftCardProductNotFoundError()) + + expect(await quote()).toBeInstanceOf(GiftCardProductNotFoundError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("refuses a product from a provider the account's country is not routed to", async () => { + // The exact refusal purchaseGiftCard gives, so the quote cannot price a card + // the purchase would then decline. + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ providerId: "bitrefill" })) + + expect(await quote()).toBeInstanceOf(GiftCardProductNotAvailableInCountryError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("refuses a sold-out product as not found", async () => { + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ inStock: false })) + + expect(await quote()).toBeInstanceOf(GiftCardProductNotFoundError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + describe("value and quantity validation", () => { + it("refuses a value below the card's minimum", async () => { + expect(await quote({ valueMinor: 499 })).toBeInstanceOf(GiftCardInvalidValueError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("refuses a value above the card's maximum", async () => { + expect(await quote({ valueMinor: 50_001 })).toBeInstanceOf( + GiftCardInvalidValueError, + ) + }) + + it("refuses an off-denomination value on a FIXED card", async () => { + mockGetGiftCardProduct.mockResolvedValue( + makeProduct({ + denominationType: "fixed", + denominations: [1000, 2500], + minValue: null, + maxValue: null, + }), + ) + + expect(await quote({ valueMinor: 2000 })).toBeInstanceOf(GiftCardInvalidValueError) + expect(await quote({ valueMinor: 2500 })).toBe(QUOTE) + }) + + it("refuses a non-integer or non-positive value", async () => { + expect(await quote({ valueMinor: 25.5 })).toBeInstanceOf(GiftCardInvalidValueError) + expect(await quote({ valueMinor: 0 })).toBeInstanceOf(GiftCardInvalidValueError) + }) + + it("refuses a quantity outside 1..10", async () => { + expect(await quote({ quantity: 0 })).toBeInstanceOf(GiftCardInvalidValueError) + expect(await quote({ quantity: 11 })).toBeInstanceOf(GiftCardInvalidValueError) + expect(mockQuote).not.toHaveBeenCalled() + }) + }) + + it("returns the registry's refusal when the gated provider is not enabled", async () => { + mockGetProvider.mockReturnValue(new GiftCardProviderUnavailableError()) + + expect(await quote()).toBeInstanceOf(GiftCardProviderUnavailableError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("propagates the vendor's quote failure", async () => { + mockQuote.mockResolvedValue(new GiftCardVendorUnavailableError()) + + expect(await quote()).toBeInstanceOf(GiftCardVendorUnavailableError) + }) +}) diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts new file mode 100644 index 000000000..6620c19bd --- /dev/null +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -0,0 +1,538 @@ +import { GiftCardVendorUnavailableError, UnknownGiftCardError } from "@domain/gift-cards" +import { IbexError } from "@services/ibex/errors" + +import { + __resetGiftCardReconcileStateForTest, + GIFT_CARD_PAID_TIMEOUT_MS, + GIFT_CARD_RECONCILE_LOCK_KEY, + nextGiftCardPollAt, + reconcileGiftCardOrders, + reconcileGiftCardOrdersJob, +} from "@app/gift-cards/reconcile-orders" + +import { + makeFakeOrdersRepo, + makeGiftCardsConfig, + makeOrder, + NOW_MS, + type FakeOrdersRepo, +} from "./fixtures" + +const mockGetTransactionDetails = jest.fn() +const mockFetchAndSettle = jest.fn() +const mockNotifyOpsEvent = jest.fn() +const mockRedisSet = jest.fn() +const mockRedisGet = jest.fn() +const mockRedisDel = jest.fn() +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + +let repo: FakeOrdersRepo +let mockConfig = makeGiftCardsConfig() + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockConfig + }, +})) +jest.mock("@services/mongoose", () => ({ + GiftCardOrdersRepository: () => repo, +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getTransactionDetails: (...a: unknown[]) => mockGetTransactionDetails(...a), + }, +})) +jest.mock("@app/gift-cards/settle-order", () => ({ + fetchAndSettle: (...a: unknown[]) => mockFetchAndSettle(...a), +})) +jest.mock("@services/redis", () => ({ + redis: { + set: (...a: unknown[]) => mockRedisSet(...a), + get: (...a: unknown[]) => mockRedisGet(...a), + del: (...a: unknown[]) => mockRedisDel(...a), + }, +})) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...a: unknown[]) => mockNotifyOpsEvent(...a), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { + info: (...a: unknown[]) => mockLogger.info(...a), + warn: (...a: unknown[]) => mockLogger.warn(...a), + error: (...a: unknown[]) => mockLogger.error(...a), + // Closures, not the object: `child()` runs at the module's load, which is + // before this spec's `const mockLogger` has been initialised. + child: () => ({ + info: (...a: unknown[]) => mockLogger.info(...a), + warn: (...a: unknown[]) => mockLogger.warn(...a), + error: (...a: unknown[]) => mockLogger.error(...a), + }), + }, +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const PAYMENT_HASH = "b".repeat(64) + +let clock = NOW_MS +const at = (offsetMs: number) => new Date(NOW_MS + offsetMs) +const runAt = (offsetMs: number) => { + clock = NOW_MS + offsetMs + jest.setSystemTime(clock) + return reconcileGiftCardOrders(new Date(clock)) +} + +const paidOrder = (id: string, paidAtOffsetMs = 0) => + repo.seed( + makeOrder({ + id: id as GiftCardOrderId, + status: "PAID", + providerOrderId: `tbc-${id}` as GiftCardProviderOrderId, + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + invoiceSats: 40_100 as Satoshis, + statusHistory: [ + { status: "CREATED", at: at(paidAtOffsetMs - 2000), reason: null }, + { status: "INVOICE_ISSUED", at: at(paidAtOffsetMs - 1000), reason: null }, + { status: "PAID", at: at(paidAtOffsetMs), reason: null }, + ], + updatedAt: at(paidAtOffsetMs), + }), + ) + +// The `getTransactionDetails` 200 for a sent payment: the same payment-level +// status fields the pay response carries. 1 IN_FLIGHT, 2 SUCCEEDED, 3 FAILED. +const ibexTransaction = (statusId: number) => ({ + id: "ibex-tx", + accountId: "wallet", + payment: { + hash: PAYMENT_HASH, + statusId, + status: { id: statusId }, + failureId: statusId === 3 ? 1 : 0, + }, +}) + +beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers({ now: NOW_MS }) + clock = NOW_MS + __resetGiftCardReconcileStateForTest() + repo = makeFakeOrdersRepo() + mockConfig = makeGiftCardsConfig() + mockRedisSet.mockResolvedValue("OK") + mockRedisGet.mockImplementation(async () => mockRedisSet.mock.calls[0]?.[1] ?? null) + mockRedisDel.mockResolvedValue(1) + mockGetTransactionDetails.mockResolvedValue(new IbexError(new Error("not found"))) + mockFetchAndSettle.mockImplementation(async (order: GiftCardOrder) => order) +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe("nextGiftCardPollAt", () => { + it("follows 5s, 15s, 60s, 5m, then every 15m from the PAID transition", () => { + const paid = NOW_MS + let last: number | undefined + const dueTimes: number[] = [] + for (let i = 0; i < 6; i += 1) { + const next = nextGiftCardPollAt(paid, last) + dueTimes.push(next - paid) + last = next + } + expect(dueTimes).toEqual([ + 5 * SECOND, + 20 * SECOND, + 80 * SECOND, + 380 * SECOND, + 380 * SECOND + 15 * MINUTE, + 380 * SECOND + 30 * MINUTE, + ]) + }) + + it("never schedules before the PAID transition", () => { + expect(nextGiftCardPollAt(NOW_MS, NOW_MS - HOUR)).toBe(NOW_MS + 5 * SECOND) + }) +}) + +describe("reconcileGiftCardOrders", () => { + describe("lock", () => { + it("does no work when another worker holds the lock", async () => { + mockRedisSet.mockResolvedValue(null) + paidOrder("a", -10 * SECOND) + const res = await runAt(0) + expect(res).toEqual({ + scanned: 0, + fulfilled: 0, + refundRequired: 0, + expired: 0, + paymentSettled: 0, + skipped: "lock-held", + }) + expect(repo.listByStatus).not.toHaveBeenCalled() + expect(mockRedisDel).not.toHaveBeenCalled() + }) + + it("acquires with NX + 5 minute TTL and releases only its own token", async () => { + await runAt(0) + const [key, token, px, ttl, nx] = mockRedisSet.mock.calls[0] + expect(key).toBe(GIFT_CARD_RECONCILE_LOCK_KEY) + expect(typeof token).toBe("string") + expect(px).toBe("PX") + expect(ttl).toBe(5 * MINUTE) + expect(nx).toBe("NX") + expect(mockRedisDel).toHaveBeenCalledWith(GIFT_CARD_RECONCILE_LOCK_KEY) + }) + + it("leaves a lock that now belongs to someone else", async () => { + mockRedisGet.mockResolvedValue("someone-elses-token") + await runAt(0) + expect(mockRedisDel).not.toHaveBeenCalled() + }) + + it("a Redis fault on acquire is returned, not thrown", async () => { + mockRedisSet.mockRejectedValue(new Error("ECONNREFUSED")) + const res = await runAt(0) + expect(res).toBeInstanceOf(UnknownGiftCardError) + }) + }) + + describe("expiry", () => { + it("CREATED past expiresAt -> EXPIRED", async () => { + repo.seed( + makeOrder({ id: "c" as GiftCardOrderId, status: "CREATED", expiresAt: at(-1) }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.expired).toBe(1) + expect(repo.store.get("c")?.status).toBe("EXPIRED") + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "order-failed", + meta: expect.objectContaining({ reason: "expired" }), + }), + ) + }) + + it("INVOICE_ISSUED past expiresAt with no payment record -> EXPIRED", async () => { + repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.expired).toBe(1) + expect(mockGetTransactionDetails).toHaveBeenCalledWith("ibex-tx") + expect(repo.store.get("i")?.status).toBe("EXPIRED") + }) + + it("INVOICE_ISSUED past expiresAt whose payment actually settled -> PAID, then polled", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(2)) + repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + invoiceSats: 40_100 as Satoshis, + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.expired).toBe(0) + expect(res.paymentSettled).toBe(1) + expect(repo.store.get("i")?.status).toBe("PAID") + expect(repo.store.get("i")?.paidSats).toBe(40_100) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + }) + + it("INVOICE_ISSUED past expiresAt with an in-flight payment is not expired", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(1)) + repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.expired).toBe(0) + expect(repo.store.get("i")?.status).toBe("INVOICE_ISSUED") + }) + + it("orders not yet expired are untouched", async () => { + repo.seed( + makeOrder({ id: "c" as GiftCardOrderId, status: "CREATED", expiresAt: at(+1) }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.scanned).toBe(0) + expect(repo.transition).not.toHaveBeenCalled() + }) + }) + + describe("PAYMENT_PENDING", () => { + const pending = () => + repo.seed( + makeOrder({ + id: "p" as GiftCardOrderId, + status: "PAYMENT_PENDING", + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + invoiceSats: 40_100 as Satoshis, + providerOrderId: "tbc-p" as GiftCardProviderOrderId, + }), + ) + + it("settled -> PAID and polled immediately", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(2)) + pending() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.paymentSettled).toBe(1) + expect(repo.store.get("p")?.status).toBe("PAID") + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ phase: "order-paid", status: "success" }), + ) + }) + + it("failed -> PAYMENT_FAILED", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(3)) + pending() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(repo.store.get("p")?.status).toBe("PAYMENT_FAILED") + expect(mockFetchAndSettle).not.toHaveBeenCalled() + }) + + it("pending / unknown stays PAYMENT_PENDING", async () => { + pending() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("an order with no IBEX transaction id is left alone and IBEX is not asked", async () => { + repo.seed( + makeOrder({ + id: "noref" as GiftCardOrderId, + status: "PAYMENT_PENDING", + paymentHash: PAYMENT_HASH, + providerPaymentRef: null, + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockGetTransactionDetails).not.toHaveBeenCalled() + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + }) + + it("an IBEX lookup error leaves the order pending for the next run", async () => { + mockGetTransactionDetails.mockResolvedValue(new IbexError(new Error("502"))) + pending() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "p", providerPaymentRef: "ibex-tx" }), + expect.stringContaining("Could not re-read"), + ) + }) + + it("a transaction with no recognised payment status is not treated as in flight", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(0)) + pending() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("warns once pending for over an hour", async () => { + pending() + await runAt(61 * MINUTE) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "p" }), + expect.stringContaining("pending for over an hour"), + ) + }) + }) + + describe("PAID backoff", () => { + it("polls on the 5s/15s/60s/5m/15m schedule and not in between", async () => { + paidOrder("a", 0) + const pollsAfter = async (offset: number) => { + mockFetchAndSettle.mockClear() + await runAt(offset) + return mockFetchAndSettle.mock.calls.length + } + expect(await pollsAfter(1 * SECOND)).toBe(0) + expect(await pollsAfter(5 * SECOND)).toBe(1) + expect(await pollsAfter(10 * SECOND)).toBe(0) + expect(await pollsAfter(20 * SECOND)).toBe(1) + expect(await pollsAfter(30 * SECOND)).toBe(0) + expect(await pollsAfter(80 * SECOND)).toBe(1) + expect(await pollsAfter(3 * MINUTE)).toBe(0) + expect(await pollsAfter(380 * SECOND)).toBe(1) + expect(await pollsAfter(380 * SECOND + 10 * MINUTE)).toBe(0) + expect(await pollsAfter(380 * SECOND + 15 * MINUTE)).toBe(1) + expect(await pollsAfter(380 * SECOND + 29 * MINUTE)).toBe(0) + expect(await pollsAfter(380 * SECOND + 30 * MINUTE)).toBe(1) + }) + + it("a fresh process polls a long-PAID order at most once per 15 minutes", async () => { + paidOrder("a", -2 * HOUR) + await runAt(0) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + await runAt(10 * MINUTE) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + await runAt(15 * MINUTE) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(2) + }) + + it("counts a fulfilment and stops tracking the order", async () => { + const order = paidOrder("a", -10 * SECOND) + mockFetchAndSettle.mockImplementation(async (o: GiftCardOrder) => { + const fulfilled = { ...o, status: "FULFILLED" as const } + repo.store.set(o.id, fulfilled) + return fulfilled + }) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.fulfilled).toBe(1) + expect(res.scanned).toBe(1) + expect(mockFetchAndSettle).toHaveBeenCalledWith(order) + }) + + it("counts a vendor refund", async () => { + paidOrder("a", -10 * SECOND) + mockFetchAndSettle.mockImplementation(async (o: GiftCardOrder) => ({ + ...o, + status: "REFUND_REQUIRED" as const, + })) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(1) + }) + }) + + describe("24h escalation", () => { + it("PAID for 24h -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(1) + const order = repo.store.get("old") + expect(order?.status).toBe("REFUND_REQUIRED") + expect(order?.failureReason).toBe("fulfillment-timeout") + expect(mockFetchAndSettle).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + flow: "giftcard", + phase: "refund-required", + status: "failed", + error: "fulfillment-timeout", + meta: expect.objectContaining({ orderId: "old" }), + }), + ) + }) + + it("one second short of 24h still polls", async () => { + paidOrder("almost", -(GIFT_CARD_PAID_TIMEOUT_MS - SECOND)) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(0) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + }) + }) + + describe("isolation", () => { + it("one order throwing does not stop the others", async () => { + paidOrder("bad", -10 * SECOND) + paidOrder("good", -10 * SECOND) + mockFetchAndSettle.mockImplementation(async (o: GiftCardOrder) => { + if (o.id === "bad") throw new Error("vendor exploded") + const fulfilled = { ...o, status: "FULFILLED" as const } + repo.store.set(o.id, fulfilled) + return fulfilled + }) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.scanned).toBe(2) + expect(res.fulfilled).toBe(1) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "bad" }), + expect.any(String), + ) + expect(mockRedisDel).toHaveBeenCalledTimes(1) + }) + + it("a returned vendor error is logged and counted as scanned", async () => { + paidOrder("a", -10 * SECOND) + mockFetchAndSettle.mockResolvedValue(new GiftCardVendorUnavailableError()) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.scanned).toBe(1) + expect(res.fulfilled).toBe(0) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it("a listing failure for one status does not skip the others", async () => { + repo.listByStatus.mockImplementation( + async ({ statuses }: { statuses: string[] }) => { + if (statuses.includes("PAYMENT_PENDING")) { + return new (jest.requireActual("@domain/errors").RepositoryError)("mongo") + } + return [...repo.store.values()].filter((o) => statuses.includes(o.status)) + }, + ) + paidOrder("a", -10 * SECOND) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.scanned).toBe(1) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe("reconcileGiftCardOrdersJob", () => { + it("is a no-op while gift cards are disabled", async () => { + mockConfig = makeGiftCardsConfig({ enabled: false }) + await reconcileGiftCardOrdersJob() + expect(mockRedisSet).not.toHaveBeenCalled() + }) + + it("throws the reconcile error so the cron runner counts the failure", async () => { + mockRedisSet.mockRejectedValue(new Error("ECONNREFUSED")) + await expect(reconcileGiftCardOrdersJob()).rejects.toBeInstanceOf( + UnknownGiftCardError, + ) + }) + + it("logs the summary on success", async () => { + await reconcileGiftCardOrdersJob() + expect(mockLogger.info).toHaveBeenCalledWith( + { summary: expect.objectContaining({ scanned: 0 }) }, + "gift card reconcile finished", + ) + }) +}) diff --git a/test/flash/unit/app/gift-cards/settle-order.spec.ts b/test/flash/unit/app/gift-cards/settle-order.spec.ts new file mode 100644 index 000000000..f1c43e4c5 --- /dev/null +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -0,0 +1,328 @@ +import { + GiftCardClaimCryptoError, + GiftCardOrderStateError, + GiftCardProviderUnavailableError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" + +const mockEncrypt = jest.fn() +const mockGetProvider = jest.fn() +const mockGetOrder = jest.fn() +const mockNotifyOpsEvent = jest.fn() +const mockSendFulfilledPush = jest.fn() +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + +let repo: FakeOrdersRepo + +jest.mock("@services/mongoose", () => ({ + GiftCardOrdersRepository: () => repo, +})) +jest.mock("@services/gift-cards/claim-crypto", () => ({ + encryptGiftCardClaim: (...a: unknown[]) => mockEncrypt(...a), +})) +jest.mock("@services/gift-cards/registry", () => ({ + getEnabledGiftCardProvider: (...a: unknown[]) => mockGetProvider(...a), +})) +jest.mock("@app/gift-cards/send-fulfilled-notification", () => ({ + sendGiftCardFulfilledNotificationBestEffort: (...a: unknown[]) => + mockSendFulfilledPush(...a), +})) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...a: unknown[]) => mockNotifyOpsEvent(...a), +})) +jest.mock("@services/logger", () => ({ + baseLogger: { + info: (...a: unknown[]) => mockLogger.info(...a), + warn: (...a: unknown[]) => mockLogger.warn(...a), + error: (...a: unknown[]) => mockLogger.error(...a), + child: () => mockLogger, + }, +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +import { fetchAndSettle, settleOrderFromVendor } from "@app/gift-cards/settle-order" + +import { + allMockCallText, + makeFakeOrdersRepo, + makeOrder, + NOW_MS, + type FakeOrdersRepo, +} from "./fixtures" + +const CLAIM_CODE = "CLAIM-CODE-SECRET-9f8e7d" +const CLAIM: GiftCardClaim = { + codes: [{ label: "Code", value: CLAIM_CODE }], + claimLink: "https://vendor.example/claim/SECRET-LINK-TOKEN", + barcode: null, +} +const FULFILLED = { kind: "fulfilled", claim: CLAIM } as const + +const paidOrder = (overrides: Partial = {}) => + repo.seed( + makeOrder({ + status: "PAID", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + paymentRequest: "lnbc1...", + invoiceSats: 40_100 as Satoshis, + paidSats: 40_100 as Satoshis, + statusHistory: [ + { status: "CREATED", at: new Date(NOW_MS - 3000), reason: null }, + { status: "INVOICE_ISSUED", at: new Date(NOW_MS - 2000), reason: null }, + { status: "PAID", at: new Date(NOW_MS - 1000), reason: null }, + ], + ...overrides, + }), + ) + +const opsPhases = () => mockNotifyOpsEvent.mock.calls.map((c) => c[0].phase) + +beforeEach(() => { + jest.clearAllMocks() + repo = makeFakeOrdersRepo() + mockEncrypt.mockReturnValue({ ciphertext: "ENCRYPTED", keyId: "k1" }) + mockGetOrder.mockResolvedValue(FULFILLED) + mockGetProvider.mockReturnValue({ id: "bitcoinCompany", getOrder: mockGetOrder }) + mockSendFulfilledPush.mockResolvedValue(undefined) +}) + +describe("settleOrderFromVendor", () => { + describe("fulfilled", () => { + it("PAID -> FULFILLED with the encrypted claim, an ops event and a push", async () => { + const order = paidOrder() + const res = await settleOrderFromVendor(order, FULFILLED) + + expect(res).not.toBeInstanceOf(Error) + if (res instanceof Error) return + expect(res.status).toBe("FULFILLED") + expect(res.claimCiphertext).toBe("ENCRYPTED") + expect(res.claimKeyId).toBe("k1") + expect(res.fulfilledAt).toBeInstanceOf(Date) + expect(mockEncrypt).toHaveBeenCalledWith(CLAIM) + + expect(opsPhases()).toEqual(["order-fulfilled"]) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + flow: "giftcard", + status: "success", + amount: { value: "25.00", currency: "USD" }, + meta: { orderId: order.id, providerId: "bitcoinCompany" }, + }) + expect(mockSendFulfilledPush).toHaveBeenCalledWith({ + accountId: order.accountId, + orderId: order.id, + brand: "Amazon", + valueMinor: 2500, + quantity: 1, + currency: "USD", + }) + }) + + it("never stores, logs, traces or reports the plaintext claim", async () => { + const order = paidOrder() + await settleOrderFromVendor(order, FULFILLED) + + const stored = JSON.stringify(repo.store.get(order.id)) + expect(stored).not.toContain(CLAIM_CODE) + expect(stored).not.toContain("SECRET-LINK-TOKEN") + + const transitionArgs = JSON.stringify(repo.transition.mock.calls) + expect(transitionArgs).not.toContain(CLAIM_CODE) + + const leaked = allMockCallText( + mockNotifyOpsEvent, + mockSendFulfilledPush, + mockLogger.info, + mockLogger.warn, + mockLogger.error, + ) + expect(leaked).not.toContain(CLAIM_CODE) + expect(leaked).not.toContain("SECRET-LINK-TOKEN") + }) + + it("is idempotent on an already-FULFILLED order", async () => { + const order = repo.seed( + makeOrder({ status: "FULFILLED", claimCiphertext: "OLD", claimKeyId: "k0" }), + ) + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).toBe(order) + expect(mockEncrypt).not.toHaveBeenCalled() + expect(repo.transition).not.toHaveBeenCalled() + expect(mockSendFulfilledPush).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + + it("PAYMENT_PENDING -> PAID -> FULFILLED when the vendor saw the payment first", async () => { + const order = repo.seed( + makeOrder({ + status: "PAYMENT_PENDING", + invoiceSats: 40_100 as Satoshis, + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + }), + ) + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).not.toBeInstanceOf(Error) + if (res instanceof Error) return + expect(res.status).toBe("FULFILLED") + expect(res.paidSats).toBe(40_100) + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "PAYMENT_PENDING", + "PAID", + "FULFILLED", + ]) + expect(opsPhases()).toEqual(["order-paid", "order-fulfilled"]) + }) + + it("INVOICE_ISSUED -> PAID -> FULFILLED (crash between pay and PAID)", async () => { + const order = repo.seed( + makeOrder({ + status: "INVOICE_ISSUED", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + }), + ) + const res = await settleOrderFromVendor(order, FULFILLED) + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + // No invoiceSats recorded yet, so the quote is the best record of what was paid. + expect(res.paidSats).toBe(40_000) + }) + + it("a terminal non-paid order that the vendor says is fulfilled pages", async () => { + const order = repo.seed(makeOrder({ status: "PAYMENT_FAILED" })) + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).toBeInstanceOf(GiftCardOrderStateError) + expect(mockEncrypt).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["vendor-fulfilled-unexpected"]) + expect(mockNotifyOpsEvent.mock.calls[0][0].status).toBe("failed") + }) + + it("an encryption failure leaves the order PAID and pages", async () => { + mockEncrypt.mockReturnValue(new GiftCardClaimCryptoError("no key")) + const order = paidOrder() + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).toBeInstanceOf(GiftCardClaimCryptoError) + expect(repo.store.get(order.id)?.status).toBe("PAID") + expect(repo.store.get(order.id)?.claimCiphertext).toBeNull() + expect(opsPhases()).toEqual(["claim-encrypt-failed"]) + expect(mockSendFulfilledPush).not.toHaveBeenCalled() + }) + + it("losing the FULFILLED race returns the winner's order", async () => { + const order = paidOrder() + const realTransition = repo.transition.getMockImplementation() + repo.transition.mockImplementationOnce(async (args) => { + // Another settler got there first. + await realTransition?.({ + ...args, + patch: { ...args.patch, claimCiphertext: "THEIRS" }, + }) + return new GiftCardOrderStateError("raced") + }) + const res = await settleOrderFromVendor(order, FULFILLED) + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + expect(res.claimCiphertext).toBe("THEIRS") + // The loser does not notify twice. + expect(mockSendFulfilledPush).not.toHaveBeenCalled() + }) + }) + + describe("failed / refunded", () => { + for (const kind of ["failed", "refunded"] as const) { + it(`${kind} on PAID -> REFUND_REQUIRED and pages`, async () => { + const order = paidOrder() + const res = await settleOrderFromVendor(order, { + kind, + reason: "Vendor cancelled", + }) + if (res instanceof Error) throw res + expect(res.status).toBe("REFUND_REQUIRED") + expect(res.failureReason).toBe(`vendor-${kind}: Vendor cancelled`) + expect(mockNotifyOpsEvent).toHaveBeenCalledTimes(1) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + flow: "giftcard", + phase: "refund-required", + status: "failed", + error: `vendor-${kind}`, + amount: { value: "25.00", currency: "USD" }, + meta: { orderId: order.id, reason: "Vendor cancelled" }, + }) + }) + } + + it("failed on INVOICE_ISSUED -> FAILED (nothing was paid)", async () => { + const order = repo.seed(makeOrder({ status: "INVOICE_ISSUED" })) + const res = await settleOrderFromVendor(order, { + kind: "failed", + reason: "expired", + }) + if (res instanceof Error) throw res + expect(res.status).toBe("FAILED") + expect(opsPhases()).toEqual(["order-failed"]) + }) + + it("failed on PAYMENT_PENDING is left for the payment re-read", async () => { + const order = repo.seed(makeOrder({ status: "PAYMENT_PENDING" })) + const res = await settleOrderFromVendor(order, { kind: "failed", reason: "x" }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it("refunded on an already-terminal order is a no-op", async () => { + const order = repo.seed(makeOrder({ status: "REFUND_REQUIRED" })) + const res = await settleOrderFromVendor(order, { kind: "refunded", reason: "x" }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + }) + }) + + describe("non-terminal vendor statuses", () => { + for (const kind of ["awaitingPayment", "paidPendingFulfillment"] as const) { + it(`${kind} returns the order untouched`, async () => { + const order = paidOrder() + const res = await settleOrderFromVendor(order, { kind }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + } + }) +}) + +describe("fetchAndSettle", () => { + it("asks the order's provider by providerOrderId + paymentRequest and settles", async () => { + const order = paidOrder() + const res = await fetchAndSettle(order) + if (res instanceof Error) throw res + expect(mockGetProvider).toHaveBeenCalledWith("bitcoinCompany") + expect(mockGetOrder).toHaveBeenCalledWith({ + providerOrderId: "tbc-123", + paymentRequest: "lnbc1...", + }) + expect(res.status).toBe("FULFILLED") + }) + + it("returns the provider lookup error without touching the order", async () => { + mockGetOrder.mockResolvedValue(new GiftCardVendorUnavailableError()) + const order = paidOrder() + const res = await fetchAndSettle(order) + expect(res).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("returns an error when the provider is disabled", async () => { + mockGetProvider.mockReturnValue(new GiftCardProviderUnavailableError()) + const res = await fetchAndSettle(paidOrder()) + expect(res).toBeInstanceOf(GiftCardProviderUnavailableError) + expect(mockGetOrder).not.toHaveBeenCalled() + }) + + it("refuses an order with no provider order id", async () => { + const res = await fetchAndSettle(paidOrder({ providerOrderId: null })) + expect(res).toBeInstanceOf(GiftCardOrderStateError) + expect(mockGetOrder).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/gift-cards/sync-catalog.spec.ts b/test/flash/unit/app/gift-cards/sync-catalog.spec.ts new file mode 100644 index 000000000..b3072d0fa --- /dev/null +++ b/test/flash/unit/app/gift-cards/sync-catalog.spec.ts @@ -0,0 +1,408 @@ +const mockNotifyOpsEvent = jest.fn() +const mockWrite = jest.fn() +const mockEnabledProviders = jest.fn() +const mockRedisSet = jest.fn() +const mockRedisEval = jest.fn() +const mockRedisDel = jest.fn() + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: unknown }) => fns, + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...args: unknown[]) => mockNotifyOpsEvent(...args), +})) + +jest.mock("@services/gift-cards", () => ({ + enabledGiftCardProviders: (...args: unknown[]) => mockEnabledProviders(...args), +})) + +jest.mock("@services/gift-cards/catalog-cache", () => ({ + GiftCardCatalogCache: () => ({ + write: (...args: unknown[]) => mockWrite(...args), + }), +})) + +jest.mock("@services/redis", () => ({ + redis: { + set: (...args: unknown[]) => mockRedisSet(...args), + eval: (...args: unknown[]) => mockRedisEval(...args), + del: (...args: unknown[]) => mockRedisDel(...args), + }, +})) + +import { + GIFT_CARD_CATALOG_SYNC_LOCK_KEY, + GIFT_CARD_CATALOG_SYNC_LOCK_TTL_SECONDS, + GIFT_CARD_CATALOG_SYNC_MARKER_KEY, + syncGiftCardCatalogForProvider, + syncGiftCardCatalogs, +} from "@app/gift-cards/sync-catalog" +import { + GiftCardCatalogUnavailableError, + GiftCardVendorUnavailableError, + UnknownGiftCardError, +} from "@domain/gift-cards" +import { baseLogger } from "@services/logger" + +const product = ( + providerId: GiftCardProviderId, + id: string, + countryCode = "US", +): GiftCardProduct => ({ + id: `${providerId}:${id}` as GiftCardProductId, + providerId, + providerProductId: id, + name: `${id} card`, + brand: id, + countryCode, + currency: "USD", + denominationType: "fixed", + denominations: [2500], + minValue: null, + maxValue: null, + isOpenLoop: false, + categories: [], + logoUrl: null, + termsUrl: null, + rewardBps: 0, + inStock: true, +}) + +const provider = ( + id: GiftCardProviderId, + listProducts: jest.Mock, +): IGiftCardProvider & { listProducts: jest.Mock } => ({ + id, + listProducts, + quote: jest.fn(), + createOrder: jest.fn(), + getOrder: jest.fn(), +}) + +const BITCOIN_COMPANY_PRODUCTS = [ + product("bitcoinCompany", "amazon", "US"), + product("bitcoinCompany", "digicel", "JM"), +] + +const opsEvents = () => mockNotifyOpsEvent.mock.calls.map((call) => call[0]) + +// The lock's SET and the marker's SET go through the same mock; tell them apart +// by key so a test can hold one and grant the other. +const grantRedisSet = (answers: { lock?: "OK" | null; marker?: "OK" | null } = {}) => { + // `null` is Redis saying "NX: already set", so it must survive — no `??` here. + mockRedisSet.mockImplementation(async (key: string) => { + if (key === GIFT_CARD_CATALOG_SYNC_LOCK_KEY) { + return answers.lock === undefined ? "OK" : answers.lock + } + if (key === GIFT_CARD_CATALOG_SYNC_MARKER_KEY) { + return answers.marker === undefined ? "OK" : answers.marker + } + return "OK" + }) +} + +beforeEach(() => { + jest.clearAllMocks() + grantRedisSet() + mockRedisEval.mockResolvedValue(1) + mockRedisDel.mockResolvedValue(1) + mockWrite.mockImplementation(async ({ products }: { products: GiftCardProduct[] }) => ({ + countries: [...new Set(products.map((p) => p.countryCode))], + })) + mockEnabledProviders.mockReturnValue([]) +}) + +describe("syncGiftCardCatalogForProvider", () => { + it("writes the vendor catalog to the cache and reports the counts", async () => { + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + + const res = await syncGiftCardCatalogForProvider(tbc) + + expect(res).toEqual({ + providerId: "bitcoinCompany", + products: 2, + countries: 2, + durationMs: expect.any(Number), + }) + expect(mockWrite).toHaveBeenCalledWith({ + providerId: "bitcoinCompany", + products: BITCOIN_COMPANY_PRODUCTS, + syncedAt: expect.any(Date), + }) + expect(opsEvents()).toEqual([ + { + flow: "giftcard", + phase: "catalog-synced", + status: "success", + meta: expect.objectContaining({ + providerId: "bitcoinCompany", + products: "2", + countries: "2", + }), + }, + ]) + }) + + it("reports a vendor failure, writes nothing, and returns the error", async () => { + const vendorDown = new GiftCardVendorUnavailableError() + const tbc = provider("bitcoinCompany", jest.fn().mockResolvedValue(vendorDown)) + + const res = await syncGiftCardCatalogForProvider(tbc) + + expect(res).toBe(vendorDown) + expect(mockWrite).not.toHaveBeenCalled() + expect(opsEvents()).toEqual([ + { + flow: "giftcard", + phase: "catalog-sync-failed", + status: "failed", + error: "GiftCardVendorUnavailableError", + meta: { providerId: "bitcoinCompany" }, + }, + ]) + }) + + it("turns an adapter that THROWS into UnknownGiftCardError instead of propagating", async () => { + // The port contract is return-not-throw; a vendor SDK bug that throws anyway + // must still surface as one provider's failure, not as a crash of the run. + const tbc = provider( + "bitcoinCompany", + jest.fn().mockRejectedValue(new Error("ECONNRESET")), + ) + + const res = await syncGiftCardCatalogForProvider(tbc) + + expect(res).toBeInstanceOf(UnknownGiftCardError) + expect(opsEvents()[0]).toMatchObject({ + phase: "catalog-sync-failed", + status: "failed", + error: "UnknownGiftCardError", + }) + }) + + it("reports a cache write failure as a failed sync", async () => { + const cacheDown = new GiftCardCatalogUnavailableError() + mockWrite.mockResolvedValue(cacheDown) + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + + const res = await syncGiftCardCatalogForProvider(tbc) + + expect(res).toBe(cacheDown) + expect(opsEvents()[0]).toMatchObject({ + phase: "catalog-sync-failed", + error: "GiftCardCatalogUnavailableError", + }) + }) +}) + +describe("syncGiftCardCatalogs", () => { + it("syncs every enabled provider even when one fails, with an ops event for each", async () => { + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + const bitrefill = provider( + "bitrefill", + jest.fn().mockResolvedValue(new GiftCardVendorUnavailableError()), + ) + mockEnabledProviders.mockReturnValue([bitrefill, tbc]) + + const summaries = await syncGiftCardCatalogs() + + expect(bitrefill.listProducts).toHaveBeenCalledTimes(1) + expect(tbc.listProducts).toHaveBeenCalledTimes(1) + expect(summaries).toEqual([ + expect.objectContaining({ + providerId: "bitcoinCompany", + products: 2, + countries: 2, + }), + ]) + expect(opsEvents()).toEqual([ + expect.objectContaining({ + phase: "catalog-sync-failed", + status: "failed", + meta: { providerId: "bitrefill" }, + }), + expect.objectContaining({ + phase: "catalog-synced", + status: "success", + meta: expect.objectContaining({ providerId: "bitcoinCompany" }), + }), + ]) + }) + + it("takes the distributed lock with a 10 minute TTL and releases only its own token", async () => { + mockEnabledProviders.mockReturnValue([]) + + await syncGiftCardCatalogs() + + const lockCall = mockRedisSet.mock.calls.find( + (call) => call[0] === GIFT_CARD_CATALOG_SYNC_LOCK_KEY, + ) + expect(lockCall).toEqual([ + GIFT_CARD_CATALOG_SYNC_LOCK_KEY, + expect.any(String), + "EX", + GIFT_CARD_CATALOG_SYNC_LOCK_TTL_SECONDS, + "NX", + ]) + expect(GIFT_CARD_CATALOG_SYNC_LOCK_TTL_SECONDS).toBe(600) + + // Owner-checked release: the script compares the stored value to OUR token. + const token = lockCall?.[1] + expect(mockRedisEval).toHaveBeenCalledTimes(1) + expect(mockRedisEval).toHaveBeenCalledWith( + expect.stringContaining("del"), + 1, + GIFT_CARD_CATALOG_SYNC_LOCK_KEY, + token, + ) + }) + + it("does no work when another run holds the lock", async () => { + grantRedisSet({ lock: null }) + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + mockEnabledProviders.mockReturnValue([tbc]) + + const summaries = await syncGiftCardCatalogs() + + expect(summaries).toEqual([]) + expect(tbc.listProducts).not.toHaveBeenCalled() + expect(mockWrite).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + // Not ours to release. + expect(mockRedisEval).not.toHaveBeenCalled() + expect(baseLogger.info).toHaveBeenCalledWith( + expect.stringContaining("holds the lock"), + ) + }) + + it("releases the lock even when every provider fails", async () => { + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(new GiftCardVendorUnavailableError()), + ) + mockEnabledProviders.mockReturnValue([tbc]) + + const summaries = await syncGiftCardCatalogs() + + expect(summaries).toEqual([]) + expect(mockRedisEval).toHaveBeenCalledTimes(1) + }) + + it("skips, without throwing, when Redis is unreachable for the lock", async () => { + mockRedisSet.mockRejectedValue(new Error("ECONNREFUSED")) + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + mockEnabledProviders.mockReturnValue([tbc]) + + await expect(syncGiftCardCatalogs()).resolves.toEqual([]) + + expect(tbc.listProducts).not.toHaveBeenCalled() + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("lock unavailable"), + ) + }) + + it("never throws, and still releases the lock, when the registry itself blows up", async () => { + mockEnabledProviders.mockImplementation(() => { + throw new Error("registry exploded") + }) + + await expect(syncGiftCardCatalogs()).resolves.toEqual([]) + + expect(mockRedisEval).toHaveBeenCalledTimes(1) + expect(baseLogger.error).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("crashed"), + ) + }) + + describe("minIntervalSeconds", () => { + const INTERVAL = 21600 + + it("claims the interval marker for exactly the interval when a run is due", async () => { + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + mockEnabledProviders.mockReturnValue([tbc]) + + const summaries = await syncGiftCardCatalogs({ minIntervalSeconds: INTERVAL }) + + expect(summaries).toHaveLength(1) + expect(mockRedisSet).toHaveBeenCalledWith( + GIFT_CARD_CATALOG_SYNC_MARKER_KEY, + expect.any(String), + "EX", + INTERVAL, + "NX", + ) + // A successful run keeps the marker so the next tick waits out the interval. + expect(mockRedisDel).not.toHaveBeenCalled() + }) + + it("skips the run when a sync finished within the interval", async () => { + grantRedisSet({ marker: null }) + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(BITCOIN_COMPANY_PRODUCTS), + ) + mockEnabledProviders.mockReturnValue([tbc]) + + const summaries = await syncGiftCardCatalogs({ minIntervalSeconds: INTERVAL }) + + expect(summaries).toEqual([]) + expect(tbc.listProducts).not.toHaveBeenCalled() + // The lock WAS ours this time, so it is released. + expect(mockRedisEval).toHaveBeenCalledTimes(1) + expect(baseLogger.info).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("within the interval"), + ) + }) + + it("frees the marker when nothing synced so the next tick retries instead of waiting", async () => { + const tbc = provider( + "bitcoinCompany", + jest.fn().mockResolvedValue(new GiftCardVendorUnavailableError()), + ) + mockEnabledProviders.mockReturnValue([tbc]) + + await syncGiftCardCatalogs({ minIntervalSeconds: INTERVAL }) + + expect(mockRedisDel).toHaveBeenCalledWith(GIFT_CARD_CATALOG_SYNC_MARKER_KEY) + }) + + it("does not touch the marker when the interval is not requested", async () => { + mockEnabledProviders.mockReturnValue([]) + + await syncGiftCardCatalogs() + + const markerCalls = mockRedisSet.mock.calls.filter( + (call) => call[0] === GIFT_CARD_CATALOG_SYNC_MARKER_KEY, + ) + expect(markerCalls).toHaveLength(0) + }) + }) +}) diff --git a/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts b/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts new file mode 100644 index 000000000..b4aa42320 --- /dev/null +++ b/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts @@ -0,0 +1,147 @@ +// The IBEX-custodial pay rail lifted out of lnInvoicePaymentSend. The +// idempotency wrapper is mocked to a faithful "authorize, then execute" so the +// spec can pin what this module hands it and in what order, without re-testing +// the wrapper (see idempotency.spec.ts for that). + +const mockPayInvoice = jest.fn() +const mockWithPaymentIdempotency = jest.fn() + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { payInvoice: (...a: unknown[]) => mockPayInvoice(...a) }, +})) +jest.mock("@app/payments/idempotency", () => ({ + withPaymentIdempotency: (...a: unknown[]) => mockWithPaymentIdempotency(...a), +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + addEventToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { ValidationError } from "@domain/shared" +import { IbexError } from "@services/ibex/errors" +import { payLnInvoiceViaIbex } from "@app/payments/pay-invoice-via-ibex" + +const WALLET_ID = "6a8203ce490716aa69381455" as WalletId +const ACCOUNT = { id: "6a8203ce490716aa69381454" } as unknown as Account +const PAYMENT_REQUEST = "lnbc401u1p-vendor-invoice" + +const ibex200 = (statusId: number) => ({ + status: statusId, + transaction: { + id: "ibex-tx-1", + payment: { hash: "a".repeat(64), statusId, status: { id: statusId } }, + }, +}) + +type WrapperArgs = { + idempotencyKey: string + senderWalletId: string + requestFingerprint: string + authorize: () => Promise + execute: () => Promise +} + +const pay = (overrides: Record = {}) => + payLnInvoiceViaIbex({ + senderWalletId: WALLET_ID, + senderAccount: ACCOUNT, + paymentRequest: PAYMENT_REQUEST, + idempotencyKey: "giftcard:order-1", + requestFingerprint: `ln|${PAYMENT_REQUEST}|giftcard|order-1`, + authorize: async () => true, + ...overrides, + }) + +beforeEach(() => { + jest.clearAllMocks() + // Faithful to the real wrapper's ordering on the execute path: the guard + // runs first and a rejection short-circuits without executing. + mockWithPaymentIdempotency.mockImplementation(async (args: WrapperArgs) => { + const authorized = await args.authorize() + if (authorized instanceof Error) return authorized + return args.execute() + }) + mockPayInvoice.mockResolvedValue(ibex200(2)) +}) + +describe("payLnInvoiceViaIbex", () => { + it("pays the bolt11 from the wallet's IBEX account and reports Success", async () => { + const res = await pay() + expect(res).toBe(PaymentSendStatus.Success) + // The IBEX account id IS the Flash wallet id on this rail. + expect(mockPayInvoice).toHaveBeenCalledWith({ + invoice: PAYMENT_REQUEST, + accountId: WALLET_ID, + }) + }) + + it("reports Pending for an in-flight payment", async () => { + mockPayInvoice.mockResolvedValue(ibex200(1)) + await expect(pay()).resolves.toBe(PaymentSendStatus.Pending) + }) + + it("reports Pending, not an error, for a response with no recognised status", async () => { + mockPayInvoice.mockResolvedValue({}) + await expect(pay()).resolves.toBe(PaymentSendStatus.Pending) + }) + + it("passes an IbexError through as-is and does not call onResponse", async () => { + const error = new IbexError(new Error("upstream 502")) + mockPayInvoice.mockResolvedValue(error) + const onResponse = jest.fn() + const res = await pay({ onResponse }) + expect(res).toBe(error) + expect(onResponse).not.toHaveBeenCalled() + }) + + it("hands the raw 200 to onResponse before reducing it to a status", async () => { + const onResponse = jest.fn() + await pay({ onResponse }) + expect(onResponse).toHaveBeenCalledTimes(1) + expect(onResponse.mock.calls[0][0].transaction.id).toBe("ibex-tx-1") + }) + + describe("idempotency wrapper", () => { + it("is handed the key, wallet, fingerprint and guard unchanged", async () => { + const authorize = jest.fn(async () => true as const) + await pay({ authorize }) + expect(mockWithPaymentIdempotency).toHaveBeenCalledTimes(1) + const args = mockWithPaymentIdempotency.mock.calls[0][0] as WrapperArgs + expect(args).toMatchObject({ + idempotencyKey: "giftcard:order-1", + senderWalletId: WALLET_ID, + requestFingerprint: `ln|${PAYMENT_REQUEST}|giftcard|order-1`, + }) + expect(args.authorize).toBe(authorize) + expect(typeof args.execute).toBe("function") + }) + + it("runs the guard inside the wrapper, before IBEX is called", async () => { + const authorize = jest.fn(async () => true as const) + await pay({ authorize }) + expect(authorize).toHaveBeenCalledTimes(1) + expect(authorize.mock.invocationCallOrder[0]).toBeLessThan( + mockPayInvoice.mock.invocationCallOrder[0], + ) + }) + + it("a guard rejection returns the error and never reaches IBEX", async () => { + const rejection = new ValidationError("over limit") + const res = await pay({ authorize: async () => rejection }) + expect(res).toBe(rejection) + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("a replayed result never calls IBEX or onResponse", async () => { + mockWithPaymentIdempotency.mockResolvedValue({ value: "success" }) + const onResponse = jest.fn() + const res = await pay({ onResponse }) + expect(res).toEqual({ value: "success" }) + expect(mockPayInvoice).not.toHaveBeenCalled() + expect(onResponse).not.toHaveBeenCalled() + }) + }) +}) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts new file mode 100644 index 000000000..cf9a6b248 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts @@ -0,0 +1,738 @@ +const mockRedisStore = new Map() + +jest.mock("axios", () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: jest.fn((err) => Boolean(err?.isAxiosError)), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: unknown }) => fns, +})) + +jest.mock("@config", () => ({ + GiftCardsConfig: jest.requireActual("./fixtures").giftCardsConfigFixture, +})) + +// In-memory stand-in for the real ioredis client, honouring the subset the +// token store uses: GET, SET [PX ttl] [NX], DEL, and the compare-and-delete EVAL. +jest.mock("@services/redis", () => ({ + redis: { + get: async (key: string) => mockRedisStore.get(key) ?? null, + set: async (key: string, value: string, ...opts: unknown[]) => { + if (opts.includes("NX") && mockRedisStore.has(key)) return null + mockRedisStore.set(key, value) + return "OK" + }, + del: async (key: string) => (mockRedisStore.delete(key) ? 1 : 0), + eval: async (_script: string, _numKeys: number, key: string, nonce: string) => { + if (mockRedisStore.get(key) === nonce) { + mockRedisStore.delete(key) + return 1 + } + return 0 + }, + }, +})) + +import axios from "axios" + +import { + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" +import { + BITCOIN_COMPANY_AUTH_CACHE_KEY, + BITCOIN_COMPANY_AUTH_LOCK_KEY, + BitcoinCompanyClient, + BitcoinCompanyClientDeps, + CATALOG_PAGE_SIZE, + RETRY_MAX, + redactForLog, +} from "@services/gift-cards/bitcoin-company/client" +import { baseLogger } from "@services/logger" +import { recordExceptionInCurrentSpan } from "@services/tracing" + +import { + ACCESS_TOKEN_1, + ACCESS_TOKEN_2, + CLAIM_CODE, + CLAIM_LINK, + FULFILLED_RESULT, + INVOICE, + LOGIN_RESULT, + NOW, + PURCHASE_RESULT, + QUOTE_RESULT, + REFRESH_RESULT, + REFRESH_TOKEN_1, + SECRET_STRINGS, + bearerOf, + bitcoinCompanyConfigFixture, + callsTo, + catalogPage, + failThen, + httpError, + httpOk, + makeCatalog, + networkError, + routeGet, + routePost, + sequence, + vendorProductFixture, +} from "./fixtures" + +const mockedAxios = axios as unknown as { get: jest.Mock; post: jest.Mock } +const mockedLogger = baseLogger as unknown as Record< + "info" | "warn" | "error" | "debug", + jest.Mock +> +const mockedRecordException = recordExceptionInCurrentSpan as jest.Mock + +const ONE_MINUTE = 60 * 1000 +const ONE_HOUR = 60 * ONE_MINUTE + +const noSleep = () => Promise.resolve() + +const makeClient = (overrides: BitcoinCompanyClientDeps = {}) => + new BitcoinCompanyClient({ + sleep: noSleep, + now: () => NOW, + random: () => 0, + ...overrides, + }) + +type Tokens = { accessToken: string; refreshToken: string; accessExpiresAt: number } + +const seedTokens = (tokens: Tokens) => + mockRedisStore.set(BITCOIN_COMPANY_AUTH_CACHE_KEY, JSON.stringify(tokens)) + +const cachedTokens = (): Tokens | null => { + const raw = mockRedisStore.get(BITCOIN_COMPANY_AUTH_CACHE_KEY) + return raw ? JSON.parse(raw) : null +} + +const FRESH_TOKENS: Tokens = { + accessToken: ACCESS_TOKEN_1, + refreshToken: REFRESH_TOKEN_1, + accessExpiresAt: NOW + ONE_HOUR, +} + +const STALE_TOKENS: Tokens = { + accessToken: "stale-access-token", + refreshToken: REFRESH_TOKEN_1, + accessExpiresAt: NOW + 4 * ONE_MINUTE, +} + +const QUOTE_ARGS = { productId: "prod-amazon-us", cardValue: 25, quantity: 1 } +const PURCHASE_ARGS = { ...QUOTE_ARGS, label: "gco_0001" } + +const loginRoute = { "/auth/login": () => httpOk(LOGIN_RESULT) } +const quoteRoute = { "/svs/quote-card": () => httpOk(QUOTE_RESULT) } +const refreshRoute = { "/auth/refresh-token": () => httpOk(REFRESH_RESULT) } + +beforeEach(() => { + jest.clearAllMocks() + mockedAxios.get.mockReset() + mockedAxios.post.mockReset() + mockRedisStore.clear() +}) + +describe("BitcoinCompanyClient auth", () => { + it("logs in once and caches the token pair in Redis", async () => { + mockedAxios.post.mockImplementation(routePost({ ...loginRoute, ...quoteRoute })) + const client = makeClient() + + const first = await client.quoteCard(QUOTE_ARGS) + const second = await client.quoteCard(QUOTE_ARGS) + + expect(first).toEqual(QUOTE_RESULT) + expect(second).toEqual(QUOTE_RESULT) + + const logins = callsTo(mockedAxios.post, "/auth/login") + expect(logins).toHaveLength(1) + expect(logins[0][1]).toEqual({ + email: bitcoinCompanyConfigFixture.email, + password: bitcoinCompanyConfigFixture.password, + }) + expect(bearerOf(logins[0][2])).toBeNull() + + expect(cachedTokens()).toEqual({ + accessToken: ACCESS_TOKEN_1, + refreshToken: REFRESH_TOKEN_1, + accessExpiresAt: NOW + ONE_HOUR, + }) + + const quotes = callsTo(mockedAxios.post, "/svs/quote-card") + expect(quotes).toHaveLength(2) + expect(quotes.map((call) => bearerOf(call[2]))).toEqual([ + `Bearer ${ACCESS_TOKEN_1}`, + `Bearer ${ACCESS_TOKEN_1}`, + ]) + expect(mockRedisStore.has(BITCOIN_COMPANY_AUTH_LOCK_KEY)).toBe(false) + }) + + it("uses a cached access token without touching the auth endpoints", async () => { + seedTokens(FRESH_TOKENS) + mockedAxios.post.mockImplementation(routePost(quoteRoute)) + + await makeClient().quoteCard(QUOTE_ARGS) + + expect(mockedAxios.get).not.toHaveBeenCalled() + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(0) + expect(bearerOf(callsTo(mockedAxios.post, "/svs/quote-card")[0][2])).toBe( + `Bearer ${ACCESS_TOKEN_1}`, + ) + }) + + it("refreshes when the cached access token has under five minutes left", async () => { + seedTokens(STALE_TOKENS) + mockedAxios.get.mockImplementation(routeGet(refreshRoute)) + mockedAxios.post.mockImplementation(routePost(quoteRoute)) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual(QUOTE_RESULT) + const refreshes = callsTo(mockedAxios.get, "/auth/refresh-token") + expect(refreshes).toHaveLength(1) + expect(bearerOf(refreshes[0][1])).toBe(`Bearer ${REFRESH_TOKEN_1}`) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(0) + expect(bearerOf(callsTo(mockedAxios.post, "/svs/quote-card")[0][2])).toBe( + `Bearer ${ACCESS_TOKEN_2}`, + ) + expect(cachedTokens()).toEqual({ + accessToken: REFRESH_RESULT.accessToken, + refreshToken: REFRESH_RESULT.refreshToken, + accessExpiresAt: NOW + ONE_HOUR, + }) + }) + + it("on a 401 refreshes once and retries the call once", async () => { + seedTokens(FRESH_TOKENS) + mockedAxios.get.mockImplementation(routeGet(refreshRoute)) + mockedAxios.post.mockImplementation( + routePost({ + "/svs/quote-card": sequence(httpError(401, "Unauthorized"), httpOk(QUOTE_RESULT)), + }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual(QUOTE_RESULT) + const quotes = callsTo(mockedAxios.post, "/svs/quote-card") + expect(quotes).toHaveLength(2) + expect(quotes.map((call) => bearerOf(call[2]))).toEqual([ + `Bearer ${ACCESS_TOKEN_1}`, + `Bearer ${ACCESS_TOKEN_2}`, + ]) + expect(callsTo(mockedAxios.get, "/auth/refresh-token")).toHaveLength(1) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(0) + }) + + it("does not retry a second 401 after refreshing", async () => { + seedTokens(FRESH_TOKENS) + mockedAxios.get.mockImplementation(routeGet(refreshRoute)) + mockedAxios.post.mockImplementation( + routePost({ "/svs/quote-card": () => httpError(401, "Unauthorized") }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/svs/quote-card")).toHaveLength(2) + expect(callsTo(mockedAxios.get, "/auth/refresh-token")).toHaveLength(1) + }) + + it("falls back to login when the refresh token is rejected", async () => { + seedTokens(STALE_TOKENS) + mockedAxios.get.mockImplementation( + routeGet({ "/auth/refresh-token": () => httpError(401, "refresh token expired") }), + ) + mockedAxios.post.mockImplementation(routePost({ ...loginRoute, ...quoteRoute })) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual(QUOTE_RESULT) + expect(callsTo(mockedAxios.get, "/auth/refresh-token")).toHaveLength(1) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(1) + expect(bearerOf(callsTo(mockedAxios.post, "/svs/quote-card")[0][2])).toBe( + `Bearer ${ACCESS_TOKEN_1}`, + ) + expect(cachedTokens()).toEqual({ + accessToken: ACCESS_TOKEN_1, + refreshToken: REFRESH_TOKEN_1, + accessExpiresAt: NOW + ONE_HOUR, + }) + }) + + it("falls back to login when the refresh call fails on the network", async () => { + seedTokens(STALE_TOKENS) + mockedAxios.get.mockRejectedValue(networkError("ETIMEDOUT")) + mockedAxios.post.mockImplementation(routePost({ ...loginRoute, ...quoteRoute })) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual(QUOTE_RESULT) + // Refresh is never retried: a rotated refresh token makes a replay harmful. + expect(callsTo(mockedAxios.get, "/auth/refresh-token")).toHaveLength(1) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(1) + }) + + it("returns vendor-unavailable and caches nothing when login is rejected", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/auth/login": () => httpError(401, "Invalid credentials") }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(1) + expect(callsTo(mockedAxios.post, "/svs/quote-card")).toHaveLength(0) + expect(cachedTokens()).toBeNull() + expect(mockRedisStore.has(BITCOIN_COMPANY_AUTH_LOCK_KEY)).toBe(false) + }) + + it("refuses to call the vendor when credentials are not configured", async () => { + mockedAxios.post.mockImplementation(routePost({ ...loginRoute, ...quoteRoute })) + const client = makeClient({ + getConfig: () => ({ ...bitcoinCompanyConfigFixture, email: "", password: "" }), + }) + + const result = await client.quoteCard(QUOTE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(mockedAxios.post).not.toHaveBeenCalled() + }) + + it("serialises concurrent token acquisition through the Redis lock", async () => { + mockedAxios.post.mockImplementation(routePost({ ...loginRoute, ...quoteRoute })) + // A real yield, so the waiter sees the holder finish before polling again. + const client = makeClient({ + sleep: () => new Promise((resolve) => setImmediate(resolve)), + }) + + const results = await Promise.all([ + client.quoteCard(QUOTE_ARGS), + client.quoteCard(QUOTE_ARGS), + client.quoteCard(QUOTE_ARGS), + ]) + + expect(results).toEqual([QUOTE_RESULT, QUOTE_RESULT, QUOTE_RESULT]) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(1) + expect(mockRedisStore.has(BITCOIN_COMPANY_AUTH_LOCK_KEY)).toBe(false) + }) + + it("still works when Redis is unavailable, by logging in each time", async () => { + mockedAxios.post.mockImplementation(routePost({ ...loginRoute, ...quoteRoute })) + const failing = async () => { + throw new Error("ECONNREFUSED redis") + } + const client = makeClient({ + tokenStore: { read: failing, write: failing, clear: failing, tryLock: failing }, + }) + + const result = await client.quoteCard(QUOTE_ARGS) + + expect(result).toEqual(QUOTE_RESULT) + expect(callsTo(mockedAxios.post, "/auth/login")).toHaveLength(1) + }) +}) + +describe("BitcoinCompanyClient catalog", () => { + it("pages through the catalog until a short page (500 + 355 = 855)", async () => { + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => { + const offset = Number(ctx.query.get("offset")) + return catalogPage( + offset === 0 ? makeCatalog(CATALOG_PAGE_SIZE, 0) : makeCatalog(355, offset), + ) + }, + }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + expect(result).toHaveLength(855) + expect(new Set(result.map((p) => p.id)).size).toBe(855) + + const pages = callsTo(mockedAxios.get, "/giftcards") + expect(pages).toHaveLength(2) + expect(pages.map(([url]) => new URL(url).searchParams.get("offset"))).toEqual([ + "0", + "500", + ]) + expect(pages.map(([url]) => new URL(url).searchParams.get("size"))).toEqual([ + "500", + "500", + ]) + // Public endpoint: no auth round-trip. + expect(bearerOf(pages[0][1])).toBeNull() + expect(mockedAxios.post).not.toHaveBeenCalled() + }) + + it("stops after a full page followed by an empty one", async () => { + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => + catalogPage( + Number(ctx.query.get("offset")) === 0 ? makeCatalog(CATALOG_PAGE_SIZE) : [], + ), + }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + expect(result).toHaveLength(CATALOG_PAGE_SIZE) + expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(2) + }) + + it("skips catalog rows that fail validation instead of failing the sync", async () => { + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": () => + catalogPage([ + vendorProductFixture(), + { id: 42, name: "broken" }, + vendorProductFixture({ + id: "prod-2", + isOpenLoop: "yes" as unknown as boolean, + }), + ]), + }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + expect(result.map((p) => p.id)).toEqual(["prod-amazon-us"]) + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ op: "listProducts", skipped: 2, kept: 1 }), + expect.any(String), + ) + }) + + it("retries a GET after a network error and then succeeds", async () => { + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": failThen( + [networkError("ECONNRESET")], + catalogPage([vendorProductFixture()]), + ), + }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + expect(result).toHaveLength(1) + expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(2) + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ op: "listProducts", attempt: 1 }), + expect.any(String), + ) + }) + + it("gives up after the retry budget on a persistent 5xx", async () => { + mockedAxios.get.mockImplementation( + routeGet({ "/giftcards": () => httpError(503, "Service Unavailable") }), + ) + + const result = await makeClient().listProducts() + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(RETRY_MAX + 1) + }) + + it("does not retry a 4xx on a GET", async () => { + mockedAxios.get.mockImplementation( + routeGet({ "/giftcards": () => httpError(400, "Bad Request") }), + ) + + const result = await makeClient().listProducts() + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(1) + }) +}) + +describe("BitcoinCompanyClient purchase", () => { + beforeEach(() => seedTokens(FRESH_TOKENS)) + + it("sends the documented purchase body and returns the validated result", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/giftcards/purchase/bitcoin": () => httpOk(PURCHASE_RESULT) }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toEqual(PURCHASE_RESULT) + const [, body] = callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")[0] + expect(body).toEqual({ + productId: "prod-amazon-us", + cardValue: 25, + quantity: 1, + useUsdBalance: false, + useSatsBalance: false, + label: "gco_0001", + }) + }) + + it("never retries a purchase on a 5xx", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/purchase/bitcoin": () => httpError(500, "Internal Server Error"), + }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")).toHaveLength(1) + }) + + it("never retries a purchase on a network error", async () => { + mockedAxios.post.mockRejectedValue(networkError("ECONNABORTED")) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")).toHaveLength(1) + }) + + it("maps a 4xx with a vendor message to GiftCardVendorRejectedOrderError", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/purchase/bitcoin": () => httpError(400, "Insufficient stock"), + }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorRejectedOrderError) + expect((result as Error).message).toBe("Insufficient stock") + }) + + it("maps a 2xx envelope with a 4xx statusCode, null result and an error to a rejection", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/purchase/bitcoin": () => ({ + status: 200, + data: { statusCode: 400, result: null, error: "Card value not allowed" }, + }), + }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorRejectedOrderError) + expect((result as Error).message).toBe("Card value not allowed") + }) + + it("maps a 4xx without a vendor message to GiftCardVendorUnavailableError", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/giftcards/purchase/bitcoin": () => ({ status: 422, data: {} }) }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + }) +}) + +describe("BitcoinCompanyClient invoice status", () => { + beforeEach(() => seedTokens(FRESH_TOKENS)) + + it("sends the invoice and retries the status POST on a 5xx", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/invoice-status": sequence( + httpError(502, "Bad Gateway"), + httpOk(FULFILLED_RESULT), + ), + }), + ) + + const result = await makeClient().invoiceStatus(INVOICE) + + expect(result).toEqual(FULFILLED_RESULT) + const calls = callsTo(mockedAxios.post, "/giftcards/invoice-status") + expect(calls).toHaveLength(2) + expect(calls[0][1]).toEqual({ invoice: INVOICE }) + }) + + it("treats a null result with an error string as vendor-unavailable", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/invoice-status": () => ({ + status: 200, + data: { statusCode: 404, result: null, error: "Invoice not found" }, + }), + }), + ) + + const result = await makeClient().invoiceStatus(INVOICE) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + }) +}) + +describe("BitcoinCompanyClient response validation", () => { + beforeEach(() => seedTokens(FRESH_TOKENS)) + + it.each([ + ["a body that is not an envelope", { hello: "world" }], + ["a non-object body", "oops"], + [ + "a result of the wrong shape", + { statusCode: 200, result: { fiatCost: "abc" }, error: null }, + ], + [ + "a result missing required fields", + { statusCode: 200, result: { fiatCost: 25 }, error: null }, + ], + ])("maps %s to GiftCardVendorUnavailableError", async (_label, data) => { + mockedAxios.post.mockImplementation( + routePost({ "/svs/quote-card": () => ({ status: 200, data }) }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(mockedRecordException).toHaveBeenCalledWith( + expect.objectContaining({ + level: "critical", + attributes: { "giftcard.provider": "bitcoinCompany", "giftcard.op": "quote" }, + }), + ) + }) + + it("accepts numeric strings where the vendor sends numbers as text", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/svs/quote-card": () => + httpOk({ + fiatCost: "25.00", + satsCost: "39000", + satsBack: "585", + bitcoinPrice: "64102.56", + }), + }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual({ + fiatCost: 25, + satsCost: 39000, + satsBack: 585, + bitcoinPrice: 64102.56, + }) + }) + + it("applies the configured timeout to every request", async () => { + mockedAxios.get.mockImplementation(routeGet({ "/giftcards": () => catalogPage([]) })) + mockedAxios.post.mockImplementation(routePost(quoteRoute)) + const client = makeClient() + + await client.listProducts() + await client.quoteCard(QUOTE_ARGS) + + expect(mockedAxios.get.mock.calls[0][1]).toEqual( + expect.objectContaining({ timeout: bitcoinCompanyConfigFixture.timeoutMs }), + ) + expect(mockedAxios.post.mock.calls[0][2]).toEqual( + expect.objectContaining({ timeout: bitcoinCompanyConfigFixture.timeoutMs }), + ) + }) +}) + +describe("BitcoinCompanyClient log hygiene", () => { + it("never hands a token, credential, or claim value to the logger or the span", async () => { + // Login + a status response that fails validation WITH claim data in it, + // so the redacted body-preview path is exercised; then a login response + // that fails validation WITH a token in it. + mockedAxios.post.mockImplementation( + routePost({ + "/auth/login": sequence( + httpOk(LOGIN_RESULT), + httpOk({ accessToken: 42, refreshToken: REFRESH_TOKEN_1 }), + ), + "/giftcards/invoice-status": () => + httpOk({ + status: 123, + claimData: { codes: [{ value: CLAIM_CODE }], claimLink: CLAIM_LINK }, + }), + }), + ) + const client = makeClient() + + const status = await client.invoiceStatus(INVOICE) + mockRedisStore.clear() + const relogin = await client.invoiceStatus(INVOICE) + + expect(status).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(relogin).toBeInstanceOf(GiftCardVendorUnavailableError) + + const logCalls = Object.values(mockedLogger).flatMap((mock) => mock.mock.calls) + expect(logCalls.length).toBeGreaterThan(0) + const loggedText = JSON.stringify(logCalls) + // Prove the redaction path ran, not that the body was merely dropped. + expect(loggedText).toContain("[REDACTED]") + for (const secret of SECRET_STRINGS) { + expect(loggedText).not.toContain(secret) + } + + const spanText = JSON.stringify( + mockedRecordException.mock.calls.map(([arg]) => ({ + ...arg, + error: + arg.error instanceof Error + ? `${arg.error.name}: ${arg.error.message}` + : arg.error, + })), + ) + expect(mockedRecordException).toHaveBeenCalled() + for (const secret of SECRET_STRINGS) { + expect(spanText).not.toContain(secret) + } + }) + + it("redactForLog masks sensitive keys at any depth and leaves the rest intact", () => { + const redacted = redactForLog({ + headers: { Authorization: "Bearer abc", Accept: "application/json" }, + result: { + status: "Completed", + claimData: { codes: [{ value: "X" }] }, + nested: [ + { refreshToken: "r", accessToken: "a", token: "t", password: "p", id: 7 }, + ], + }, + }) + + expect(redacted).toEqual({ + headers: { Authorization: "[REDACTED]", Accept: "application/json" }, + result: { + status: "Completed", + claimData: "[REDACTED]", + nested: [ + { + refreshToken: "[REDACTED]", + accessToken: "[REDACTED]", + token: "[REDACTED]", + password: "[REDACTED]", + id: 7, + }, + ], + }, + }) + }) +}) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts new file mode 100644 index 000000000..f1c358673 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts @@ -0,0 +1,298 @@ +const mockRedisStore = new Map() + +jest.mock("axios", () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: jest.fn((err) => Boolean(err?.isAxiosError)), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: unknown }) => fns, +})) + +jest.mock("@config", () => ({ + GiftCardsConfig: jest.requireActual("./fixtures").giftCardsConfigFixture, +})) + +jest.mock("@services/redis", () => ({ + redis: { + get: async (key: string) => mockRedisStore.get(key) ?? null, + set: async (key: string, value: string, ...opts: unknown[]) => { + if (opts.includes("NX") && mockRedisStore.has(key)) return null + mockRedisStore.set(key, value) + return "OK" + }, + del: async (key: string) => (mockRedisStore.delete(key) ? 1 : 0), + eval: async (_script: string, _numKeys: number, key: string, nonce: string) => { + if (mockRedisStore.get(key) === nonce) { + mockRedisStore.delete(key) + return 1 + } + return 0 + }, + }, +})) + +import axios from "axios" + +import { + GiftCardVendorUnavailableError, + toGiftCardOrderId, + toGiftCardProviderOrderId, +} from "@domain/gift-cards" +import { + BitcoinCompanyProvider, + registerBitcoinCompanyProvider, +} from "@services/gift-cards/bitcoin-company" +import { + __resetGiftCardProvidersForTest, + getRegisteredGiftCardProvider, +} from "@services/gift-cards/registry" +import { baseLogger } from "@services/logger" + +import { runGiftCardProviderContract } from "../provider-contract" + +import { + CLAIM_CODE, + CLAIM_LINK, + FULFILLED_RESULT, + LOGIN_RESULT, + NOW, + PURCHASE_RESULT, + QUOTE_RESULT, + RouteHandler, + callsTo, + catalogPage, + httpOk, + networkError, + routeGet, + routePost, + vendorProductFixture, + vendorVariableProductFixture, +} from "./fixtures" + +const mockedAxios = axios as unknown as { get: jest.Mock; post: jest.Mock } +const mockedLogger = baseLogger as unknown as Record<"info" | "warn" | "error", jest.Mock> + +const noSleep = () => Promise.resolve() +const REFERENCE = toGiftCardOrderId("gco_contract_0001") + +const makeProvider = () => + BitcoinCompanyProvider({ + clientDeps: { sleep: noSleep, now: () => NOW, random: () => 0 }, + now: () => new Date(NOW), + }) + +const happyGet: Record = { + "/giftcards": (ctx) => + catalogPage( + Number(ctx.query.get("offset")) === 0 + ? [ + vendorProductFixture(), + vendorVariableProductFixture(), + vendorProductFixture({ id: "prod-no-country", countries: [] }), + ] + : [], + ), +} + +const happyPost: Record = { + "/auth/login": () => httpOk(LOGIN_RESULT), + "/svs/quote-card": () => httpOk(QUOTE_RESULT), + "/giftcards/purchase/bitcoin": () => httpOk(PURCHASE_RESULT), + "/giftcards/invoice-status": () => httpOk(FULFILLED_RESULT), +} + +const arrangeHappyPath = () => { + jest.clearAllMocks() + mockRedisStore.clear() + mockedAxios.get.mockReset().mockImplementation(routeGet(happyGet)) + mockedAxios.post.mockReset().mockImplementation(routePost(happyPost)) +} + +runGiftCardProviderContract("bitcoinCompany", makeProvider, { + providerId: "bitcoinCompany", + reference: REFERENCE, + now: () => new Date(NOW), + statusRequiresPaymentRequest: true, + arrangeHappyPath, + arrangeVendorDown: () => { + mockedAxios.get.mockReset().mockRejectedValue(networkError()) + mockedAxios.post.mockReset().mockRejectedValue(networkError()) + }, + pickPurchase: (products) => { + const product = products.find((p) => p.denominationType === "fixed") + if (!product) throw new Error("fixture catalog has no fixed-denomination product") + return { product, valueMinor: product.denominations[1], quantity: 1 } + }, +}) + +describe("bitcoinCompany adapter specifics", () => { + beforeEach(arrangeHappyPath) + + it("maps the vendor catalog and skips rows without a country", async () => { + const products = await makeProvider().listProducts() + if (products instanceof Error) throw products + + expect(products.map((p) => p.id)).toEqual([ + "bitcoinCompany:prod-amazon-us", + "bitcoinCompany:prod-visa-us", + ]) + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + op: "listProducts", + received: 3, + mapped: 2, + skipped: { "no-country": 1 }, + }), + expect.any(String), + ) + }) + + it("sends major units, Lightning purchase type, and the order reference to the vendor", async () => { + const provider = makeProvider() + const products = await provider.listProducts() + if (products instanceof Error) throw products + const product = products[0] + + const quote = await provider.quote({ product, valueMinor: 2500, quantity: 2 }) + if (quote instanceof Error) throw quote + const order = await provider.createOrder({ + product, + valueMinor: 2500, + quantity: 2, + reference: REFERENCE, + }) + if (order instanceof Error) throw order + + expect(callsTo(mockedAxios.post, "/svs/quote-card")[0][1]).toEqual({ + productId: "prod-amazon-us", + cardValue: 25, + quantity: 2, + purchaseType: "Lightning", + }) + expect(callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")[0][1]).toEqual({ + productId: "prod-amazon-us", + cardValue: 25, + quantity: 2, + useUsdBalance: false, + useSatsBalance: false, + label: REFERENCE, + }) + expect(quote).toEqual( + expect.objectContaining({ + productId: product.id, + fiatCostMinor: 2500, + satsCost: 39000, + rewardSats: 585, + bitcoinPriceMinor: 6410256, + expiresAt: new Date(NOW + 60_000), + }), + ) + expect(order).toEqual({ + providerOrderId: PURCHASE_RESULT.uuid, + paymentRequest: PURCHASE_RESULT.invoice, + amountSats: 39000, + expiresAt: new Date(NOW + 15 * 60_000), + }) + }) + + it("maps a completed vendor order to fulfilled with its claim", async () => { + const status = await makeProvider().getOrder({ + providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), + paymentRequest: PURCHASE_RESULT.invoice, + }) + + expect(status).toEqual({ + kind: "fulfilled", + claim: { + codes: [{ label: "Claim code", value: CLAIM_CODE }], + claimLink: CLAIM_LINK, + barcode: null, + }, + }) + expect(callsTo(mockedAxios.post, "/giftcards/invoice-status")[0][1]).toEqual({ + invoice: PURCHASE_RESULT.invoice, + }) + }) + + it("holds a completed order without claim data as pending and warns", async () => { + mockedAxios.post.mockImplementation( + routePost({ + ...happyPost, + "/giftcards/invoice-status": () => + httpOk({ status: "Completed", claimData: null }), + }), + ) + + const status = await makeProvider().getOrder({ + providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), + paymentRequest: PURCHASE_RESULT.invoice, + }) + + expect(status).toEqual({ kind: "paidPendingFulfillment" }) + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + op: "getOrder", + providerOrderId: PURCHASE_RESULT.uuid, + vendorStatus: "Completed", + }), + expect.stringContaining("without claim data"), + ) + }) + + it("maps an unpaid vendor order to awaitingPayment", async () => { + mockedAxios.post.mockImplementation( + routePost({ + ...happyPost, + "/giftcards/invoice-status": () => httpOk({ status: "Unpaid" }), + }), + ) + + const status = await makeProvider().getOrder({ + providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), + paymentRequest: PURCHASE_RESULT.invoice, + }) + + expect(status).toEqual({ kind: "awaitingPayment" }) + }) + + it("refuses a status lookup without a payment request and never calls the vendor", async () => { + const status = await makeProvider().getOrder({ + providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), + paymentRequest: null, + }) + + expect(status).toBeInstanceOf(GiftCardVendorUnavailableError) + expect((status as Error).message).toBe("status lookup requires payment request") + expect(mockedAxios.post).not.toHaveBeenCalled() + }) +}) + +describe("bitcoinCompany registration", () => { + it("registered itself on import and stays registered exactly once", () => { + const registered = getRegisteredGiftCardProvider("bitcoinCompany") + expect(registered).toBeDefined() + expect(registered?.id).toBe("bitcoinCompany") + + registerBitcoinCompanyProvider() + registerBitcoinCompanyProvider() + + expect(getRegisteredGiftCardProvider("bitcoinCompany")).toBe(registered) + }) + + it("re-registers after the registry is reset", () => { + __resetGiftCardProvidersForTest() + expect(getRegisteredGiftCardProvider("bitcoinCompany")).toBeUndefined() + + registerBitcoinCompanyProvider() + + expect(getRegisteredGiftCardProvider("bitcoinCompany")?.id).toBe("bitcoinCompany") + }) +}) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts new file mode 100644 index 000000000..955ad5f89 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts @@ -0,0 +1,273 @@ +/** + * Shared fixtures for the Bitcoin Company adapter specs. Vendor payloads here + * follow the shapes documented in ENG-577; anything the vendor has not + * confirmed is flagged in the adapter's report, not here. + */ + +export const NOW = Date.parse("2026-09-09T12:00:00Z") +export const BASE_URL = "https://api.dev.thebitcoincompany.com" + +export const bitcoinCompanyConfigFixture = { + enabled: true, + baseUrl: BASE_URL, + email: "ops@example.com", + password: "hunter2-not-a-real-password", + referralCode: "", + timeoutMs: 5000, +} + +export const giftCardsConfigFixture = { + enabled: true, + allowOpenLoop: false, + feeBps: 0, + claimDataEncryptionKey: "", + quoteToleranceBps: 100, + routing: { default: "bitcoinCompany", byCountry: {} }, + providers: { + bitcoinCompany: bitcoinCompanyConfigFixture, + bitrefill: { + enabled: false, + baseUrl: "", + apiId: "", + apiSecret: "", + webhookSecret: "", + timeoutMs: 5000, + }, + }, +} + +// ============ Vendor payloads ============ + +export type VendorProductJson = { + id: string + name: string + description?: string | null + countries: string[] + currency: string + currencySymbol?: string | null + denominationType: string + denominations: number[] + defaultDenoms?: number[] | null + isOpenLoop: boolean + isPhysical?: boolean + categories?: string[] + logo?: string | null + panelImg?: string | null + terms?: string | null + satsBackPercentage: number + stock: number + productType?: string + paymentTypes?: string[] + resellingEnabled?: boolean + deliveryTypes?: string[] +} + +export const vendorProductFixture = ( + overrides: Partial = {}, +): VendorProductJson => ({ + id: "prod-amazon-us", + name: "Amazon 🇺🇸 (US)", + description: "Shop everything.", + countries: ["US"], + currency: "USD", + currencySymbol: "$", + denominationType: "Fixed", + denominations: [10, 25, 50], + defaultDenoms: [25], + isOpenLoop: false, + isPhysical: false, + categories: ["Shopping"], + logo: "https://cdn.example/amazon.png", + panelImg: null, + terms: "Terms and conditions apply.", + satsBackPercentage: 1.5, + stock: -1, + productType: "svs", + paymentTypes: ["Lightning"], + resellingEnabled: true, + deliveryTypes: ["Digital"], + ...overrides, +}) + +export const vendorVariableProductFixture = ( + overrides: Partial = {}, +): VendorProductJson => + vendorProductFixture({ + id: "prod-visa-us", + name: "Visa 🇺🇸 (US)", + denominationType: "Variable", + denominations: [5, 500], + defaultDenoms: [50], + isOpenLoop: true, + categories: ["Prepaid"], + satsBackPercentage: 0.5, + ...overrides, + }) + +export const makeCatalog = (count: number, offset = 0): VendorProductJson[] => + Array.from({ length: count }, (_, i) => + vendorProductFixture({ + id: `prod-${offset + i}`, + name: `Brand ${offset + i} 🇺🇸 (US)`, + }), + ) + +export const ACCESS_TOKEN_1 = "access-token-one-SECRET" +export const REFRESH_TOKEN_1 = "refresh-token-one-SECRET" +export const ACCESS_TOKEN_2 = "access-token-two-SECRET" +export const REFRESH_TOKEN_2 = "refresh-token-two-SECRET" +export const CLAIM_CODE = "CLAIM-CODE-1234-SECRET" +export const CLAIM_LINK = "https://claim.example/redeem/abc-SECRET" + +export const SECRET_STRINGS = [ + ACCESS_TOKEN_1, + REFRESH_TOKEN_1, + ACCESS_TOKEN_2, + REFRESH_TOKEN_2, + CLAIM_CODE, + CLAIM_LINK, + bitcoinCompanyConfigFixture.password, +] + +export const LOGIN_RESULT = { + accessToken: ACCESS_TOKEN_1, + refreshToken: REFRESH_TOKEN_1, + user: { id: "user-1", email: "ops@example.com" }, +} + +export const REFRESH_RESULT = { + accessToken: ACCESS_TOKEN_2, + refreshToken: REFRESH_TOKEN_2, +} + +export const QUOTE_RESULT = { + fiatCost: 25, + satsCost: 39000, + satsBack: 585, + bitcoinPrice: 64102.56, + quoteMode: "standard", +} + +export const INVOICE = "lnbc390u1pexampleinvoiceSECRETLOOKING" + +export const PURCHASE_RESULT = { + invoice: INVOICE, + address: null, + amount: 39000, + orderId: 1234, + uuid: "9f1c2b3a-0000-4000-8000-000000000001", + satsBack: 585, +} + +export const FULFILLED_RESULT = { + status: "Completed", + claimData: { + codes: [{ label: "Claim code", value: CLAIM_CODE }], + claimLink: CLAIM_LINK, + barcodeChars: null, + barcodeType: null, + }, +} + +// ============ HTTP response builders (axios response shape) ============ + +export type AxiosLikeResponse = { status: number; data: unknown } + +export const envelope = (result: T, statusCode = 200) => ({ + statusCode, + result, + error: null, +}) + +export const httpOk = (result: T): AxiosLikeResponse => ({ + status: 200, + data: envelope(result), +}) + +export const httpError = (status: number, error: string): AxiosLikeResponse => ({ + status, + data: { statusCode: status, result: null, error }, +}) + +export const catalogPage = (products: unknown[]): AxiosLikeResponse => + httpOk({ svs: products }) + +/** What axios rejects with when no response arrived. */ +export const networkError = (code = "ECONNRESET") => + Object.assign(new Error(`network: ${code}`), { isAxiosError: true, code }) + +// ============ Route-based mock helpers ============ + +export type RouteContext = { + url: string + path: string + query: URLSearchParams + body: unknown + config: { headers?: Record; timeout?: number } +} + +export type RouteHandler = ( + ctx: RouteContext, +) => AxiosLikeResponse | Promise + +export const pathOf = (url: string): string => { + const parsed = new URL(url) + return parsed.pathname +} + +const dispatch = (routes: Record, ctx: RouteContext) => { + const handler = routes[ctx.path] + if (!handler) throw new Error(`unmocked route ${ctx.path}`) + return handler(ctx) +} + +/** `axios.get(url, config)` implementation that dispatches on the URL path. */ +export const routeGet = + (routes: Record) => + (url: string, config: RouteContext["config"] = {}) => + dispatch(routes, { + url, + path: pathOf(url), + query: new URL(url).searchParams, + body: undefined, + config, + }) + +/** `axios.post(url, body, config)` implementation that dispatches on the URL path. */ +export const routePost = + (routes: Record) => + (url: string, body: unknown, config: RouteContext["config"] = {}) => + dispatch(routes, { + url, + path: pathOf(url), + query: new URL(url).searchParams, + body, + config, + }) + +/** Calls a jest mock received for the given path, oldest first. */ +export const callsTo = (mock: jest.Mock, path: string) => + mock.mock.calls.filter(([url]) => pathOf(url as string) === path) + +export const bearerOf = (config: RouteContext["config"] | undefined) => + config?.headers?.Authorization ?? null + +/** A handler that answers from a queue, then repeats the last entry. */ +export const sequence = (...responses: AxiosLikeResponse[]): RouteHandler => { + const queue = [...responses] + return () => { + const next = queue.length > 1 ? queue.shift() : queue[0] + if (!next) throw new Error("sequence exhausted") + return next + } +} + +/** A handler that rejects N times then answers. */ +export const failThen = (failures: Error[], then: AxiosLikeResponse): RouteHandler => { + const queue = [...failures] + return async () => { + const failure = queue.shift() + if (failure) throw failure + return then + } +} diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts new file mode 100644 index 000000000..87136257f --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts @@ -0,0 +1,412 @@ +import { + KNOWN_VENDOR_STATUSES, + ORDER_TTL_MS, + QUOTE_TTL_MS, + isProductMappingSkip, + mapDenominationType, + mapVendorClaim, + mapVendorOrderStatus, + mapVendorProduct, + mapVendorPurchase, + mapVendorQuote, + stripBrand, + toMajorUnits, + toMinorUnits, +} from "@services/gift-cards/bitcoin-company/mapping" +import { vendorProductSchema } from "@services/gift-cards/bitcoin-company/schemas" + +import { + CLAIM_CODE, + CLAIM_LINK, + NOW, + PURCHASE_RESULT, + QUOTE_RESULT, + VendorProductJson, + vendorProductFixture, + vendorVariableProductFixture, +} from "./fixtures" + +const now = new Date(NOW) + +/** Run the fixture through the real schema so the mapper sees what the client hands it. */ +const parsed = (json: VendorProductJson) => vendorProductSchema.parse(json) + +const mapped = (json: VendorProductJson): GiftCardProduct => { + const result = mapVendorProduct(parsed(json)) + if (isProductMappingSkip(result)) throw new Error(`unexpected skip: ${result.skipped}`) + return result +} + +const skipReason = (json: VendorProductJson): string | null => { + const result = mapVendorProduct(parsed(json)) + return isProductMappingSkip(result) ? result.skipped : null +} + +describe("unit conversion", () => { + it.each([ + [25, 2500], + [12.34, 1234], + [0.1, 10], + [64102.56, 6410256], + [0, 0], + ])("toMinorUnits(%p) = %p", (major, minor) => { + expect(toMinorUnits(major)).toBe(minor) + }) + + it("toMajorUnits inverts toMinorUnits", () => { + expect(toMajorUnits(2500)).toBe(25) + expect(toMajorUnits(1234)).toBe(12.34) + }) +}) + +describe("mapVendorProduct", () => { + it("maps a fixed-denomination product to minor units", () => { + expect(mapped(vendorProductFixture())).toEqual({ + id: "bitcoinCompany:prod-amazon-us", + providerId: "bitcoinCompany", + providerProductId: "prod-amazon-us", + name: "Amazon 🇺🇸 (US)", + brand: "Amazon", + countryCode: "US", + currency: "USD", + denominationType: "fixed", + denominations: [1000, 2500, 5000], + minValue: null, + maxValue: null, + isOpenLoop: false, + categories: ["Shopping"], + logoUrl: "https://cdn.example/amazon.png", + termsUrl: null, + rewardBps: 150, + inStock: true, + }) + }) + + it("maps a variable product to a min/max range with no fixed denominations", () => { + expect(mapped(vendorVariableProductFixture())).toEqual( + expect.objectContaining({ + id: "bitcoinCompany:prod-visa-us", + brand: "Visa", + denominationType: "variable", + denominations: [], + minValue: 500, + maxValue: 50000, + isOpenLoop: true, + rewardBps: 50, + }), + ) + }) + + it("treats VariableNoCents as variable", () => { + const product = mapped( + vendorVariableProductFixture({ denominationType: "VariableNoCents" }), + ) + expect(product.denominationType).toBe("variable") + expect(product.minValue).toBe(500) + expect(product.maxValue).toBe(50000) + }) + + it("dedupes, sorts, and drops non-positive denominations", () => { + const product = mapped( + vendorProductFixture({ denominations: [50, 10, 10, 0, -5, 25] }), + ) + expect(product.denominations).toEqual([1000, 2500, 5000]) + }) + + it("derives the variable range from the extremes regardless of order", () => { + const product = mapped( + vendorVariableProductFixture({ denominations: [100, 5, 250, 20] }), + ) + expect(product.minValue).toBe(500) + expect(product.maxValue).toBe(25000) + }) + + it.each([ + ["no countries", vendorProductFixture({ countries: [] }), "no-country"], + ["blank countries", vendorProductFixture({ countries: ["", " "] }), "no-country"], + [ + "an unknown denomination type", + vendorProductFixture({ denominationType: "Tiered" }), + "unknown-denomination-type", + ], + [ + "a fixed product with no denominations", + vendorProductFixture({ denominations: [] }), + "no-denominations", + ], + [ + "a variable product with no denominations", + vendorVariableProductFixture({ denominations: [] }), + "no-denominations", + ], + [ + "only non-positive denominations", + vendorProductFixture({ denominations: [0, -1] }), + "no-denominations", + ], + ])("skips a product with %s", (_label, json, reason) => { + expect(skipReason(json)).toBe(reason) + }) + + it("uses the first non-blank country, normalised", () => { + expect( + mapped(vendorProductFixture({ countries: [" ", "gb", "US"] })).countryCode, + ).toBe("GB") + }) + + it("normalises currency and trims the name", () => { + const product = mapped(vendorProductFixture({ currency: " usd", name: " Target " })) + expect(product.currency).toBe("USD") + expect(product.name).toBe("Target") + expect(product.brand).toBe("Target") + }) + + it.each([ + [1.5, 150], + [0.5, 50], + [1.25, 125], + [0, 0], + [10, 1000], + ])("converts satsBackPercentage %p to %p bps", (pct, bps) => { + expect(mapped(vendorProductFixture({ satsBackPercentage: pct })).rewardBps).toBe(bps) + }) + + it.each([ + [0, false], + [-1, true], + [7, true], + ])("stock %p → inStock %p", (stock, inStock) => { + expect(mapped(vendorProductFixture({ stock })).inStock).toBe(inStock) + }) + + it("prefers logo, falls back to panelImg, else null", () => { + expect( + mapped(vendorProductFixture({ logo: "a.png", panelImg: "b.png" })).logoUrl, + ).toBe("a.png") + expect(mapped(vendorProductFixture({ logo: null, panelImg: "b.png" })).logoUrl).toBe( + "b.png", + ) + expect(mapped(vendorProductFixture({ logo: "", panelImg: "b.png" })).logoUrl).toBe( + "b.png", + ) + expect( + mapped(vendorProductFixture({ logo: null, panelImg: null })).logoUrl, + ).toBeNull() + }) + + it("never maps vendor terms text to termsUrl", () => { + expect( + mapped(vendorProductFixture({ terms: "https://looks.like/a/url" })).termsUrl, + ).toBeNull() + }) + + it("defaults missing categories to an empty list", () => { + expect(mapped(vendorProductFixture({ categories: undefined })).categories).toEqual([]) + }) +}) + +describe("stripBrand", () => { + it.each([ + ["Visa 🇺🇸 (US)", "Visa"], + ["Amazon (US)", "Amazon"], + ["Amazon 🇺🇸", "Amazon"], + ["Steam (USA) 🇺🇸", "Steam"], + ["Tim Hortons 🇨🇦 (CA)", "Tim Hortons"], + ["Xbox (Digital)", "Xbox (Digital)"], + [" Target ", "Target"], + ["Best Buy", "Best Buy"], + ["🇺🇸", "🇺🇸"], + ])("%p → %p", (input, expected) => { + expect(stripBrand(input)).toBe(expected) + }) +}) + +describe("mapDenominationType", () => { + it.each([ + ["Fixed", "fixed"], + ["Variable", "variable"], + ["VariableNoCents", "variable"], + ["fixed", null], + ["Other", null], + ])("%p → %p", (raw, expected) => { + expect(mapDenominationType(raw)).toBe(expected) + }) +}) + +describe("mapVendorQuote", () => { + const product = mapped(vendorProductFixture()) + + it("converts vendor major units and sats into the port quote", () => { + const quote = mapVendorQuote({ + product, + valueMinor: 2500, + quantity: 1, + vendor: QUOTE_RESULT, + now, + }) + + expect(quote).toEqual({ + productId: "bitcoinCompany:prod-amazon-us", + valueMinor: 2500, + currency: "USD", + quantity: 1, + fiatCostMinor: 2500, + satsCost: 39000, + rewardSats: 585, + bitcoinPriceMinor: 6410256, + expiresAt: new Date(NOW + QUOTE_TTL_MS), + }) + expect(QUOTE_TTL_MS).toBe(60_000) + }) + + it("rounds fractional sats", () => { + const quote = mapVendorQuote({ + product, + valueMinor: 2500, + quantity: 2, + vendor: { ...QUOTE_RESULT, satsCost: 39000.4, satsBack: 584.6 }, + now, + }) + expect(quote.satsCost).toBe(39000) + expect(quote.rewardSats).toBe(585) + expect(quote.quantity).toBe(2) + }) +}) + +describe("mapVendorPurchase", () => { + it("maps invoice, amount, and uuid into the provider order", () => { + expect(mapVendorPurchase(PURCHASE_RESULT, now)).toEqual({ + providerOrderId: PURCHASE_RESULT.uuid, + paymentRequest: PURCHASE_RESULT.invoice, + amountSats: 39000, + expiresAt: new Date(NOW + ORDER_TTL_MS), + }) + expect(ORDER_TTL_MS).toBe(15 * 60_000) + }) + + it("stringifies a numeric uuid and rounds the amount", () => { + const order = mapVendorPurchase({ ...PURCHASE_RESULT, uuid: 42, amount: 100.6 }, now) + expect(order.providerOrderId).toBe("42") + expect(order.amountSats).toBe(101) + }) +}) + +describe("mapVendorClaim", () => { + it("returns null when there is nothing to redeem", () => { + expect(mapVendorClaim(null)).toBeNull() + expect(mapVendorClaim(undefined)).toBeNull() + expect(mapVendorClaim({})).toBeNull() + expect(mapVendorClaim({ codes: [], claimLink: null })).toBeNull() + expect(mapVendorClaim({ codes: [{ value: " " }], claimLink: "" })).toBeNull() + }) + + it("keeps codes with values, a claim link, and a complete barcode", () => { + expect( + mapVendorClaim({ + codes: [{ label: "PIN", value: "1234" }, { value: "CODE" }, { value: "" }], + claimLink: CLAIM_LINK, + barcodeChars: "0123456789", + barcodeType: "CODE128", + }), + ).toEqual({ + codes: [ + { label: "PIN", value: "1234" }, + { label: null, value: "CODE" }, + ], + claimLink: CLAIM_LINK, + barcode: { chars: "0123456789", type: "CODE128" }, + }) + }) + + it("drops an incomplete barcode", () => { + expect(mapVendorClaim({ claimLink: CLAIM_LINK, barcodeChars: "0123" })).toEqual({ + codes: [], + claimLink: CLAIM_LINK, + barcode: null, + }) + }) +}) + +describe("mapVendorOrderStatus", () => { + const claimData = { + codes: [{ label: "Code", value: CLAIM_CODE }], + claimLink: CLAIM_LINK, + } + + it.each([ + ["Unpaid", "awaitingPayment"], + ["Underpaid", "awaitingPayment"], + ["Confirming", "awaitingPayment"], + ["Pending", "paidPendingFulfillment"], + ["SentToFulfillment", "paidPendingFulfillment"], + ["Completed", "fulfilled"], + ["Sent", "fulfilled"], + ["Claimed", "fulfilled"], + ["Shipped", "fulfilled"], + ["Expired", "failed"], + ["Cancelled", "failed"], + ["Refunded", "refunded"], + ["Disputed", "refunded"], + ["ClawedBack", "refunded"], + ])("maps vendor status %p to %p without a warning", (vendorStatus, kind) => { + const { status, warning } = mapVendorOrderStatus({ status: vendorStatus, claimData }) + expect(status.kind).toBe(kind) + expect(warning).toBeNull() + }) + + it("covers every known vendor status in the table test above", () => { + expect(KNOWN_VENDOR_STATUSES).toHaveLength(14) + }) + + it("carries the vendor status as the reason for failed and refunded", () => { + expect(mapVendorOrderStatus({ status: "Expired" }).status).toEqual({ + kind: "failed", + reason: "Expired", + }) + expect(mapVendorOrderStatus({ status: "ClawedBack" }).status).toEqual({ + kind: "refunded", + reason: "ClawedBack", + }) + }) + + it("returns the mapped claim for a fulfilled status", () => { + expect(mapVendorOrderStatus({ status: "Completed", claimData }).status).toEqual({ + kind: "fulfilled", + claim: { + codes: [{ label: "Code", value: CLAIM_CODE }], + claimLink: CLAIM_LINK, + barcode: null, + }, + }) + }) + + it("is case-insensitive on the vendor status", () => { + expect(mapVendorOrderStatus({ status: "completed", claimData }).status.kind).toBe( + "fulfilled", + ) + expect(mapVendorOrderStatus({ status: "UNPAID" }).status.kind).toBe("awaitingPayment") + expect(mapVendorOrderStatus({ status: " Pending " }).status.kind).toBe( + "paidPendingFulfillment", + ) + }) + + it.each(["Completed", "Sent", "Claimed", "Shipped"])( + "holds %p as paidPendingFulfillment when there is nothing to redeem", + (vendorStatus) => { + for (const emptyClaim of [undefined, null, {}, { codes: [], claimLink: null }]) { + const { status, warning } = mapVendorOrderStatus({ + status: vendorStatus, + claimData: emptyClaim, + }) + expect(status).toEqual({ kind: "paidPendingFulfillment" }) + expect(warning).toContain(vendorStatus) + } + }, + ) + + it("holds an unknown status as paidPendingFulfillment with a warning, never fulfilled", () => { + const { status, warning } = mapVendorOrderStatus({ status: "Teleported", claimData }) + expect(status).toEqual({ kind: "paidPendingFulfillment" }) + expect(warning).toContain("Teleported") + }) +}) diff --git a/test/flash/unit/services/gift-cards/catalog-cache.spec.ts b/test/flash/unit/services/gift-cards/catalog-cache.spec.ts new file mode 100644 index 000000000..84273e7f6 --- /dev/null +++ b/test/flash/unit/services/gift-cards/catalog-cache.spec.ts @@ -0,0 +1,347 @@ +const mockGiftCardsConfig = { + catalog: { syncIntervalSeconds: 21600, ttlSeconds: 21600, staleAfterSeconds: 86400 }, +} + +// In-memory stand-in for RedisCacheService. Values round-trip through JSON +// exactly as ioredis-cache does, so a Date written comes back as a string — +// which is the behaviour the cache module has to undo. +const mockStore = new Map() +const mockWriteOrder: string[] = [] +const mockFailures = { set: false, get: false } + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockGiftCardsConfig + }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: unknown }) => fns, + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@services/cache", () => { + const { CacheUndefinedError, UnknownCacheServiceError } = + jest.requireActual("@domain/cache") + const roundTrip = (value: unknown) => JSON.parse(JSON.stringify(value)) + return { + RedisCacheService: () => ({ + set: async ({ + key, + value, + ttlSecs, + }: { + key: string + value: unknown + ttlSecs: number + }) => { + if (mockFailures.set) return new UnknownCacheServiceError("redis down") + mockStore.set(key, { value: roundTrip(value), ttlSecs }) + mockWriteOrder.push(key) + return value + }, + get: async ({ key }: { key: string }) => { + if (mockFailures.get) return new UnknownCacheServiceError("redis down") + const hit = mockStore.get(key) + return hit ? roundTrip(hit.value) : new CacheUndefinedError() + }, + clear: async ({ key }: { key: string }) => { + mockStore.delete(key) + return true + }, + }), + } +}) + +import { + GiftCardCatalogUnavailableError, + GiftCardProductNotFoundError, +} from "@domain/gift-cards" +import { + GiftCardCatalogCache, + giftCardCatalogKey, + giftCardCountriesKey, + giftCardProductKey, +} from "@services/gift-cards/catalog-cache" + +const NOW_MS = 1_800_000_000_000 +const TTL_MS = mockGiftCardsConfig.catalog.ttlSeconds * 1000 +const PROVIDER: GiftCardProviderId = "bitcoinCompany" + +const product = (id: string, over: Partial = {}): GiftCardProduct => ({ + id: `${PROVIDER}:${id}` as GiftCardProductId, + providerId: PROVIDER, + providerProductId: id, + name: `${id} card`, + brand: id, + countryCode: "US", + currency: "USD", + denominationType: "fixed", + denominations: [2500], + minValue: null, + maxValue: null, + isOpenLoop: false, + categories: [], + logoUrl: null, + termsUrl: null, + rewardBps: 0, + inStock: true, + ...over, +}) + +const US_A = product("us-a") +const US_B = product("us-b") +// Lower-case on purpose: the vendor's casing must not leak into key names. +const JM_C = product("jm-c", { countryCode: "jm", currency: "JMD" }) + +const cache = GiftCardCatalogCache() +let nowSpy: jest.SpyInstance + +beforeEach(() => { + jest.clearAllMocks() + mockStore.clear() + mockWriteOrder.length = 0 + mockFailures.set = false + mockFailures.get = false + nowSpy = jest.spyOn(Date, "now").mockReturnValue(NOW_MS) +}) + +afterEach(() => { + nowSpy.mockRestore() +}) + +describe("GiftCardCatalogCache.write", () => { + it("groups products by country and writes one catalog record per country", async () => { + const res = await cache.write({ + providerId: PROVIDER, + products: [US_A, JM_C, US_B], + syncedAt: new Date(NOW_MS), + }) + + expect(res).toEqual({ countries: ["JM", "US"] }) + + const us = mockStore.get(giftCardCatalogKey(PROVIDER, "US"))?.value as { + syncedAt: string + products: GiftCardProduct[] + } + expect(us.syncedAt).toBe(new Date(NOW_MS).toISOString()) + expect(us.products.map((p) => p.id)).toEqual([US_A.id, US_B.id]) + + const jm = mockStore.get(giftCardCatalogKey(PROVIDER, "JM"))?.value as { + products: GiftCardProduct[] + } + expect(jm.products.map((p) => p.id)).toEqual([JM_C.id]) + + expect(mockStore.get(giftCardCountriesKey(PROVIDER))?.value).toEqual(["JM", "US"]) + }) + + it("writes every product under its own key", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A, JM_C, US_B], + syncedAt: new Date(NOW_MS), + }) + + for (const p of [US_A, US_B, JM_C]) { + expect(mockStore.get(giftCardProductKey(p.id))?.value).toEqual(p) + } + }) + + it("writes the countries index LAST, after every catalog and product key", async () => { + // A sync that dies half-way must not advertise a country whose catalog key + // never landed. Ordering is the whole guarantee, so it is asserted directly. + await cache.write({ + providerId: PROVIDER, + products: [US_A, JM_C, US_B], + syncedAt: new Date(NOW_MS), + }) + + expect(mockWriteOrder[mockWriteOrder.length - 1]).toBe(giftCardCountriesKey(PROVIDER)) + expect(mockWriteOrder).toHaveLength(6) // 2 catalogs + 3 products + 1 index + }) + + it("retains every key for staleAfterSeconds, not ttlSeconds", async () => { + // ttlSeconds is the freshness horizon (served with stale=true past it); + // staleAfterSeconds is when Redis may drop the data. Writing the shorter + // one would make "stale but servable" impossible. + await cache.write({ + providerId: PROVIDER, + products: [US_A, JM_C], + syncedAt: new Date(NOW_MS), + }) + + const ttls = [...mockStore.values()].map((entry) => entry.ttlSecs) + expect(ttls).toHaveLength(5) + expect(new Set(ttls)).toEqual( + new Set([mockGiftCardsConfig.catalog.staleAfterSeconds]), + ) + }) + + it("returns GiftCardCatalogUnavailableError and skips the index when a write fails", async () => { + mockFailures.set = true + + const res = await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + + expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) + expect(mockStore.has(giftCardCountriesKey(PROVIDER))).toBe(false) + }) + + it("writes an empty index for an empty catalog rather than failing", async () => { + const res = await cache.write({ + providerId: PROVIDER, + products: [], + syncedAt: new Date(), + }) + + expect(res).toEqual({ countries: [] }) + expect(mockStore.get(giftCardCountriesKey(PROVIDER))?.value).toEqual([]) + }) +}) + +describe("GiftCardCatalogCache.read", () => { + it("returns the products with syncedAt revived as a Date", async () => { + const syncedAt = new Date(NOW_MS - 1000) + await cache.write({ providerId: PROVIDER, products: [US_A, US_B], syncedAt }) + + const res = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + if (res instanceof Error) throw res + + expect(res.syncedAt).toBeInstanceOf(Date) + expect(res.syncedAt.getTime()).toBe(syncedAt.getTime()) + expect(res.products).toEqual([US_A, US_B]) + expect(res.stale).toBe(false) + }) + + it("is fresh exactly at the ttl boundary and stale one millisecond past it", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS - TTL_MS), + }) + const atBoundary = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + if (atBoundary instanceof Error) throw atBoundary + expect(atBoundary.stale).toBe(false) + + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS - TTL_MS - 1), + }) + const pastBoundary = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + if (pastBoundary instanceof Error) throw pastBoundary + expect(pastBoundary.stale).toBe(true) + }) + + it("normalises the country code before looking up the key", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + + const res = await cache.read({ providerId: PROVIDER, countryCode: " us " }) + if (res instanceof Error) throw res + + expect(res.products).toEqual([US_A]) + }) + + it("returns GiftCardCatalogUnavailableError when the key is missing", async () => { + const res = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + + expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) + }) + + it("returns GiftCardCatalogUnavailableError when Redis cannot be read", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + mockFailures.get = true + + const res = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + + expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) + }) + + it("refuses a record it cannot interpret rather than serving garbage", async () => { + mockStore.set(giftCardCatalogKey(PROVIDER, "US"), { + value: { products: "not-a-list" }, + ttlSecs: 1, + }) + expect(await cache.read({ providerId: PROVIDER, countryCode: "US" })).toBeInstanceOf( + GiftCardCatalogUnavailableError, + ) + + mockStore.set(giftCardCatalogKey(PROVIDER, "US"), { + value: { syncedAt: "yesterday-ish", products: [] }, + ttlSecs: 1, + }) + expect(await cache.read({ providerId: PROVIDER, countryCode: "US" })).toBeInstanceOf( + GiftCardCatalogUnavailableError, + ) + }) +}) + +describe("GiftCardCatalogCache.readProduct", () => { + it("returns the product written for that id", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A, JM_C], + syncedAt: new Date(), + }) + + expect(await cache.readProduct(JM_C.id)).toEqual(JM_C) + }) + + it("returns GiftCardProductNotFoundError on a miss", async () => { + const res = await cache.readProduct("bitcoinCompany:nope" as GiftCardProductId) + + expect(res).toBeInstanceOf(GiftCardProductNotFoundError) + }) + + it("returns GiftCardCatalogUnavailableError when Redis cannot be read", async () => { + mockFailures.get = true + + const res = await cache.readProduct(US_A.id) + + expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) + expect(res).not.toBeInstanceOf(GiftCardProductNotFoundError) + }) +}) + +describe("GiftCardCatalogCache.countries", () => { + it("returns the sorted index the last write produced", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A, JM_C], + syncedAt: new Date(), + }) + + expect(await cache.countries(PROVIDER)).toEqual(["JM", "US"]) + }) + + it("returns GiftCardCatalogUnavailableError when the index is missing", async () => { + expect(await cache.countries(PROVIDER)).toBeInstanceOf( + GiftCardCatalogUnavailableError, + ) + }) + + it("returns GiftCardCatalogUnavailableError when Redis cannot be read", async () => { + await cache.write({ providerId: PROVIDER, products: [US_A], syncedAt: new Date() }) + mockFailures.get = true + + expect(await cache.countries(PROVIDER)).toBeInstanceOf( + GiftCardCatalogUnavailableError, + ) + }) +}) diff --git a/test/flash/unit/services/gift-cards/claim-crypto.spec.ts b/test/flash/unit/services/gift-cards/claim-crypto.spec.ts new file mode 100644 index 000000000..d02757d89 --- /dev/null +++ b/test/flash/unit/services/gift-cards/claim-crypto.spec.ts @@ -0,0 +1,260 @@ +/** + * Claim data is the bearer value of a gift card — whoever holds the code holds + * the money. These specs pin the at-rest envelope: AES-256-GCM under the + * configured key, `version | iv | tag | data`, key identified by a sha256 + * prefix so rotation can be detected rather than guessed. Every check here + * must be able to return "no": a tampered byte, a foreign key id, a short key + * and a missing key all have to be refused, and no refusal may echo the code + * or the key back in its message. + */ +import { createHash, randomBytes } from "crypto" + +import { GiftCardsConfig } from "@config" +import { GiftCardClaimCryptoError } from "@domain/gift-cards" +import { + decryptGiftCardClaim, + encryptGiftCardClaim, + giftCardClaimKeyId, +} from "@services/gift-cards/claim-crypto" + +jest.mock("@config", () => ({ + GiftCardsConfig: { claimDataEncryptionKey: "" }, +})) + +const config = GiftCardsConfig as { claimDataEncryptionKey: string } + +const KEY_BYTES = randomBytes(32) +const HEX_KEY = KEY_BYTES.toString("hex") +const BASE64_KEY = KEY_BYTES.toString("base64") +const EXPECTED_KEY_ID = createHash("sha256").update(KEY_BYTES).digest("hex").slice(0, 16) + +const CLAIM: GiftCardClaim = { + codes: [ + { label: "Card number", value: "6006-4931-0000-1234-567" }, + { label: "PIN", value: "8891" }, + ], + claimLink: "https://example.test/claim/abc123", + barcode: { chars: "600649310000123456", type: "CODE128" }, +} + +const setKey = (key: string) => { + config.claimDataEncryptionKey = key +} + +const flipByte = (ciphertext: string, offset: number): string => { + const buf = Buffer.from(ciphertext, "base64") + buf[offset] = buf[offset] ^ 0xff + return buf.toString("base64") +} + +const expectNoLeak = (err: Error) => { + expect(err.message).not.toContain(CLAIM.codes[0].value) + expect(err.message).not.toContain(CLAIM.codes[1].value) + expect(err.message).not.toContain(HEX_KEY) + expect(err.message).not.toContain(BASE64_KEY) +} + +describe("gift card claim crypto", () => { + beforeEach(() => setKey(HEX_KEY)) + + describe("round trip", () => { + it("decrypts to the claim it encrypted", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + const opened = decryptGiftCardClaim(sealed) + + expect(opened).toEqual(CLAIM) + }) + + it("lays the envelope out as version | iv | tag | data and reports the key id", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + const packed = Buffer.from(sealed.ciphertext, "base64") + expect(packed[0]).toBe(0x01) + // 1 version + 12 iv + 16 tag, then at least one byte of payload. + expect(packed.length).toBeGreaterThan(1 + 12 + 16) + expect(sealed.keyId).toBe(EXPECTED_KEY_ID) + expect(sealed.keyId).toMatch(/^[0-9a-f]{16}$/) + // The ciphertext carries no plaintext. + expect(sealed.ciphertext).not.toContain(CLAIM.codes[0].value) + }) + + it("uses a fresh iv per call, so equal claims never share a ciphertext", () => { + const a = encryptGiftCardClaim(CLAIM) + const b = encryptGiftCardClaim(CLAIM) + if (a instanceof Error) throw a + if (b instanceof Error) throw b + + expect(a.ciphertext).not.toBe(b.ciphertext) + expect(decryptGiftCardClaim(a)).toEqual(CLAIM) + expect(decryptGiftCardClaim(b)).toEqual(CLAIM) + }) + + it("round-trips a minimal claim with nulls", () => { + const minimal: GiftCardClaim = { + codes: [{ label: null, value: "ONLYCODE" }], + claimLink: null, + barcode: null, + } + const sealed = encryptGiftCardClaim(minimal) + if (sealed instanceof Error) throw sealed + + expect(decryptGiftCardClaim(sealed)).toEqual(minimal) + }) + }) + + describe("tamper detection", () => { + it("refuses a ciphertext with one payload byte flipped", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + // Last byte sits in the data region. + const packedLength = Buffer.from(sealed.ciphertext, "base64").length + const tampered = flipByte(sealed.ciphertext, packedLength - 1) + + const result = decryptGiftCardClaim({ ...sealed, ciphertext: tampered }) + + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expectNoLeak(result as Error) + }) + + it("refuses a ciphertext with one auth tag byte flipped", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + // Offsets 13..28 are the tag. + const tampered = flipByte(sealed.ciphertext, 13) + + expect(decryptGiftCardClaim({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + + it("refuses a ciphertext with one iv byte flipped", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + const tampered = flipByte(sealed.ciphertext, 1) + + expect(decryptGiftCardClaim({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + + it("refuses an unknown envelope version", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + const tampered = flipByte(sealed.ciphertext, 0) + + expect(decryptGiftCardClaim({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + + it("refuses a ciphertext too short to hold the envelope", () => { + const result = decryptGiftCardClaim({ + ciphertext: Buffer.from([0x01, 0x02, 0x03]).toString("base64"), + keyId: EXPECTED_KEY_ID, + }) + + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + }) + }) + + describe("key id", () => { + it("reports the current key's id", () => { + expect(giftCardClaimKeyId()).toBe(EXPECTED_KEY_ID) + }) + + it("refuses to decrypt under a key id that is not the current key", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + const result = decryptGiftCardClaim({ + ciphertext: sealed.ciphertext, + keyId: "0123456789abcdef", + }) + + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expect((result as Error).message).toMatch(/key rotated/) + expectNoLeak(result as Error) + }) + + it("reports a mismatch when the configured key changes under a sealed order", () => { + const sealed = encryptGiftCardClaim(CLAIM) + if (sealed instanceof Error) throw sealed + + setKey(randomBytes(32).toString("hex")) + + const result = decryptGiftCardClaim(sealed) + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expect((result as Error).message).toMatch(/key rotated/) + }) + }) + + describe("key loading", () => { + it("accepts a 64-char hex key", () => { + setKey(HEX_KEY) + expect(giftCardClaimKeyId()).toBe(EXPECTED_KEY_ID) + }) + + it("accepts a base64 key of 32 bytes and derives the same key id from the raw bytes", () => { + setKey(HEX_KEY) + const sealedUnderHex = encryptGiftCardClaim(CLAIM) + if (sealedUnderHex instanceof Error) throw sealedUnderHex + + setKey(BASE64_KEY) + expect(giftCardClaimKeyId()).toBe(EXPECTED_KEY_ID) + // Same bytes, different encoding: the order sealed under the hex form + // still opens. + expect(decryptGiftCardClaim(sealedUnderHex)).toEqual(CLAIM) + }) + + it("tolerates surrounding whitespace from a config override", () => { + setKey(` ${HEX_KEY}\n`) + expect(giftCardClaimKeyId()).toBe(EXPECTED_KEY_ID) + }) + + it("refuses an empty key rather than silently no-op'ing", () => { + setKey("") + + const sealed = encryptGiftCardClaim(CLAIM) + expect(sealed).toBeInstanceOf(GiftCardClaimCryptoError) + expectNoLeak(sealed as Error) + expect(giftCardClaimKeyId()).toBeInstanceOf(GiftCardClaimCryptoError) + expect( + decryptGiftCardClaim({ ciphertext: "AAAA", keyId: EXPECTED_KEY_ID }), + ).toBeInstanceOf(GiftCardClaimCryptoError) + }) + + it("refuses a whitespace-only key", () => { + setKey(" ") + expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + }) + + it("refuses a 31-byte key in hex", () => { + setKey(randomBytes(31).toString("hex")) + const result = encryptGiftCardClaim(CLAIM) + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expectNoLeak(result as Error) + }) + + it("refuses a 31-byte key in base64", () => { + setKey(randomBytes(31).toString("base64")) + expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + }) + + it("refuses a 33-byte key in base64", () => { + setKey(randomBytes(33).toString("base64")) + expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + }) + + it("refuses a key that is neither hex nor base64", () => { + setKey("not-a-key!@#$%^&*()") + expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + }) + }) +}) diff --git a/test/flash/unit/services/gift-cards/provider-contract.ts b/test/flash/unit/services/gift-cards/provider-contract.ts new file mode 100644 index 000000000..03e694314 --- /dev/null +++ b/test/flash/unit/services/gift-cards/provider-contract.ts @@ -0,0 +1,216 @@ +/* eslint-disable jest/no-export -- shared contract suite, imported and invoked by each adapter spec */ +import { + GiftCardError, + buildGiftCardProductId, + checkedGiftCardValue, + parseGiftCardProductId, + toGiftCardProviderOrderId, +} from "@domain/gift-cards" + +/** + * Shared behavioural contract for every `IGiftCardProvider` adapter. Each + * adapter's spec mocks its own HTTP layer, then calls + * `runGiftCardProviderContract` so the port semantics are asserted the same + * way for Bitcoin Company, Bitrefill, and whatever comes next. + */ + +export type GiftCardProviderContractFixtures = { + providerId: GiftCardProviderId + /** Wire the adapter's transport mocks so every port operation succeeds. Runs before each test. */ + arrangeHappyPath: () => void | Promise + /** Wire the adapter's transport mocks so the vendor is unreachable. Enables the failure test. */ + arrangeVendorDown?: () => void | Promise + /** Choose what to quote and order from the adapter's own listed catalog. */ + pickPurchase: (products: GiftCardProduct[]) => { + product: GiftCardProduct + valueMinor: number + quantity: number + } + /** The Flash order id the adapter forwards to the vendor as a label. */ + reference: GiftCardOrderId + /** Clock the adapter was built with, so expiry assertions line up. */ + now?: () => Date + /** Set when the adapter cannot look up status without the payment request. */ + statusRequiresPaymentRequest?: boolean +} + +const STATUS_KINDS: readonly GiftCardProviderOrderStatus["kind"][] = [ + "awaitingPayment", + "paidPendingFulfillment", + "fulfilled", + "failed", + "refunded", +] + +const unwrap = (value: T | Error): T => { + if (value instanceof Error) throw value + return value +} + +const isNullOrSafeInt = (value: number | null): boolean => + value === null || Number.isSafeInteger(value) + +const contractProduct = (providerId: GiftCardProviderId): GiftCardProduct => ({ + id: buildGiftCardProductId(providerId, "contract-product"), + providerId, + providerProductId: "contract-product", + name: "Contract Test Card", + brand: "Contract", + countryCode: "US", + currency: "USD", + denominationType: "fixed", + denominations: [2500], + minValue: null, + maxValue: null, + isOpenLoop: false, + categories: [], + logoUrl: null, + termsUrl: null, + rewardBps: 0, + inStock: true, +}) + +export const runGiftCardProviderContract = ( + name: string, + makeProvider: () => IGiftCardProvider, + fixtures: GiftCardProviderContractFixtures, +): void => { + describe(`${name} satisfies the IGiftCardProvider contract`, () => { + let provider: IGiftCardProvider + const now = () => (fixtures.now ?? (() => new Date()))() + + beforeEach(async () => { + await fixtures.arrangeHappyPath() + provider = makeProvider() + }) + + it("reports the provider id it registers under", () => { + expect(provider.id).toBe(fixtures.providerId) + }) + + it("listProducts returns normalised products with parseable ids", async () => { + const products = unwrap(await provider.listProducts()) + expect(products.length).toBeGreaterThan(0) + + for (const product of products) { + expect(product.providerId).toBe(fixtures.providerId) + expect(product.id).toBe( + buildGiftCardProductId(fixtures.providerId, product.providerProductId), + ) + expect(parseGiftCardProductId(product.id)).toEqual({ + providerId: fixtures.providerId, + providerProductId: product.providerProductId, + }) + expect(product.countryCode).toMatch(/^[A-Z]{2}$/) + expect(product.currency).toMatch(/^[A-Z]{3}$/) + expect(product.name.trim()).not.toBe("") + expect(product.brand.trim()).not.toBe("") + expect(Number.isInteger(product.rewardBps) && product.rewardBps >= 0).toBe(true) + expect(product.termsUrl === null || typeof product.termsUrl === "string").toBe( + true, + ) + expect(product.logoUrl === null || typeof product.logoUrl === "string").toBe(true) + + const denominationsOk = + product.denominationType === "fixed" + ? product.denominations.length > 0 && + product.denominations.every((d) => Number.isSafeInteger(d) && d > 0) && + product.minValue === null && + product.maxValue === null + : product.denominations.length === 0 && + isNullOrSafeInt(product.minValue) && + isNullOrSafeInt(product.maxValue) && + (product.minValue === null || + product.maxValue === null || + product.minValue <= product.maxValue) + expect({ id: product.id, denominationsOk }).toEqual({ + id: product.id, + denominationsOk: true, + }) + } + }) + + it("quotes, creates, and reads back an order for a product from its own catalog", async () => { + const products = unwrap(await provider.listProducts()) + const { product, valueMinor, quantity } = fixtures.pickPurchase(products) + expect(checkedGiftCardValue(product, valueMinor)).toBe(valueMinor) + + const quote = unwrap(await provider.quote({ product, valueMinor, quantity })) + expect(quote.productId).toBe(product.id) + expect(quote.valueMinor).toBe(valueMinor) + expect(quote.currency).toBe(product.currency) + expect(quote.quantity).toBe(quantity) + expect(Number.isSafeInteger(quote.fiatCostMinor) && quote.fiatCostMinor > 0).toBe( + true, + ) + expect(Number.isSafeInteger(quote.satsCost) && quote.satsCost > 0).toBe(true) + expect(Number.isSafeInteger(quote.rewardSats) && quote.rewardSats >= 0).toBe(true) + expect(isNullOrSafeInt(quote.bitcoinPriceMinor)).toBe(true) + expect(quote.expiresAt.getTime()).toBeGreaterThan(now().getTime()) + + const order = unwrap( + await provider.createOrder({ + product, + valueMinor, + quantity, + reference: fixtures.reference, + }), + ) + expect(typeof order.providerOrderId).toBe("string") + expect(order.providerOrderId).not.toBe("") + expect(order.paymentRequest).not.toBe("") + expect(Number.isSafeInteger(order.amountSats) && order.amountSats > 0).toBe(true) + expect(order.expiresAt.getTime()).toBeGreaterThan(now().getTime()) + + const status = unwrap( + await provider.getOrder({ + providerOrderId: order.providerOrderId, + paymentRequest: order.paymentRequest, + }), + ) + expect(STATUS_KINDS).toContain(status.kind) + const redeemable = + status.kind !== "fulfilled" || + status.claim.codes.length > 0 || + status.claim.claimLink !== null + expect(redeemable).toBe(true) + }) + + if (fixtures.statusRequiresPaymentRequest) { + it("getOrder without a payment request returns an error rather than throwing", async () => { + const result = await provider.getOrder({ + providerOrderId: toGiftCardProviderOrderId("contract-order"), + paymentRequest: null, + }) + expect(result).toBeInstanceOf(GiftCardError) + }) + } + + if (fixtures.arrangeVendorDown) { + const arrangeVendorDown = fixtures.arrangeVendorDown + it("returns GiftCardError values instead of throwing when the vendor is unreachable", async () => { + await arrangeVendorDown() + const product = contractProduct(fixtures.providerId) + + expect(await provider.listProducts()).toBeInstanceOf(GiftCardError) + expect( + await provider.quote({ product, valueMinor: 2500, quantity: 1 }), + ).toBeInstanceOf(GiftCardError) + expect( + await provider.createOrder({ + product, + valueMinor: 2500, + quantity: 1, + reference: fixtures.reference, + }), + ).toBeInstanceOf(GiftCardError) + expect( + await provider.getOrder({ + providerOrderId: toGiftCardProviderOrderId("contract-order"), + paymentRequest: "lnbc1contract", + }), + ).toBeInstanceOf(GiftCardError) + }) + } + }) +} diff --git a/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts new file mode 100644 index 000000000..1f9e4d4d3 --- /dev/null +++ b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts @@ -0,0 +1,336 @@ +/** + * The pure half of the gift card orders repository: record → domain mapping, + * the transition table check that runs before any write, and the duplicate-key + * attribution that tells a client retry apart from a real index collision. + * None of this touches mongoose, so every case here runs without a database. + */ +import { DuplicateKeyForPersistError, RepositoryError } from "@domain/errors" +import { + GIFT_CARD_TRANSITIONS, + GiftCardOrderStateError, + GiftCardOrderStatus, +} from "@domain/gift-cards" +import { + GIFT_CARD_ORDER_IDEMPOTENCY_INDEX, + GiftCardOrderDuplicateKeyError, + checkGiftCardOrderTransition, + isGiftCardOrderIdempotencyDuplicate, + toDomain, +} from "@services/mongoose/gift-card-orders.mapping" + +const CREATED_AT = new Date("2026-09-09T12:00:00.000Z") +const UPDATED_AT = new Date("2026-09-09T12:05:00.000Z") +const EXPIRES_AT = new Date("2026-09-09T12:15:00.000Z") +const FULFILLED_AT = new Date("2026-09-09T12:04:30.000Z") + +const fullRecord: GiftCardOrderRecord = { + id: "1f9d2c4e-8b7a-4c3d-9e2f-0a1b2c3d4e5f", + accountId: "5f4c9a2b1e7d3f8a6b0c4d2e", + walletId: "7d2e1b9a-3c4f-4a5b-8c6d-9e0f1a2b3c4d", + walletCurrency: "USD", + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + providerOrderId: "tbc_ord_123", + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: "https://cdn.example.test/amazon.png", + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234, + invoiceSats: 41300, + paidSats: 41300, + paymentRequest: "lnbc413000n1...", + paymentHash: "a".repeat(64), + providerPaymentRef: "ibex_tx_123", + idempotencyKey: "idem-0001", + status: "FULFILLED", + statusHistory: [ + { status: "CREATED", at: CREATED_AT, reason: null }, + { status: "INVOICE_ISSUED", at: new Date("2026-09-09T12:01:00.000Z"), reason: null }, + { status: "PAID", at: new Date("2026-09-09T12:03:00.000Z"), reason: "ibex settled" }, + { status: "FULFILLED", at: FULFILLED_AT, reason: null }, + ], + claimCiphertext: "AQ...base64...", + claimKeyId: "0123456789abcdef", + fulfilledAt: FULFILLED_AT, + failureReason: null, + expiresAt: EXPIRES_AT, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, +} + +describe("toDomain", () => { + it("maps a fully populated record field for field", () => { + const order = toDomain(fullRecord) + + expect(order).toEqual({ + id: fullRecord.id, + accountId: fullRecord.accountId, + walletId: fullRecord.walletId, + walletCurrency: "USD", + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + providerOrderId: "tbc_ord_123", + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: "https://cdn.example.test/amazon.png", + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234, + invoiceSats: 41300, + paidSats: 41300, + paymentRequest: "lnbc413000n1...", + paymentHash: "a".repeat(64), + providerPaymentRef: "ibex_tx_123", + idempotencyKey: "idem-0001", + status: GiftCardOrderStatus.Fulfilled, + statusHistory: [ + { status: "CREATED", at: CREATED_AT, reason: null }, + { + status: "INVOICE_ISSUED", + at: new Date("2026-09-09T12:01:00.000Z"), + reason: null, + }, + { + status: "PAID", + at: new Date("2026-09-09T12:03:00.000Z"), + reason: "ibex settled", + }, + { status: "FULFILLED", at: FULFILLED_AT, reason: null }, + ], + claimCiphertext: "AQ...base64...", + claimKeyId: "0123456789abcdef", + fulfilledAt: FULFILLED_AT, + failureReason: null, + expiresAt: EXPIRES_AT, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + }) + }) + + it("keeps Date instances as Dates and numbers as numbers", () => { + const order = toDomain(fullRecord) + + expect(order.createdAt).toBeInstanceOf(Date) + expect(order.updatedAt).toBeInstanceOf(Date) + expect(order.expiresAt).toBeInstanceOf(Date) + expect(order.fulfilledAt).toBeInstanceOf(Date) + expect(order.statusHistory[0].at).toBeInstanceOf(Date) + expect(typeof order.valueMinor).toBe("number") + expect(typeof order.quoteSats).toBe("number") + expect(typeof order.invoiceSats).toBe("number") + expect(typeof order.quantity).toBe("number") + }) + + it("normalises missing optional fields to null on a freshly created record", () => { + const fresh: GiftCardOrderRecord = { + id: fullRecord.id, + accountId: fullRecord.accountId, + walletId: fullRecord.walletId, + walletCurrency: "USD", + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + // Everything nullable left unset, as a legacy or partially written + // document would have it. + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234, + idempotencyKey: "idem-0001", + status: "CREATED", + statusHistory: [{ status: "CREATED", at: CREATED_AT }], + expiresAt: EXPIRES_AT, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + } + + const order = toDomain(fresh) + + expect(order.providerOrderId).toBeNull() + expect(order.productSnapshot.logoUrl).toBeNull() + expect(order.invoiceSats).toBeNull() + expect(order.paidSats).toBeNull() + expect(order.paymentRequest).toBeNull() + expect(order.paymentHash).toBeNull() + expect(order.providerPaymentRef).toBeNull() + expect(order.claimCiphertext).toBeNull() + expect(order.claimKeyId).toBeNull() + expect(order.fulfilledAt).toBeNull() + expect(order.failureReason).toBeNull() + expect(order.statusHistory).toEqual([ + { status: "CREATED", at: CREATED_AT, reason: null }, + ]) + }) + + it("treats an explicit null providerOrderId as null, and an empty history as []", () => { + const order = toDomain({ + ...fullRecord, + providerOrderId: null, + statusHistory: undefined as unknown as GiftCardOrderStatusHistoryRecord[], + }) + + expect(order.providerOrderId).toBeNull() + expect(order.statusHistory).toEqual([]) + }) + + it("does not leak extra record fields onto the domain object", () => { + const order = toDomain({ + ...fullRecord, + _id: "mongo-object-id", + __v: 3, + } as GiftCardOrderRecord) + + expect(order).not.toHaveProperty("_id") + expect(order).not.toHaveProperty("__v") + }) +}) + +describe("checkGiftCardOrderTransition", () => { + const allStatuses = Object.values(GiftCardOrderStatus) + + const legalPairs = allStatuses.flatMap((from) => + GIFT_CARD_TRANSITIONS[from].map((to) => [from, to] as const), + ) + + const illegalPairs = allStatuses.flatMap((from) => + allStatuses + .filter((to) => !GIFT_CARD_TRANSITIONS[from].includes(to)) + .map((to) => [from, to] as const), + ) + + it("derives a non-trivial table from the domain", () => { + expect(legalPairs.length).toBeGreaterThan(0) + expect(illegalPairs.length).toBeGreaterThan(0) + }) + + it.each(legalPairs)("allows %s → %s", (from, to) => { + expect(checkGiftCardOrderTransition([from], to)).toBe(true) + }) + + it.each(illegalPairs)("refuses %s → %s", (from, to) => { + const result = checkGiftCardOrderTransition([from], to) + expect(result).toBeInstanceOf(GiftCardOrderStateError) + expect((result as Error).message).toBe(`cannot move ${from} → ${to}`) + }) + + it("allows a multi-source transition when every source is legal", () => { + expect( + checkGiftCardOrderTransition( + [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + GiftCardOrderStatus.Paid, + ), + ).toBe(true) + }) + + it("refuses a multi-source transition when any one source is illegal", () => { + const result = checkGiftCardOrderTransition( + [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.Created], + GiftCardOrderStatus.Paid, + ) + expect(result).toBeInstanceOf(GiftCardOrderStateError) + expect((result as Error).message).toBe("cannot move CREATED → PAID") + }) + + it("refuses an empty source list", () => { + expect(checkGiftCardOrderTransition([], GiftCardOrderStatus.Paid)).toBeInstanceOf( + GiftCardOrderStateError, + ) + }) + + it("refuses unknown statuses instead of throwing", () => { + expect( + checkGiftCardOrderTransition( + ["BOGUS" as GiftCardOrderStatus], + GiftCardOrderStatus.Paid, + ), + ).toBeInstanceOf(GiftCardOrderStateError) + expect( + checkGiftCardOrderTransition( + [GiftCardOrderStatus.Created], + "BOGUS" as GiftCardOrderStatus, + ), + ).toBeInstanceOf(GiftCardOrderStateError) + }) + + it("never lets a terminal state move anywhere", () => { + const terminal = [ + GiftCardOrderStatus.Fulfilled, + GiftCardOrderStatus.Failed, + GiftCardOrderStatus.PaymentFailed, + GiftCardOrderStatus.Expired, + GiftCardOrderStatus.RefundRequired, + ] + for (const from of terminal) { + for (const to of allStatuses) { + expect(checkGiftCardOrderTransition([from], to)).toBeInstanceOf( + GiftCardOrderStateError, + ) + } + } + }) +}) + +describe("isGiftCardOrderIdempotencyDuplicate", () => { + const idempotencyDriverMessage = `E11000 duplicate key error collection: galoy.giftcardorders index: ${GIFT_CARD_ORDER_IDEMPOTENCY_INDEX} dup key: { walletId: "w1", idempotencyKey: "k1" }` + + it("recognises the driver message for the idempotency index", () => { + expect(isGiftCardOrderIdempotencyDuplicate(new Error(idempotencyDriverMessage))).toBe( + true, + ) + }) + + it("recognises the structured driver error by code + keyPattern", () => { + const err = Object.assign(new Error("E11000 duplicate key error"), { + code: 11000, + keyPattern: { walletId: 1, idempotencyKey: 1 }, + }) + expect(isGiftCardOrderIdempotencyDuplicate(err)).toBe(true) + }) + + it("does not claim a duplicate on a different index", () => { + const providerDup = new Error( + 'E11000 duplicate key error collection: galoy.giftcardorders index: providerId_1_providerOrderId_1 dup key: { providerId: "bitcoinCompany", providerOrderId: "x" }', + ) + expect(isGiftCardOrderIdempotencyDuplicate(providerDup)).toBe(false) + + const structured = Object.assign(new Error("E11000 duplicate key error"), { + code: 11000, + keyPattern: { providerId: 1, providerOrderId: 1 }, + }) + expect(isGiftCardOrderIdempotencyDuplicate(structured)).toBe(false) + }) + + it("does not claim a duplicate on unrelated errors", () => { + expect(isGiftCardOrderIdempotencyDuplicate(new Error("connection closed"))).toBe( + false, + ) + expect(isGiftCardOrderIdempotencyDuplicate(null)).toBe(false) + expect(isGiftCardOrderIdempotencyDuplicate(undefined)).toBe(false) + expect(isGiftCardOrderIdempotencyDuplicate("walletId_1_idempotencyKey_1")).toBe(false) + }) + + it("exposes a RepositoryError subclass distinct from the generic duplicate error", () => { + const err = new GiftCardOrderDuplicateKeyError("dup") + expect(err).toBeInstanceOf(RepositoryError) + expect(err).not.toBeInstanceOf(DuplicateKeyForPersistError) + }) +}) diff --git a/test/flash/unit/services/mongoose/gift-card-orders.schema.spec.ts b/test/flash/unit/services/mongoose/gift-card-orders.schema.spec.ts new file mode 100644 index 000000000..85a5a37fb --- /dev/null +++ b/test/flash/unit/services/mongoose/gift-card-orders.schema.spec.ts @@ -0,0 +1,132 @@ +/** + * Schema-level guarantees that a mocked model cannot report on: the five + * indexes the migration creates are declared identically here (so + * `syncIndexes()` on boot neither drops nor rebuilds them), the provider-order + * index is partial rather than sparse, and `toJSON` drops the sealed claim. + */ +import { GiftCardOrders } from "@services/mongoose/schema" + +type IndexEntry = [Record, Record] + +const indexFor = (keys: string): IndexEntry | undefined => + (GiftCardOrders.schema.indexes() as IndexEntry[]).find( + ([fields]) => Object.keys(fields).join(",") === keys, + ) + +describe("GiftCardOrderSchema indexes", () => { + it("declares exactly the five indexes the migration creates", () => { + const declared = (GiftCardOrders.schema.indexes() as IndexEntry[]).map(([fields]) => + Object.entries(fields) + .map(([k, v]) => `${k}:${v}`) + .join(","), + ) + expect(declared.sort()).toEqual( + [ + "id:1", + "accountId:1,createdAt:-1", + "providerId:1,providerOrderId:1", + "status:1,updatedAt:1", + "walletId:1,idempotencyKey:1", + ].sort(), + ) + }) + + it("id is unique", () => { + const idx = indexFor("id") + expect(idx).toBeDefined() + expect(idx?.[1].unique).toBe(true) + }) + + it("walletId + idempotencyKey is unique, so a client retry cannot double-order", () => { + const idx = indexFor("walletId,idempotencyKey") + expect(idx).toBeDefined() + expect(idx?.[1].unique).toBe(true) + }) + + it("providerId + providerOrderId is unique and partial, not sparse", () => { + // A sparse compound index still indexes a document when ANY key is present, + // and providerId always is — so two orders still awaiting a vendor id + // (providerOrderId: null) would collide. + const idx = indexFor("providerId,providerOrderId") + expect(idx).toBeDefined() + expect(idx?.[1].unique).toBe(true) + expect(idx?.[1].partialFilterExpression).toEqual({ + providerOrderId: { $type: "string" }, + }) + expect(idx?.[1].sparse).toBeUndefined() + }) + + it("declares the two read-path indexes without uniqueness", () => { + expect(indexFor("accountId,createdAt")?.[1].unique).toBeUndefined() + expect(indexFor("status,updatedAt")?.[1].unique).toBeUndefined() + }) +}) + +describe("GiftCardOrderSchema toJSON", () => { + it("drops claimCiphertext so an accidental serialisation cannot leak it", () => { + const doc = new GiftCardOrders({ + id: "1f9d2c4e-8b7a-4c3d-9e2f-0a1b2c3d4e5f", + accountId: "acc", + walletId: "wal", + walletCurrency: "USD", + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: null, + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234, + idempotencyKey: "idem-0001", + status: "FULFILLED", + statusHistory: [{ status: "FULFILLED", at: new Date(), reason: null }], + claimCiphertext: "SEALED", + claimKeyId: "0123456789abcdef", + expiresAt: new Date(), + }) + + // The document itself still carries the value for the repository mapper. + expect(doc.claimCiphertext).toBe("SEALED") + + const json = doc.toJSON() as Record + expect(json).not.toHaveProperty("claimCiphertext") + expect(JSON.stringify(doc)).not.toContain("SEALED") + // Only the ciphertext is hidden; the key id is needed for rotation bookkeeping. + expect(json.claimKeyId).toBe("0123456789abcdef") + }) + + it("rejects a status outside the domain enum", () => { + const doc = new GiftCardOrders({ + id: "x", + accountId: "acc", + walletId: "wal", + walletCurrency: "USD", + providerId: "bitcoinCompany", + providerProductId: "p", + productSnapshot: { + name: "n", + brand: "b", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + }, + valueMinor: 1, + currency: "USD", + quantity: 1, + quoteSats: 1, + idempotencyKey: "k", + status: "BOGUS", + statusHistory: [], + expiresAt: new Date(), + }) + + const err = doc.validateSync() + expect(err?.errors.status).toBeDefined() + }) +}) diff --git a/test/flash/unit/services/mongoose/gift-card-orders.spec.ts b/test/flash/unit/services/mongoose/gift-card-orders.spec.ts new file mode 100644 index 000000000..1600dae71 --- /dev/null +++ b/test/flash/unit/services/mongoose/gift-card-orders.spec.ts @@ -0,0 +1,377 @@ +/** + * Repository behaviour with the model mocked: what query `transition` issues, + * how a non-matching conditional update is disambiguated into "not found" vs + * "wrong state", and how a duplicate idempotency key on `create` is surfaced. + * The index- and race-shaped claims are checked against a real collection in + * test/flash/integration/gift-cards/gift-card-orders.spec.ts. + */ +import { DuplicateKeyForPersistError, UnknownRepositoryError } from "@domain/errors" +import { + GiftCardOrderNotFoundError, + GiftCardOrderStateError, + GiftCardOrderStatus, + toGiftCardOrderId, +} from "@domain/gift-cards" +import { + GiftCardOrderDuplicateKeyError, + GiftCardOrdersRepository, +} from "@services/mongoose/gift-card-orders" + +const create = jest.fn() +const findOne = jest.fn() +const findOneAndUpdate = jest.fn() +const find = jest.fn() + +jest.mock("@services/mongoose/schema", () => ({ + GiftCardOrders: { + create: (...args: unknown[]) => create(...args), + findOne: (...args: unknown[]) => findOne(...args), + findOneAndUpdate: (...args: unknown[]) => findOneAndUpdate(...args), + find: (...args: unknown[]) => find(...args), + }, +})) + +const ORDER_ID = toGiftCardOrderId("1f9d2c4e-8b7a-4c3d-9e2f-0a1b2c3d4e5f") +const NOW = new Date("2026-09-09T12:00:00.000Z") + +const record = (overrides: Partial = {}): GiftCardOrderRecord => ({ + id: ORDER_ID, + accountId: "acc", + walletId: "wal", + walletCurrency: "USD", + providerId: "bitcoinCompany", + providerProductId: "amazon-us", + providerOrderId: null, + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: null, + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234, + invoiceSats: null, + paidSats: null, + paymentRequest: null, + paymentHash: null, + providerPaymentRef: null, + idempotencyKey: "idem-0001", + status: "CREATED", + statusHistory: [{ status: "CREATED", at: NOW, reason: null }], + claimCiphertext: null, + claimKeyId: null, + fulfilledAt: null, + failureReason: null, + expiresAt: new Date("2026-09-09T12:15:00.000Z"), + createdAt: NOW, + updatedAt: NOW, + ...overrides, +}) + +const chain = (result: unknown) => { + const limit = jest.fn().mockResolvedValue(result) + const sort = jest.fn(() => ({ limit })) + find.mockReturnValue({ sort }) + return { sort, limit } +} + +describe("GiftCardOrdersRepository", () => { + beforeEach(() => { + create.mockReset() + findOne.mockReset() + findOneAndUpdate.mockReset() + find.mockReset() + }) + + describe("create", () => { + const args = { + accountId: "acc" as AccountId, + walletId: "wal" as WalletId, + walletCurrency: "USD" as WalletCurrency, + providerId: "bitcoinCompany" as GiftCardProviderId, + providerProductId: "amazon-us", + productSnapshot: { + name: "Amazon.com", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: null, + }, + valueMinor: 2500, + currency: "USD", + quantity: 1, + quoteSats: 41234 as Satoshis, + idempotencyKey: "idem-0001", + expiresAt: new Date("2026-09-09T12:15:00.000Z"), + } + + it("seeds status CREATED with a single history entry and a fresh uuid", async () => { + create.mockImplementation(async (doc) => doc) + + const result = await GiftCardOrdersRepository().create(args) + + expect(create).toHaveBeenCalledTimes(1) + const written = create.mock.calls[0][0] + expect(written.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) + expect(written.status).toBe(GiftCardOrderStatus.Created) + expect(written.statusHistory).toHaveLength(1) + expect(written.statusHistory[0]).toMatchObject({ + status: GiftCardOrderStatus.Created, + reason: null, + }) + expect(written.statusHistory[0].at).toBeInstanceOf(Date) + expect(written.createdAt).toBe(written.updatedAt) + // Nothing vendor-side yet. + expect(written.providerOrderId).toBeNull() + expect(written.paymentRequest).toBeNull() + expect(written.claimCiphertext).toBeNull() + + expect(result).not.toBeInstanceOf(Error) + expect((result as GiftCardOrder).status).toBe(GiftCardOrderStatus.Created) + expect((result as GiftCardOrder).idempotencyKey).toBe("idem-0001") + }) + + it("surfaces a duplicate idempotency key as GiftCardOrderDuplicateKeyError", async () => { + create.mockRejectedValue( + new Error( + 'E11000 duplicate key error collection: galoy.giftcardorders index: walletId_1_idempotencyKey_1 dup key: { walletId: "wal", idempotencyKey: "idem-0001" }', + ), + ) + + const result = await GiftCardOrdersRepository().create(args) + + expect(result).toBeInstanceOf(GiftCardOrderDuplicateKeyError) + }) + + it("leaves a duplicate on another index as the generic duplicate error", async () => { + create.mockRejectedValue( + new Error( + 'E11000 duplicate key error collection: galoy.giftcardorders index: id_1 dup key: { id: "x" }', + ), + ) + + const result = await GiftCardOrdersRepository().create(args) + + expect(result).toBeInstanceOf(DuplicateKeyForPersistError) + expect(result).not.toBeInstanceOf(GiftCardOrderDuplicateKeyError) + }) + + it("returns, never throws, on an unknown driver error", async () => { + create.mockRejectedValue(new Error("something else")) + + const result = await GiftCardOrdersRepository().create(args) + + expect(result).toBeInstanceOf(UnknownRepositoryError) + }) + }) + + describe("find*", () => { + it("findById maps a hit and reports a miss as GiftCardOrderNotFoundError", async () => { + findOne.mockResolvedValueOnce(record()) + const hit = await GiftCardOrdersRepository().findById(ORDER_ID) + expect(findOne).toHaveBeenCalledWith({ id: { $eq: ORDER_ID } }) + expect((hit as GiftCardOrder).id).toBe(ORDER_ID) + + findOne.mockResolvedValueOnce(null) + const miss = await GiftCardOrdersRepository().findById(ORDER_ID) + expect(miss).toBeInstanceOf(GiftCardOrderNotFoundError) + }) + + it("findByIdempotencyKey scopes to the wallet", async () => { + findOne.mockResolvedValueOnce(record()) + + await GiftCardOrdersRepository().findByIdempotencyKey({ + walletId: "wal" as WalletId, + idempotencyKey: "idem-0001", + }) + + expect(findOne).toHaveBeenCalledWith({ + walletId: { $eq: "wal" }, + idempotencyKey: { $eq: "idem-0001" }, + }) + }) + + it("findByProviderOrderId scopes to the provider", async () => { + findOne.mockResolvedValueOnce(null) + + const result = await GiftCardOrdersRepository().findByProviderOrderId({ + providerId: "bitcoinCompany", + providerOrderId: "tbc_1" as GiftCardProviderOrderId, + }) + + expect(findOne).toHaveBeenCalledWith({ + providerId: { $eq: "bitcoinCompany" }, + providerOrderId: { $eq: "tbc_1" }, + }) + expect(result).toBeInstanceOf(GiftCardOrderNotFoundError) + }) + }) + + describe("list*", () => { + it("listByAccount is newest first and pages by createdAt", async () => { + const { sort, limit } = chain([record()]) + const before = new Date("2026-09-09T11:00:00.000Z") + + const result = await GiftCardOrdersRepository().listByAccount({ + accountId: "acc" as AccountId, + limit: 20, + before, + }) + + expect(find).toHaveBeenCalledWith({ + accountId: { $eq: "acc" }, + createdAt: { $lt: before }, + }) + expect(sort).toHaveBeenCalledWith({ createdAt: -1 }) + expect(limit).toHaveBeenCalledWith(20) + expect(result).toHaveLength(1) + }) + + it("listByAccount omits the createdAt filter without a cursor", async () => { + chain([]) + + await GiftCardOrdersRepository().listByAccount({ + accountId: "acc" as AccountId, + limit: 5, + }) + + expect(find).toHaveBeenCalledWith({ accountId: { $eq: "acc" } }) + }) + + it("listByStatus is oldest updatedAt first", async () => { + const { sort, limit } = chain([record({ status: "PAID" })]) + const updatedBefore = new Date("2026-09-09T11:00:00.000Z") + + const result = await GiftCardOrdersRepository().listByStatus({ + statuses: [GiftCardOrderStatus.Paid, GiftCardOrderStatus.InvoiceIssued], + updatedBefore, + limit: 50, + }) + + expect(find).toHaveBeenCalledWith({ + status: { $in: ["PAID", "INVOICE_ISSUED"] }, + updatedAt: { $lt: updatedBefore }, + }) + expect(sort).toHaveBeenCalledWith({ updatedAt: 1 }) + expect(limit).toHaveBeenCalledWith(50) + expect((result as GiftCardOrder[])[0].status).toBe("PAID") + }) + }) + + describe("transition", () => { + it("is one conditional findOneAndUpdate on the current status", async () => { + findOneAndUpdate.mockResolvedValue( + record({ + status: "INVOICE_ISSUED", + paymentRequest: "lnbc1...", + invoiceSats: 41300, + }), + ) + + const result = await GiftCardOrdersRepository().transition({ + id: ORDER_ID, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + reason: "vendor invoice", + patch: { paymentRequest: "lnbc1...", invoiceSats: 41300 as Satoshis }, + }) + + expect(findOneAndUpdate).toHaveBeenCalledTimes(1) + const [filter, update, options] = findOneAndUpdate.mock.calls[0] + expect(filter).toEqual({ id: { $eq: ORDER_ID }, status: { $in: ["CREATED"] } }) + expect(update.$set).toMatchObject({ + paymentRequest: "lnbc1...", + invoiceSats: 41300, + status: "INVOICE_ISSUED", + }) + expect(update.$set.updatedAt).toBeInstanceOf(Date) + expect(update.$push.statusHistory).toMatchObject({ + status: "INVOICE_ISSUED", + reason: "vendor invoice", + }) + expect(update.$push.statusHistory.at).toBe(update.$set.updatedAt) + expect(options).toEqual({ new: true }) + // Nothing else was read: the matched document is the answer. + expect(findOne).not.toHaveBeenCalled() + expect((result as GiftCardOrder).status).toBe("INVOICE_ISSUED") + }) + + it("records a null reason when none is given", async () => { + findOneAndUpdate.mockResolvedValue(record({ status: "PAID" })) + + await GiftCardOrdersRepository().transition({ + id: ORDER_ID, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.Paid, + }) + + const [filter, update] = findOneAndUpdate.mock.calls[0] + expect(filter.status).toEqual({ $in: ["INVOICE_ISSUED", "PAYMENT_PENDING"] }) + expect(update.$push.statusHistory.reason).toBeNull() + }) + + it("refuses an illegal from → to before touching Mongo", async () => { + const result = await GiftCardOrdersRepository().transition({ + id: ORDER_ID, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.Paid, + }) + + expect(result).toBeInstanceOf(GiftCardOrderStateError) + expect((result as Error).message).toBe("cannot move CREATED → PAID") + expect(findOneAndUpdate).not.toHaveBeenCalled() + expect(findOne).not.toHaveBeenCalled() + }) + + it("reports not-found when nothing matched and the order does not exist", async () => { + findOneAndUpdate.mockResolvedValue(null) + findOne.mockResolvedValue(null) + + const result = await GiftCardOrdersRepository().transition({ + id: ORDER_ID, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + }) + + expect(result).toBeInstanceOf(GiftCardOrderNotFoundError) + expect(findOne).toHaveBeenCalledWith({ id: { $eq: ORDER_ID } }) + }) + + it("reports the current state when nothing matched because the order moved on", async () => { + // A second worker already took CREATED → INVOICE_ISSUED. + findOneAndUpdate.mockResolvedValue(null) + findOne.mockResolvedValue(record({ status: "INVOICE_ISSUED" })) + + const result = await GiftCardOrdersRepository().transition({ + id: ORDER_ID, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.InvoiceIssued, + }) + + expect(result).toBeInstanceOf(GiftCardOrderStateError) + expect((result as Error).message).toBe( + "cannot move INVOICE_ISSUED → INVOICE_ISSUED", + ) + }) + + it("returns, never throws, on a driver error", async () => { + findOneAndUpdate.mockRejectedValue(new Error("connection 1 to mongo:27017 closed")) + + const result = await GiftCardOrdersRepository().transition({ + id: ORDER_ID, + from: [GiftCardOrderStatus.Created], + to: GiftCardOrderStatus.Failed, + }) + + expect(result).toBeInstanceOf(Error) + expect(result).not.toBeInstanceOf(GiftCardOrderStateError) + }) + }) +}) From 63a64a42706a3d9cac661f0769b400c1eafbb7a1 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 9 Sep 2026 23:59:28 -0700 Subject: [PATCH 02/14] feat(graphql): gift card catalog, quote, purchase, and order queries (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 Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf --- dev/apollo-federation/supergraph.graphql | 436 ++++++++++++++++++ src/domain/api-keys/scope-map.ts | 9 + src/graphql/public/mutations.ts | 6 + src/graphql/public/queries.ts | 8 + src/graphql/public/root/gift-card-gate.ts | 34 ++ .../root/mutation/gift-card-purchase.ts | 114 +++++ .../public/root/query/gift-card-catalog.ts | 100 ++++ .../public/root/query/gift-card-order.ts | 44 ++ .../public/root/query/gift-card-orders.ts | 88 ++++ .../public/root/query/gift-card-quote.ts | 67 +++ src/graphql/public/root/query/globals.ts | 9 + src/graphql/public/schema.graphql | 406 ++++++++++++++++ .../types/input/gift-card-purchase-input.ts | 40 ++ .../public/types/object/gift-card-order.ts | 277 +++++++++++ .../public/types/object/gift-card-product.ts | 122 +++++ .../public/types/object/gift-card-quote.ts | 86 ++++ src/graphql/public/types/object/globals.ts | 7 + .../types/payload/gift-card-purchase.ts | 21 + .../root/mutation/gift-card-purchase.spec.ts | 414 +++++++++++++++++ .../root/query/gift-card-catalog.spec.ts | 220 +++++++++ .../public/root/query/gift-card-order.spec.ts | 198 ++++++++ .../root/query/gift-card-orders.spec.ts | 204 ++++++++ .../public/root/query/gift-card-quote.spec.ts | 169 +++++++ .../root/query/globals-gift-cards.spec.ts | 74 +++ .../types/object/gift-card-order.spec.ts | 211 +++++++++ 25 files changed, 3364 insertions(+) create mode 100644 src/graphql/public/root/gift-card-gate.ts create mode 100644 src/graphql/public/root/mutation/gift-card-purchase.ts create mode 100644 src/graphql/public/root/query/gift-card-catalog.ts create mode 100644 src/graphql/public/root/query/gift-card-order.ts create mode 100644 src/graphql/public/root/query/gift-card-orders.ts create mode 100644 src/graphql/public/root/query/gift-card-quote.ts create mode 100644 src/graphql/public/types/input/gift-card-purchase-input.ts create mode 100644 src/graphql/public/types/object/gift-card-order.ts create mode 100644 src/graphql/public/types/object/gift-card-product.ts create mode 100644 src/graphql/public/types/object/gift-card-quote.ts create mode 100644 src/graphql/public/types/payload/gift-card-purchase.ts create mode 100644 test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts create mode 100644 test/flash/unit/graphql/public/root/query/gift-card-catalog.spec.ts create mode 100644 test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts create mode 100644 test/flash/unit/graphql/public/root/query/gift-card-orders.spec.ts create mode 100644 test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts create mode 100644 test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts create mode 100644 test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 3772037c6..c579378fa 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1329,6 +1329,368 @@ type FygaroTopupStatus state: FygaroTopupState! } +"""A scannable representation of the claim, when the vendor supplies one.""" +type GiftCardBarcode + @join__type(graph: PUBLIC) +{ + """The characters to encode into the barcode.""" + chars: String! + + """ + The barcode symbology, e.g. 'CODE128', 'QR'. Vendor-supplied; render accordingly. + """ + type: String! +} + +""" +The redemption data for a FULFILLED order. Bearer data: anyone holding it can spend the card. Returned only to the owning account, only once fulfilled, and never in a list — fetch it with giftCardOrder(id:). +""" +type GiftCardClaim + @join__type(graph: PUBLIC) +{ + """ + Scannable form of the claim for in-store use, when the vendor supplies one. + """ + barcode: GiftCardBarcode + + """ + A vendor page where the card can be viewed or redeemed, when one exists. + """ + claimLink: String + + """ + The code(s) to redeem. Usually one; some cards carry a number and a PIN. + """ + codes: [GiftCardClaimCode!]! +} + +"""One redeemable code on a fulfilled card.""" +type GiftCardClaimCode + @join__type(graph: PUBLIC) +{ + """ + What the vendor calls this code, e.g. 'Card number', 'PIN'. Null when the card has a single unlabelled code. + """ + label: String + + """ + The code itself. Bearer data: show it, let the customer copy it, never log it. + """ + value: String! +} + +enum GiftCardDenominationType + @join__type(graph: PUBLIC) +{ + """ + The card is sold only at the amounts listed in `denominations`; `minValue` and `maxValue` are null. A purchase at any other value is refused. + """ + FIXED @join__enumValue(graph: PUBLIC) + + """ + Any whole amount from `minValue` to `maxValue` inclusive; `denominations` is empty. + """ + VARIABLE @join__enumValue(graph: PUBLIC) +} + +""" +A gift card purchase, from creation through fulfilment. Poll giftCardOrder(id:) while `status` is transient; the claim appears once it is FULFILLED. +""" +type GiftCardOrder + @join__type(graph: PUBLIC) +{ + """ + The redemption data. Present ONLY when status is FULFILLED and ONLY via giftCardOrder(id:) or the purchase payload — never in giftCardOrders. + """ + claim: GiftCardClaim + + """When the order was placed.""" + createdAt: Timestamp! + + """ISO 4217 currency of `value`.""" + currency: String! + + """ + Short machine-oriented reason for a FAILED / PAYMENT_FAILED / REFUND_REQUIRED order, for support and diagnostics. Not customer wording; choose the copy from `status`. + """ + failureReason: String + + """When the card was issued. Null until FULFILLED.""" + fulfilledAt: Timestamp + + """Pass to giftCardOrder(id:) to poll. Stable for the life of the order.""" + id: ID! + + """ + What actually left the wallet, in sats. Null until the payment settles (and forever null for an order that never paid). + """ + paidSats: SatAmount + + """The card that was bought, as it was at purchase time.""" + product: GiftCardProductSnapshot! + + """How many cards the order is for.""" + quantity: Int! + + """Where the order is in its lifecycle. See each value for what to show.""" + status: GiftCardOrderStatus! + + """Face value of ONE card, minor units of `currency`.""" + value: CentAmount! +} + +"""A connection to a list of items.""" +type GiftCardOrderConnection + @join__type(graph: PUBLIC) +{ + """A list of edges.""" + edges: [GiftCardOrderEdge!] + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type GiftCardOrderEdge + @join__type(graph: PUBLIC) +{ + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: GiftCardOrder! +} + +enum GiftCardOrderStatus + @join__type(graph: PUBLIC) +{ + """ + The order exists and the vendor has not yet been asked for an invoice. Transient; nothing has left the wallet. Keep polling. + """ + CREATED @join__enumValue(graph: PUBLIC) + + """ + Terminal. The invoice was not paid before it expired; nothing left the wallet. + """ + EXPIRED @join__enumValue(graph: PUBLIC) + + """ + Terminal. Refused BEFORE any payment left the wallet: the vendor rejected the order, the price moved past tolerance, or the invoice could not be read. `failureReason` says which. Nothing to refund; safe to try again. + """ + FAILED @join__enumValue(graph: PUBLIC) + + """ + Terminal. The card is issued; `claim` carries the redemption data and `fulfilledAt` says when. + """ + FULFILLED @join__enumValue(graph: PUBLIC) + + """ + The vendor's invoice is in hand and is about to be paid from the wallet. Transient. Keep polling. + """ + INVOICE_ISSUED @join__enumValue(graph: PUBLIC) + + """ + The wallet has paid the vendor and the card is being issued. Transient. Keep polling. + """ + PAID @join__enumValue(graph: PUBLIC) + + """ + Terminal. The Lightning payment failed and nothing left the wallet. Safe to try again with a new idempotencyKey. + """ + PAYMENT_FAILED @join__enumValue(graph: PUBLIC) + + """ + The Lightning payment is in flight and has neither settled nor failed. Transient, and can take minutes on a slow route. Keep polling; do not purchase again. + """ + PAYMENT_PENDING @join__enumValue(graph: PUBLIC) + + """ + Terminal. The wallet paid but the vendor did not deliver a card. Flash has been alerted and will make it right; there is nothing for the customer to do and they must NOT be told to buy again. + """ + REFUND_REQUIRED @join__enumValue(graph: PUBLIC) +} + +""" +One purchasable gift card in the catalog for a country. Money fields are minor units of `currency` (cents for USD); they are face values, not what the customer pays in sats — ask giftCardQuote for that. +""" +type GiftCardProduct + @join__type(graph: PUBLIC) +{ + """The merchant whose card this is. Catalog order is by brand, then name.""" + brand: String! + + """ + Vendor-supplied grouping labels, e.g. 'Shopping'. Any one of them can be passed back as giftCardCatalog(category:) to filter. + """ + categories: [String!]! + + """The country the card is redeemable in.""" + countryCode: CountryCode! + + """ISO 4217 currency of every money field on this product.""" + currency: String! + + """Whether the card is sold at fixed amounts or any amount in a range.""" + denominationType: GiftCardDenominationType! + + """ + The purchasable face values for a FIXED card, minor units, ascending. Empty for a VARIABLE card. + """ + denominations: [CentAmount!]! + + """ + Pass to giftCardQuote and giftCardPurchase. Opaque; stable across catalog refreshes for the same card. + """ + id: ID! + + """ + True for a network-branded prepaid card (Visa/Mastercard-style) spendable anywhere, as opposed to a single-merchant card. Only listed when the operator allows them. + """ + isOpenLoop: Boolean! + + """Brand artwork, when the vendor supplies one.""" + logoUrl: String + + """Largest face value for a VARIABLE card, minor units. Null for FIXED.""" + maxValue: CentAmount + + """Smallest face value for a VARIABLE card, minor units. Null for FIXED.""" + minValue: CentAmount + + """Display name of the card, e.g. 'Amazon.com Gift Card'.""" + name: String! + + """ + Vendor reward on face value, in basis points (100 = 1%). Informational; the sats actually rebated for a given purchase are on the quote as rewardSats. + """ + rewardBps: Int! + + """The card's terms and conditions, when the vendor supplies a link.""" + termsUrl: String +} + +"""A connection to a list of items.""" +type GiftCardProductConnection + @join__type(graph: PUBLIC) +{ + """A list of edges.""" + edges: [GiftCardProductEdge!] + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type GiftCardProductEdge + @join__type(graph: PUBLIC) +{ + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: GiftCardProduct! +} + +""" +The product as it was when the order was placed. Frozen on the order so a catalog refresh (rename, delisting) never changes what a past order says it bought. +""" +type GiftCardProductSnapshot + @join__type(graph: PUBLIC) +{ + """Merchant at purchase time.""" + brand: String! + + """Country the card is redeemable in.""" + countryCode: CountryCode! + + """ISO 4217 currency of the order's `value`.""" + currency: String! + + """True for a network-branded prepaid card spendable anywhere.""" + isOpenLoop: Boolean! + + """Brand artwork at purchase time, if any.""" + logoUrl: String + + """Display name at purchase time.""" + name: String! +} + +input GiftCardPurchaseInput + @join__type(graph: PUBLIC) +{ + """ + Client-generated, 8 to 64 characters, unique per purchase attempt (a UUID is ideal). Retrying with the SAME key and the same product/value/quantity returns the existing order instead of buying again; the same key with different parameters is refused. Generate a new key for a genuinely new purchase. + """ + idempotencyKey: String! + + """A GiftCardProduct.id from giftCardCatalog.""" + productId: ID! + + """How many cards, 1 to 10. Defaults to 1.""" + quantity: Int = 1 + + """ + Face value of ONE card, minor units of the product's currency. Must be one of the product's denominations (FIXED) or within minValue..maxValue (VARIABLE). + """ + value: CentAmount! + + """ + The wallet that pays. Must belong to the calling account; the sats cost from giftCardQuote is what leaves it. + """ + walletId: WalletId! +} + +type GiftCardPurchasePayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + + """ + The order, in whatever state the purchase reached before returning — often already FULFILLED with the claim attached, sometimes still PAID or PAYMENT_PENDING. Poll giftCardOrder(id:) while the status is transient. Absent when the purchase was refused before an order existed; may be present ALONGSIDE an error when the card was issued but its claim could not be read. + """ + order: GiftCardOrder +} + +""" +What a gift card purchase would cost right now. Not a reservation: nothing is held and no limit is consumed. giftCardPurchase re-prices at order time and refuses to pay if the vendor's invoice drifted past tolerance from this, so a stale quote costs a retry, never money. +""" +type GiftCardQuote + @join__type(graph: PUBLIC) +{ + """ISO 4217 currency of `value` and `fiatCost`.""" + currency: String! + + """ + After this the vendor no longer honours the price. Ask again before purchasing; the purchase itself re-quotes regardless. + """ + expiresAt: Timestamp! + + """ + What the vendor charges for the whole order, minor units of `currency`. Can differ from value x quantity when the vendor prices below face. + """ + fiatCost: CentAmount! + + """The product this quote is for, echoed back.""" + productId: ID! + + """How many cards the quote covers, echoed back.""" + quantity: Int! + + """ + Sats the vendor rebates on this order once fulfilled. Zero when the card carries no reward. + """ + rewardSats: SatAmount! + + """ + What leaves the wallet for the whole order, in sats, at the vendor's current price. This is the number to show as the price. + """ + satsCost: SatAmount! + + """Face value of ONE card, minor units of `currency`, echoed back.""" + value: CentAmount! +} + """ Provides global settings for the application which might have an impact for the user. """ @@ -1356,6 +1718,14 @@ type Globals """ fygaroTopup: FygaroTopupInfo + """ + Whether gift card entry points (the storefront tab, "buy a + gift card" actions) should be shown to the user. True only when the + instance-wide giftCards feature flag is on AND at least one gift card + provider is enabled; when false every giftCard* field refuses. + """ + giftCardsEnabled: Boolean! + """ The domain name for lightning addresses accepted by this Galoy instance """ @@ -1995,6 +2365,11 @@ type Mutation deviceNotificationTokenCreate(input: DeviceNotificationTokenCreateInput!): SuccessPayload! feedbackSubmit(input: FeedbackSubmitInput!): SuccessPayload! fygaroCheckoutCreate(input: FygaroCheckoutCreateInput!): FygaroCheckoutCreatePayload! + + """ + Buy a gift card: pays the vendor from `walletId` over Lightning and returns the order. Re-prices at order time and refuses to pay if the price drifted past tolerance from the quote — no money moves on a refusal. Money moves at most once per idempotencyKey, so retry a timed-out call with the SAME key. + """ + giftCardPurchase(input: GiftCardPurchaseInput!): GiftCardPurchasePayload! idDocumentUploadUrlGenerate(input: IdDocumentUploadUrlGenerateInput!): IdDocumentUploadUrlPayload! """ @@ -2500,6 +2875,67 @@ type Query The outcome of a card top-up, by the checkout id returned from fygaroCheckoutCreate. Null when the checkout is unknown, expired, or not this account's — never as a way of saying 'not yet': a checkout we simply cannot read right now comes back as UNCONFIRMED so a transient fault is not reported to the customer as a payment that never existed. """ fygaroTopupStatus(checkoutId: String!): FygaroTopupStatus + + """ + The gift cards on sale for a country, in stock only, ordered by brand then name. Served from a cached catalog: a card can be listed and then refused by giftCardQuote if it sold out in between. Paginate forwards with `first`/`after`. + """ + giftCardCatalog( + """ + The `endCursor` (or any edge cursor) from the previous page. An unknown cursor restarts from the beginning rather than guessing; the client may see a repeat, never a gap. + """ + after: String + + """Keep only cards carrying this category label (case-insensitive).""" + category: String + + """ + Which country's catalog. Defaults to the calling account's country, which is also the country the purchase will be routed by — browse another country's catalog and the purchase of a card from it is refused as not available. + """ + countryCode: CountryCode + + """Page size, 1 to 200. Defaults to 50.""" + first: Int + + """ + Keep only cards whose name or brand contains this text (case-insensitive). + """ + search: String + ): GiftCardProductConnection! + + """ + One of the calling account's gift card orders, by id — the endpoint to poll after giftCardPurchase, and the ONLY place the claim is returned. Null when the id is unknown or belongs to another account; the two are deliberately indistinguishable. + """ + giftCardOrder( + """A GiftCardOrder.id from giftCardPurchase or giftCardOrders.""" + id: ID! + ): GiftCardOrder + + """ + The calling account's gift card orders, newest first. Claims are never part of a listing — fetch one order with giftCardOrder(id:) for that. + """ + giftCardOrders( + """The `endCursor` (or any edge cursor) from the previous page.""" + after: String + + """Page size, 1 to 100. Defaults to 20.""" + first: Int + ): GiftCardOrderConnection! + + """ + What a purchase would cost right now, in sats, for a product at a face value. Refuses exactly what giftCardPurchase would refuse — a value off the card's denominations, a card not sold in the account's country, a sold-out card — so the client can surface that before asking the customer to confirm. + """ + giftCardQuote( + """A GiftCardProduct.id from giftCardCatalog.""" + productId: ID! + + """How many cards, 1 to 10. Defaults to 1.""" + quantity: Int = 1 + + """ + Face value of ONE card, minor units of the product's currency. Must be one of the product's denominations (FIXED) or within minValue..maxValue (VARIABLE). + """ + value: CentAmount! + ): GiftCardQuote! globals: Globals invitePreview(token: String!): InvitePreview isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index 3824d82f9..997ed870c 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -28,6 +28,13 @@ export const apiKeyScopeForField: Readonly> = bridgeWithdrawalRequest: "BLOCKED", bridgeWithdrawals: "BLOCKED", apiKeys: "BLOCKED", + // Gift cards: the order reads return bearer redemption codes, and the + // catalog/quote are only useful as a prelude to a purchase an API key + // cannot make. Session-only, the whole family. + giftCardCatalog: "BLOCKED", + giftCardQuote: "BLOCKED", + giftCardOrder: "BLOCKED", + giftCardOrders: "BLOCKED", onChainTxFee: "read:wallet", onChainUsdTxFee: "read:wallet", onChainUsdTxFeeAsBtcDenominated: "read:wallet", @@ -68,6 +75,8 @@ export const apiKeyScopeForField: Readonly> = // Mints a payment link that charges a card. Interactive-session only, in // the same family as the cashout and Bridge money-movement mutations. fygaroCheckoutCreate: "BLOCKED", + // Pays a vendor from the wallet AND hands back bearer codes. Same family. + giftCardPurchase: "BLOCKED", requestCashout: "BLOCKED", initiateCashout: "BLOCKED", apiKeyCreate: "BLOCKED", diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 5b3e375ff..abe811d1c 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -80,6 +80,7 @@ import ApiKeyCreateMutation from "./root/mutation/api-key-create" import ApiKeyRevokeMutation from "./root/mutation/api-key-revoke" import ApiKeyRotateMutation from "./root/mutation/api-key-rotate" import BankAccountUpdateRequestMutation from "./root/mutation/bank-account-update-request" +import GiftCardPurchaseMutation from "./root/mutation/gift-card-purchase" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } export const mutationFields = { @@ -185,6 +186,11 @@ export const mutationFields = { requestCashout: RequestCashoutMutation, initiateCashout: InitiateCashoutMutation, + + // Pays a vendor invoice from input.walletId; wallet-level so the walletId + // middleware proves ownership before the resolver runs (the app layer + // checks again). + giftCardPurchase: GiftCardPurchaseMutation, }, }, } diff --git a/src/graphql/public/queries.ts b/src/graphql/public/queries.ts index 48266aff4..95f2c7c09 100644 --- a/src/graphql/public/queries.ts +++ b/src/graphql/public/queries.ts @@ -34,6 +34,10 @@ import ApiKeysQuery from "./root/query/api-keys" import InvitePreviewQuery from "./root/query/invite-preview" import CashoutRateQuery from "./root/query/cashout-rate" import MyReferralsQuery from "./root/query/my-referrals" +import GiftCardCatalogQuery from "./root/query/gift-card-catalog" +import GiftCardQuoteQuery from "./root/query/gift-card-quote" +import GiftCardOrderQuery from "./root/query/gift-card-order" +import GiftCardOrdersQuery from "./root/query/gift-card-orders" export const queryFields = { unauthed: { @@ -70,6 +74,10 @@ export const queryFields = { apiKeys: ApiKeysQuery, cashoutRate: CashoutRateQuery, myReferrals: MyReferralsQuery, + giftCardCatalog: GiftCardCatalogQuery, + giftCardQuote: GiftCardQuoteQuery, + giftCardOrder: GiftCardOrderQuery, + giftCardOrders: GiftCardOrdersQuery, }, atWalletLevel: { onChainTxFee: OnChainTxFeeQuery, diff --git a/src/graphql/public/root/gift-card-gate.ts b/src/graphql/public/root/gift-card-gate.ts new file mode 100644 index 000000000..2c130cdd6 --- /dev/null +++ b/src/graphql/public/root/gift-card-gate.ts @@ -0,0 +1,34 @@ +import { giftCardsMasterGate, resolveAccountCountryCodeOrUnknown } from "@app/gift-cards" + +/** + * The gate every gift-card root field opens with, in one place. + * + * Resolves the routing country (the explicit argument when a field takes one, + * otherwise the account's), runs `giftCardsMasterGate` on it, and hands back + * the country it gated on so the caller passes THAT country down — not a + * second resolution that could disagree with the one the gate saw. + * + * Queries throw the mapped error; the mutation returns it in `errors`. Both + * start here so the catalog can never show what the purchase would refuse. + */ +export type GiftCardGate = + | { ok: true; providerId: GiftCardProviderId; countryCode: string } + | { + ok: false + error: Extract, { ok: false }>["error"] + countryCode: string + } + +export const gateGiftCardsForAccount = async ({ + account, + countryCode, +}: { + account: Pick + countryCode?: string +}): Promise => { + const cc = countryCode ?? (await resolveAccountCountryCodeOrUnknown(account)) + const gate = giftCardsMasterGate(cc) + return gate.ok + ? { ok: true, providerId: gate.providerId, countryCode: cc } + : { ok: false, error: gate.error, countryCode: cc } +} diff --git a/src/graphql/public/root/mutation/gift-card-purchase.ts b/src/graphql/public/root/mutation/gift-card-purchase.ts new file mode 100644 index 000000000..d4b89eff3 --- /dev/null +++ b/src/graphql/public/root/mutation/gift-card-purchase.ts @@ -0,0 +1,114 @@ +import { getGiftCardOrderForAccount, purchaseGiftCard } from "@app/gift-cards" +import { GiftCardOrderStatus } from "@domain/gift-cards" +import { InputValidationError } from "@graphql/error" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { GT } from "@graphql/index" +import { gateGiftCardsForAccount } from "@graphql/public/root/gift-card-gate" +import GiftCardPurchaseInput from "@graphql/public/types/input/gift-card-purchase-input" +import { toGiftCardOrderSource } from "@graphql/public/types/object/gift-card-order" +import GiftCardPurchasePayload from "@graphql/public/types/payload/gift-card-purchase" + +// Client-generated, so bounded here rather than trusting the app layer's looser +// storage cap: short keys collide, long keys are someone stuffing the index. +export const GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH = 8 +export const GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH = 64 + +const isValidIdempotencyKey = (key: unknown): key is string => + typeof key === "string" && + key.length >= GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH && + key.length <= GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH && + !/\s/.test(key) + +type GiftCardPurchaseInputArgs = { + productId: string + value: number | InputValidationError + quantity?: number | null + walletId: WalletId | InputValidationError + idempotencyKey: string +} + +const GiftCardPurchaseMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { input: GiftCardPurchaseInputArgs } +>({ + extensions: { complexity: 120 }, + type: GT.NonNull(GiftCardPurchasePayload), + description: + "Buy a gift card: pays the vendor from `walletId` over Lightning and returns the " + + "order. Re-prices at order time and refuses to pay if the price drifted past " + + "tolerance from the quote — no money moves on a refusal. Money moves at most once " + + "per idempotencyKey, so retry a timed-out call with the SAME key.", + args: { + input: { type: GT.NonNull(GiftCardPurchaseInput) }, + }, + resolve: async (_, args, { domainAccount }) => { + const { productId, value, walletId, idempotencyKey } = args.input + const quantity = args.input.quantity ?? 1 + + // What the scalars hand back for a bad value is an InputValidationError — an + // Apollo error, NOT an ApplicationError. Run through the error map it falls + // off the end of the switch and throws `assertUnreachable`. Same handling as + // every other scalar-validated mutation (see fygaro-checkout-create). + if (value instanceof Error) return { errors: [{ message: value.message }] } + if (walletId instanceof Error) return { errors: [{ message: walletId.message }] } + if (!isValidIdempotencyKey(idempotencyKey)) { + return { + errors: [ + { + message: + `idempotencyKey must be ${GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH} to ` + + `${GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH} characters with no whitespace`, + }, + ], + } + } + + // The deploy-level gate, before anything else — the same one the catalog + // and quote opened with, so nothing offered upstream is refused here for a + // different reason. `purchaseGiftCard` gates again internally (it must stand + // alone); the cost is one user read. + const gate = await gateGiftCardsForAccount({ account: domainAccount }) + if (!gate.ok) return { errors: [mapAndParseErrorForGqlResponse(gate.error)] } + + // The purchase attempt budget (RateLimitConfig.giftCardPurchase) is consumed + // INSIDE purchaseGiftCard, before any vendor or store round-trip. Not here: + // charging it twice would halve every customer's allowance. + const order = await purchaseGiftCard({ + accountId: domainAccount.id, + walletId, + productId, + valueMinor: value, + quantity, + idempotencyKey, + }) + if (order instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(order)] } + } + + if (order.status !== GiftCardOrderStatus.Fulfilled) { + return { errors: [], order: toGiftCardOrderSource(order) } + } + + // Already fulfilled on the purchase's own first poll: hand over the claim + // now and save the client a round trip. The decrypt lives in ONE place — the + // owner-scoped read — so it is called rather than re-implemented. If the + // claim cannot be read, the order is still returned (the card IS issued) and + // the error rides alongside so the client does not show a FULFILLED order + // with a silently empty claim. + const withClaim = await getGiftCardOrderForAccount({ + accountId: domainAccount.id, + orderId: order.id, + }) + if (withClaim instanceof Error) { + return { + errors: [mapAndParseErrorForGqlResponse(withClaim)], + order: toGiftCardOrderSource(order), + } + } + + return { errors: [], order: toGiftCardOrderSource(withClaim.order, withClaim.claim) } + }, +}) + +export default GiftCardPurchaseMutation diff --git a/src/graphql/public/root/query/gift-card-catalog.ts b/src/graphql/public/root/query/gift-card-catalog.ts new file mode 100644 index 000000000..52a4d5786 --- /dev/null +++ b/src/graphql/public/root/query/gift-card-catalog.ts @@ -0,0 +1,100 @@ +import { listGiftCardProducts } from "@app/gift-cards" +import { encodeGiftCardProductCursor } from "@domain/gift-cards" +import { InputValidationError } from "@graphql/error" +import { mapError } from "@graphql/error-map" +import { GT } from "@graphql/index" +import { gateGiftCardsForAccount } from "@graphql/public/root/gift-card-gate" +import { GiftCardProductConnection } from "@graphql/public/types/object/gift-card-product" +import CountryCode from "@graphql/public/types/scalar/country-code" + +type GiftCardCatalogArgs = { + countryCode?: string | InputValidationError | null + category?: string | null + search?: string | null + first?: number | null + after?: string | null +} + +const GiftCardCatalogQuery = GT.Field< + null, + GraphQLPublicContextAuth, + GiftCardCatalogArgs +>({ + type: GT.NonNull(GiftCardProductConnection), + description: + "The gift cards on sale for a country, in stock only, ordered by brand then name. " + + "Served from a cached catalog: a card can be listed and then refused by " + + "giftCardQuote if it sold out in between. Paginate forwards with `first`/`after`.", + args: { + countryCode: { + type: CountryCode, + description: + "Which country's catalog. Defaults to the calling account's country, which is " + + "also the country the purchase will be routed by — browse another country's " + + "catalog and the purchase of a card from it is refused as not available.", + }, + category: { + type: GT.String, + description: "Keep only cards carrying this category label (case-insensitive).", + }, + search: { + type: GT.String, + description: + "Keep only cards whose name or brand contains this text (case-insensitive).", + }, + first: { + type: GT.Int, + description: "Page size, 1 to 200. Defaults to 50.", + }, + after: { + type: GT.String, + description: + "The `endCursor` (or any edge cursor) from the previous page. An unknown cursor " + + "restarts from the beginning rather than guessing; the client may see a repeat, " + + "never a gap.", + }, + }, + resolve: async (_, args, { domainAccount }) => { + // CountryCode hands back an InputValidationError for a malformed code — an + // Apollo error already, so it is thrown as-is rather than mapped. + if (args.countryCode instanceof Error) throw args.countryCode + if (typeof args.first === "number" && args.first < 1) { + throw new InputValidationError({ + message: 'Argument "first" must be greater than 0', + }) + } + + const gate = await gateGiftCardsForAccount({ + account: domainAccount, + countryCode: args.countryCode ?? undefined, + }) + if (!gate.ok) throw mapError(gate.error) + + const page = await listGiftCardProducts({ + countryCode: gate.countryCode, + category: args.category ?? undefined, + search: args.search ?? undefined, + first: args.first ?? undefined, + after: args.after ?? undefined, + }) + if (page instanceof Error) throw mapError(page) + + const edges = page.products.map((product) => ({ + node: product, + cursor: encodeGiftCardProductCursor(product.id), + })) + return { + edges, + pageInfo: { + hasNextPage: page.hasNextPage, + // Forward-only pagination; the relay contract lets hasPreviousPage be + // false when paginating forwards (same as connectionFromPaginatedArray). + hasPreviousPage: false, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: page.endCursor, + }, + } + }, +}) + +export default GiftCardCatalogQuery diff --git a/src/graphql/public/root/query/gift-card-order.ts b/src/graphql/public/root/query/gift-card-order.ts new file mode 100644 index 000000000..9c4194127 --- /dev/null +++ b/src/graphql/public/root/query/gift-card-order.ts @@ -0,0 +1,44 @@ +import { getGiftCardOrderForAccount } from "@app/gift-cards" +import { GiftCardOrderNotFoundError } from "@domain/gift-cards" +import { mapError } from "@graphql/error-map" +import { GT } from "@graphql/index" +import GiftCardOrder, { + toGiftCardOrderSource, +} from "@graphql/public/types/object/gift-card-order" + +type GiftCardOrderArgs = { id: string } + +const GiftCardOrderQuery = GT.Field({ + type: GiftCardOrder, + description: + "One of the calling account's gift card orders, by id — the endpoint to poll " + + "after giftCardPurchase, and the ONLY place the claim is returned. Null when " + + "the id is unknown or belongs to another account; the two are deliberately " + + "indistinguishable.", + args: { + id: { + type: GT.NonNullID, + description: "A GiftCardOrder.id from giftCardPurchase or giftCardOrders.", + }, + }, + resolve: async (_, args, { domainAccount }) => { + // Deliberately NOT behind the gift card master gate: this is an owner-scoped + // read of orders the customer already paid for. Switching the rail off (or a + // provider being disabled for their country) must never hide codes they own. + // Ownership is enforced in the app layer, which returns NotFound for non-owners. + + // The account id comes from the session, never from an argument: the order + // id alone cannot name whose order is being read. The app layer answers + // "not found" for another account's order, which becomes null here. + const result = await getGiftCardOrderForAccount({ + accountId: domainAccount.id, + orderId: args.id as GiftCardOrderId, + }) + if (result instanceof GiftCardOrderNotFoundError) return null + if (result instanceof Error) throw mapError(result) + + return toGiftCardOrderSource(result.order, result.claim) + }, +}) + +export default GiftCardOrderQuery diff --git a/src/graphql/public/root/query/gift-card-orders.ts b/src/graphql/public/root/query/gift-card-orders.ts new file mode 100644 index 000000000..8a09ac517 --- /dev/null +++ b/src/graphql/public/root/query/gift-card-orders.ts @@ -0,0 +1,88 @@ +import { listGiftCardOrdersForAccount } from "@app/gift-cards" +import { InputValidationError } from "@graphql/error" +import { mapError } from "@graphql/error-map" +import { GT } from "@graphql/index" +import { + GiftCardOrderConnection, + toGiftCardOrderSource, +} from "@graphql/public/types/object/gift-card-order" + +/** + * Order-list cursors. The app layer pages by `createdAt` (keyset, newest + * first) and speaks Date; the wire speaks an opaque string. Base64 of the ISO + * timestamp round-trips to the millisecond, which is the precision the store + * keeps, so a cursor copied off any edge resumes exactly after that row. + */ +export const encodeGiftCardOrderCursor = (createdAt: Date): string => + Buffer.from(createdAt.toISOString(), "utf8").toString("base64") + +export const decodeGiftCardOrderCursor = (cursor: string): Date | null => { + const decoded = Buffer.from(cursor, "base64").toString("utf8") + const date = new Date(decoded) + return Number.isNaN(date.getTime()) ? null : date +} + +type GiftCardOrdersArgs = { first?: number | null; after?: string | null } + +const GiftCardOrdersQuery = GT.Field({ + type: GT.NonNull(GiftCardOrderConnection), + description: + "The calling account's gift card orders, newest first. Claims are never part of a " + + "listing — fetch one order with giftCardOrder(id:) for that.", + args: { + first: { + type: GT.Int, + description: "Page size, 1 to 100. Defaults to 20.", + }, + after: { + type: GT.String, + description: "The `endCursor` (or any edge cursor) from the previous page.", + }, + }, + resolve: async (_, args, { domainAccount }) => { + if (typeof args.first === "number" && args.first < 1) { + throw new InputValidationError({ + message: 'Argument "first" must be greater than 0', + }) + } + let after: Date | undefined + if (args.after) { + const decoded = decodeGiftCardOrderCursor(args.after) + if (decoded === null) { + throw new InputValidationError({ + message: 'Argument "after" must be a valid cursor', + }) + } + after = decoded + } + + // Deliberately NOT behind the gift card master gate: this is an owner-scoped + // read of orders the customer already paid for. Switching the rail off (or a + // provider being disabled for their country) must never hide codes they own. + // Ownership is enforced in the app layer, which returns NotFound for non-owners. + + const page = await listGiftCardOrdersForAccount({ + accountId: domainAccount.id, + first: args.first ?? undefined, + after, + }) + if (page instanceof Error) throw mapError(page) + + const edges = page.orders.map((order) => ({ + // No claim in a listing, ever: the mapper is called without one. + node: toGiftCardOrderSource(order), + cursor: encodeGiftCardOrderCursor(order.createdAt), + })) + return { + edges, + pageInfo: { + hasNextPage: page.hasNextPage, + hasPreviousPage: false, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: page.endCursor ? encodeGiftCardOrderCursor(page.endCursor) : null, + }, + } + }, +}) + +export default GiftCardOrdersQuery diff --git a/src/graphql/public/root/query/gift-card-quote.ts b/src/graphql/public/root/query/gift-card-quote.ts new file mode 100644 index 000000000..a6cd2a849 --- /dev/null +++ b/src/graphql/public/root/query/gift-card-quote.ts @@ -0,0 +1,67 @@ +import { quoteGiftCard } from "@app/gift-cards" +import { InputValidationError } from "@graphql/error" +import { mapError } from "@graphql/error-map" +import { GT } from "@graphql/index" +import { gateGiftCardsForAccount } from "@graphql/public/root/gift-card-gate" +import GiftCardQuote, { + toGiftCardQuoteSource, +} from "@graphql/public/types/object/gift-card-quote" +import CentAmount from "@graphql/public/types/scalar/cent-amount" + +type GiftCardQuoteArgs = { + productId: string + value: number | InputValidationError + quantity?: number | null +} + +const GiftCardQuoteQuery = GT.Field({ + type: GT.NonNull(GiftCardQuote), + description: + "What a purchase would cost right now, in sats, for a product at a face value. " + + "Refuses exactly what giftCardPurchase would refuse — a value off the card's " + + "denominations, a card not sold in the account's country, a sold-out card — so " + + "the client can surface that before asking the customer to confirm.", + args: { + productId: { + type: GT.NonNullID, + description: "A GiftCardProduct.id from giftCardCatalog.", + }, + value: { + type: GT.NonNull(CentAmount), + description: + "Face value of ONE card, minor units of the product's currency. Must be one of " + + "the product's denominations (FIXED) or within minValue..maxValue (VARIABLE).", + }, + quantity: { + type: GT.Int, + defaultValue: 1, + description: "How many cards, 1 to 10. Defaults to 1.", + }, + }, + resolve: async (_, args, { domainAccount }) => { + // CentAmount hands back an InputValidationError for a negative, oversized or + // non-integer value. It is an Apollo error already; thrown, not mapped. + if (args.value instanceof Error) throw args.value + const quantity = args.quantity ?? 1 + if (!Number.isInteger(quantity) || quantity < 1) { + throw new InputValidationError({ + message: 'Argument "quantity" must be a whole number greater than 0', + }) + } + + const gate = await gateGiftCardsForAccount({ account: domainAccount }) + if (!gate.ok) throw mapError(gate.error) + + const quote = await quoteGiftCard({ + accountId: domainAccount.id, + productId: args.productId, + valueMinor: args.value, + quantity, + }) + if (quote instanceof Error) throw mapError(quote) + + return toGiftCardQuoteSource(quote) + }, +}) + +export default GiftCardQuoteQuery diff --git a/src/graphql/public/root/query/globals.ts b/src/graphql/public/root/query/globals.ts index cb3a9ba7c..c8b7ea026 100644 --- a/src/graphql/public/root/query/globals.ts +++ b/src/graphql/public/root/query/globals.ts @@ -11,8 +11,10 @@ import { } from "@config" import { getSupportedCountries } from "@app/authentication/get-supported-countries" +import { GIFT_CARD_PROVIDER_IDS } from "@domain/gift-cards" import { getFygaroSettings } from "@services/fygaro/webhook-server/fygaro-settings" +import { isGiftCardProviderEnabled } from "@services/gift-cards/registry" import { GT } from "@graphql/index" import Globals from "@graphql/public/types/object/globals" @@ -47,6 +49,13 @@ const GlobalsQuery = GT.Field({ cashoutEnabled: Cashout.Enabled, bridgeEnabled: BridgeConfig.enabled, referralRewardEnabled: getReferralRewardConfig().enabled, + // The rail flag alone is not enough: with every provider off, every + // gift-card field answers GIFT_CARD_PROVIDER_UNAVAILABLE, so the entry + // point must stay hidden. `isGiftCardProviderEnabled` folds in the + // master `giftCards.enabled` switch. + giftCardsEnabled: GIFT_CARD_PROVIDER_IDS.some((id) => + isGiftCardProviderEnabled(id), + ), fygaroTopup: fygaroSettings ? { minimumAmount: fygaroSettings.minimumTopup, diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index a9c45a956..6a45a7688 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1090,6 +1090,338 @@ type FygaroTopupStatus { state: FygaroTopupState! } +"""A scannable representation of the claim, when the vendor supplies one.""" +type GiftCardBarcode { + """The characters to encode into the barcode.""" + chars: String! + + """ + The barcode symbology, e.g. 'CODE128', 'QR'. Vendor-supplied; render accordingly. + """ + type: String! +} + +""" +The redemption data for a FULFILLED order. Bearer data: anyone holding it can spend the card. Returned only to the owning account, only once fulfilled, and never in a list — fetch it with giftCardOrder(id:). +""" +type GiftCardClaim { + """ + Scannable form of the claim for in-store use, when the vendor supplies one. + """ + barcode: GiftCardBarcode + + """ + A vendor page where the card can be viewed or redeemed, when one exists. + """ + claimLink: String + + """ + The code(s) to redeem. Usually one; some cards carry a number and a PIN. + """ + codes: [GiftCardClaimCode!]! +} + +"""One redeemable code on a fulfilled card.""" +type GiftCardClaimCode { + """ + What the vendor calls this code, e.g. 'Card number', 'PIN'. Null when the card has a single unlabelled code. + """ + label: String + + """ + The code itself. Bearer data: show it, let the customer copy it, never log it. + """ + value: String! +} + +enum GiftCardDenominationType { + """ + The card is sold only at the amounts listed in `denominations`; `minValue` and `maxValue` are null. A purchase at any other value is refused. + """ + FIXED + + """ + Any whole amount from `minValue` to `maxValue` inclusive; `denominations` is empty. + """ + VARIABLE +} + +""" +A gift card purchase, from creation through fulfilment. Poll giftCardOrder(id:) while `status` is transient; the claim appears once it is FULFILLED. +""" +type GiftCardOrder { + """ + The redemption data. Present ONLY when status is FULFILLED and ONLY via giftCardOrder(id:) or the purchase payload — never in giftCardOrders. + """ + claim: GiftCardClaim + + """When the order was placed.""" + createdAt: Timestamp! + + """ISO 4217 currency of `value`.""" + currency: String! + + """ + Short machine-oriented reason for a FAILED / PAYMENT_FAILED / REFUND_REQUIRED order, for support and diagnostics. Not customer wording; choose the copy from `status`. + """ + failureReason: String + + """When the card was issued. Null until FULFILLED.""" + fulfilledAt: Timestamp + + """Pass to giftCardOrder(id:) to poll. Stable for the life of the order.""" + id: ID! + + """ + What actually left the wallet, in sats. Null until the payment settles (and forever null for an order that never paid). + """ + paidSats: SatAmount + + """The card that was bought, as it was at purchase time.""" + product: GiftCardProductSnapshot! + + """How many cards the order is for.""" + quantity: Int! + + """Where the order is in its lifecycle. See each value for what to show.""" + status: GiftCardOrderStatus! + + """Face value of ONE card, minor units of `currency`.""" + value: CentAmount! +} + +"""A connection to a list of items.""" +type GiftCardOrderConnection { + """A list of edges.""" + edges: [GiftCardOrderEdge!] + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type GiftCardOrderEdge { + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: GiftCardOrder! +} + +enum GiftCardOrderStatus { + """ + The order exists and the vendor has not yet been asked for an invoice. Transient; nothing has left the wallet. Keep polling. + """ + CREATED + + """ + Terminal. The invoice was not paid before it expired; nothing left the wallet. + """ + EXPIRED + + """ + Terminal. Refused BEFORE any payment left the wallet: the vendor rejected the order, the price moved past tolerance, or the invoice could not be read. `failureReason` says which. Nothing to refund; safe to try again. + """ + FAILED + + """ + Terminal. The card is issued; `claim` carries the redemption data and `fulfilledAt` says when. + """ + FULFILLED + + """ + The vendor's invoice is in hand and is about to be paid from the wallet. Transient. Keep polling. + """ + INVOICE_ISSUED + + """ + The wallet has paid the vendor and the card is being issued. Transient. Keep polling. + """ + PAID + + """ + Terminal. The Lightning payment failed and nothing left the wallet. Safe to try again with a new idempotencyKey. + """ + PAYMENT_FAILED + + """ + The Lightning payment is in flight and has neither settled nor failed. Transient, and can take minutes on a slow route. Keep polling; do not purchase again. + """ + PAYMENT_PENDING + + """ + Terminal. The wallet paid but the vendor did not deliver a card. Flash has been alerted and will make it right; there is nothing for the customer to do and they must NOT be told to buy again. + """ + REFUND_REQUIRED +} + +""" +One purchasable gift card in the catalog for a country. Money fields are minor units of `currency` (cents for USD); they are face values, not what the customer pays in sats — ask giftCardQuote for that. +""" +type GiftCardProduct { + """The merchant whose card this is. Catalog order is by brand, then name.""" + brand: String! + + """ + Vendor-supplied grouping labels, e.g. 'Shopping'. Any one of them can be passed back as giftCardCatalog(category:) to filter. + """ + categories: [String!]! + + """The country the card is redeemable in.""" + countryCode: CountryCode! + + """ISO 4217 currency of every money field on this product.""" + currency: String! + + """Whether the card is sold at fixed amounts or any amount in a range.""" + denominationType: GiftCardDenominationType! + + """ + The purchasable face values for a FIXED card, minor units, ascending. Empty for a VARIABLE card. + """ + denominations: [CentAmount!]! + + """ + Pass to giftCardQuote and giftCardPurchase. Opaque; stable across catalog refreshes for the same card. + """ + id: ID! + + """ + True for a network-branded prepaid card (Visa/Mastercard-style) spendable anywhere, as opposed to a single-merchant card. Only listed when the operator allows them. + """ + isOpenLoop: Boolean! + + """Brand artwork, when the vendor supplies one.""" + logoUrl: String + + """Largest face value for a VARIABLE card, minor units. Null for FIXED.""" + maxValue: CentAmount + + """Smallest face value for a VARIABLE card, minor units. Null for FIXED.""" + minValue: CentAmount + + """Display name of the card, e.g. 'Amazon.com Gift Card'.""" + name: String! + + """ + Vendor reward on face value, in basis points (100 = 1%). Informational; the sats actually rebated for a given purchase are on the quote as rewardSats. + """ + rewardBps: Int! + + """The card's terms and conditions, when the vendor supplies a link.""" + termsUrl: String +} + +"""A connection to a list of items.""" +type GiftCardProductConnection { + """A list of edges.""" + edges: [GiftCardProductEdge!] + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type GiftCardProductEdge { + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: GiftCardProduct! +} + +""" +The product as it was when the order was placed. Frozen on the order so a catalog refresh (rename, delisting) never changes what a past order says it bought. +""" +type GiftCardProductSnapshot { + """Merchant at purchase time.""" + brand: String! + + """Country the card is redeemable in.""" + countryCode: CountryCode! + + """ISO 4217 currency of the order's `value`.""" + currency: String! + + """True for a network-branded prepaid card spendable anywhere.""" + isOpenLoop: Boolean! + + """Brand artwork at purchase time, if any.""" + logoUrl: String + + """Display name at purchase time.""" + name: String! +} + +input GiftCardPurchaseInput { + """ + Client-generated, 8 to 64 characters, unique per purchase attempt (a UUID is ideal). Retrying with the SAME key and the same product/value/quantity returns the existing order instead of buying again; the same key with different parameters is refused. Generate a new key for a genuinely new purchase. + """ + idempotencyKey: String! + + """A GiftCardProduct.id from giftCardCatalog.""" + productId: ID! + + """How many cards, 1 to 10. Defaults to 1.""" + quantity: Int = 1 + + """ + Face value of ONE card, minor units of the product's currency. Must be one of the product's denominations (FIXED) or within minValue..maxValue (VARIABLE). + """ + value: CentAmount! + + """ + The wallet that pays. Must belong to the calling account; the sats cost from giftCardQuote is what leaves it. + """ + walletId: WalletId! +} + +type GiftCardPurchasePayload { + errors: [Error!]! + + """ + The order, in whatever state the purchase reached before returning — often already FULFILLED with the claim attached, sometimes still PAID or PAYMENT_PENDING. Poll giftCardOrder(id:) while the status is transient. Absent when the purchase was refused before an order existed; may be present ALONGSIDE an error when the card was issued but its claim could not be read. + """ + order: GiftCardOrder +} + +""" +What a gift card purchase would cost right now. Not a reservation: nothing is held and no limit is consumed. giftCardPurchase re-prices at order time and refuses to pay if the vendor's invoice drifted past tolerance from this, so a stale quote costs a retry, never money. +""" +type GiftCardQuote { + """ISO 4217 currency of `value` and `fiatCost`.""" + currency: String! + + """ + After this the vendor no longer honours the price. Ask again before purchasing; the purchase itself re-quotes regardless. + """ + expiresAt: Timestamp! + + """ + What the vendor charges for the whole order, minor units of `currency`. Can differ from value x quantity when the vendor prices below face. + """ + fiatCost: CentAmount! + + """The product this quote is for, echoed back.""" + productId: ID! + + """How many cards the quote covers, echoed back.""" + quantity: Int! + + """ + Sats the vendor rebates on this order once fulfilled. Zero when the card carries no reward. + """ + rewardSats: SatAmount! + + """ + What leaves the wallet for the whole order, in sats, at the vendor's current price. This is the number to show as the price. + """ + satsCost: SatAmount! + + """Face value of ONE card, minor units of `currency`, echoed back.""" + value: CentAmount! +} + """ Provides global settings for the application which might have an impact for the user. """ @@ -1115,6 +1447,14 @@ type Globals { """ fygaroTopup: FygaroTopupInfo + """ + Whether gift card entry points (the storefront tab, "buy a + gift card" actions) should be shown to the user. True only when the + instance-wide giftCards feature flag is on AND at least one gift card + provider is enabled; when false every giftCard* field refuses. + """ + giftCardsEnabled: Boolean! + """ The domain name for lightning addresses accepted by this Galoy instance """ @@ -1627,6 +1967,11 @@ type Mutation { deviceNotificationTokenCreate(input: DeviceNotificationTokenCreateInput!): SuccessPayload! feedbackSubmit(input: FeedbackSubmitInput!): SuccessPayload! fygaroCheckoutCreate(input: FygaroCheckoutCreateInput!): FygaroCheckoutCreatePayload! + + """ + Buy a gift card: pays the vendor from `walletId` over Lightning and returns the order. Re-prices at order time and refuses to pay if the price drifted past tolerance from the quote — no money moves on a refusal. Money moves at most once per idempotencyKey, so retry a timed-out call with the SAME key. + """ + giftCardPurchase(input: GiftCardPurchaseInput!): GiftCardPurchasePayload! idDocumentUploadUrlGenerate(input: IdDocumentUploadUrlGenerateInput!): IdDocumentUploadUrlPayload! """ @@ -2040,6 +2385,67 @@ type Query { The outcome of a card top-up, by the checkout id returned from fygaroCheckoutCreate. Null when the checkout is unknown, expired, or not this account's — never as a way of saying 'not yet': a checkout we simply cannot read right now comes back as UNCONFIRMED so a transient fault is not reported to the customer as a payment that never existed. """ fygaroTopupStatus(checkoutId: String!): FygaroTopupStatus + + """ + The gift cards on sale for a country, in stock only, ordered by brand then name. Served from a cached catalog: a card can be listed and then refused by giftCardQuote if it sold out in between. Paginate forwards with `first`/`after`. + """ + giftCardCatalog( + """ + The `endCursor` (or any edge cursor) from the previous page. An unknown cursor restarts from the beginning rather than guessing; the client may see a repeat, never a gap. + """ + after: String + + """Keep only cards carrying this category label (case-insensitive).""" + category: String + + """ + Which country's catalog. Defaults to the calling account's country, which is also the country the purchase will be routed by — browse another country's catalog and the purchase of a card from it is refused as not available. + """ + countryCode: CountryCode + + """Page size, 1 to 200. Defaults to 50.""" + first: Int + + """ + Keep only cards whose name or brand contains this text (case-insensitive). + """ + search: String + ): GiftCardProductConnection! + + """ + One of the calling account's gift card orders, by id — the endpoint to poll after giftCardPurchase, and the ONLY place the claim is returned. Null when the id is unknown or belongs to another account; the two are deliberately indistinguishable. + """ + giftCardOrder( + """A GiftCardOrder.id from giftCardPurchase or giftCardOrders.""" + id: ID! + ): GiftCardOrder + + """ + The calling account's gift card orders, newest first. Claims are never part of a listing — fetch one order with giftCardOrder(id:) for that. + """ + giftCardOrders( + """The `endCursor` (or any edge cursor) from the previous page.""" + after: String + + """Page size, 1 to 100. Defaults to 20.""" + first: Int + ): GiftCardOrderConnection! + + """ + What a purchase would cost right now, in sats, for a product at a face value. Refuses exactly what giftCardPurchase would refuse — a value off the card's denominations, a card not sold in the account's country, a sold-out card — so the client can surface that before asking the customer to confirm. + """ + giftCardQuote( + """A GiftCardProduct.id from giftCardCatalog.""" + productId: ID! + + """How many cards, 1 to 10. Defaults to 1.""" + quantity: Int = 1 + + """ + Face value of ONE card, minor units of the product's currency. Must be one of the product's denominations (FIXED) or within minValue..maxValue (VARIABLE). + """ + value: CentAmount! + ): GiftCardQuote! globals: Globals invitePreview(token: String!): InvitePreview isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload diff --git a/src/graphql/public/types/input/gift-card-purchase-input.ts b/src/graphql/public/types/input/gift-card-purchase-input.ts new file mode 100644 index 000000000..d9ec86a77 --- /dev/null +++ b/src/graphql/public/types/input/gift-card-purchase-input.ts @@ -0,0 +1,40 @@ +import { GT } from "@graphql/index" +import CentAmount from "@graphql/public/types/scalar/cent-amount" +import WalletId from "@graphql/shared/types/scalar/wallet-id" + +const GiftCardPurchaseInput = GT.Input({ + name: "GiftCardPurchaseInput", + fields: () => ({ + productId: { + type: GT.NonNullID, + description: "A GiftCardProduct.id from giftCardCatalog.", + }, + value: { + type: GT.NonNull(CentAmount), + description: + "Face value of ONE card, minor units of the product's currency. Must be one of " + + "the product's denominations (FIXED) or within minValue..maxValue (VARIABLE).", + }, + quantity: { + type: GT.Int, + defaultValue: 1, + description: "How many cards, 1 to 10. Defaults to 1.", + }, + walletId: { + type: GT.NonNull(WalletId), + description: + "The wallet that pays. Must belong to the calling account; the sats cost from " + + "giftCardQuote is what leaves it.", + }, + idempotencyKey: { + type: GT.NonNull(GT.String), + description: + "Client-generated, 8 to 64 characters, unique per purchase attempt (a UUID is " + + "ideal). Retrying with the SAME key and the same product/value/quantity returns " + + "the existing order instead of buying again; the same key with different " + + "parameters is refused. Generate a new key for a genuinely new purchase.", + }, + }), +}) + +export default GiftCardPurchaseInput diff --git a/src/graphql/public/types/object/gift-card-order.ts b/src/graphql/public/types/object/gift-card-order.ts new file mode 100644 index 000000000..a45a7ff72 --- /dev/null +++ b/src/graphql/public/types/object/gift-card-order.ts @@ -0,0 +1,277 @@ +import { GiftCardOrderStatus as OrderStatus } from "@domain/gift-cards" +import { connectionDefinitions } from "@graphql/connections" +import { GT } from "@graphql/index" +import CentAmount from "@graphql/public/types/scalar/cent-amount" +import CountryCode from "@graphql/public/types/scalar/country-code" +import SatAmount from "@graphql/shared/types/scalar/sat-amount" +import Timestamp from "@graphql/shared/types/scalar/timestamp" + +/** + * The INTERNAL value behind each enum member, typed as the domain's + * `GiftCardOrderStatus` (pattern: `fygaro-topup-status.ts`). The domain's + * lifecycle constants are already the wire spelling, so a member added to the + * domain without a row here, or a row whose `value:` drifts, is a compile + * error instead of `Enum "GiftCardOrderStatus" cannot represent value` at the + * customer polling their order. + */ +const STATUS_ENUM_VALUES: Record< + string, + { value: GiftCardOrderStatus; description: string } +> = { + CREATED: { + value: OrderStatus.Created, + description: + "The order exists and the vendor has not yet been asked for an invoice. " + + "Transient; nothing has left the wallet. Keep polling.", + }, + INVOICE_ISSUED: { + value: OrderStatus.InvoiceIssued, + description: + "The vendor's invoice is in hand and is about to be paid from the wallet. " + + "Transient. Keep polling.", + }, + PAYMENT_PENDING: { + value: OrderStatus.PaymentPending, + description: + "The Lightning payment is in flight and has neither settled nor failed. " + + "Transient, and can take minutes on a slow route. Keep polling; do not " + + "purchase again.", + }, + PAID: { + value: OrderStatus.Paid, + description: + "The wallet has paid the vendor and the card is being issued. Transient. Keep polling.", + }, + FULFILLED: { + value: OrderStatus.Fulfilled, + description: + "Terminal. The card is issued; `claim` carries the redemption data and " + + "`fulfilledAt` says when.", + }, + FAILED: { + value: OrderStatus.Failed, + description: + "Terminal. Refused BEFORE any payment left the wallet: the vendor rejected the " + + "order, the price moved past tolerance, or the invoice could not be read. " + + "`failureReason` says which. Nothing to refund; safe to try again.", + }, + PAYMENT_FAILED: { + value: OrderStatus.PaymentFailed, + description: + "Terminal. The Lightning payment failed and nothing left the wallet. Safe to " + + "try again with a new idempotencyKey.", + }, + EXPIRED: { + value: OrderStatus.Expired, + description: + "Terminal. The invoice was not paid before it expired; nothing left the wallet.", + }, + REFUND_REQUIRED: { + value: OrderStatus.RefundRequired, + description: + "Terminal. The wallet paid but the vendor did not deliver a card. Flash has " + + "been alerted and will make it right; there is nothing for the customer to do " + + "and they must NOT be told to buy again.", + }, +} + +export const GiftCardOrderStatusEnum = GT.Enum({ + name: "GiftCardOrderStatus", + values: STATUS_ENUM_VALUES, +}) + +const GiftCardProductSnapshot = GT.Object({ + name: "GiftCardProductSnapshot", + description: + "The product as it was when the order was placed. Frozen on the order so a " + + "catalog refresh (rename, delisting) never changes what a past order says it bought.", + fields: () => ({ + name: { type: GT.NonNull(GT.String), description: "Display name at purchase time." }, + brand: { type: GT.NonNull(GT.String), description: "Merchant at purchase time." }, + countryCode: { + type: GT.NonNull(CountryCode), + description: "Country the card is redeemable in.", + }, + currency: { + type: GT.NonNull(GT.String), + description: "ISO 4217 currency of the order's `value`.", + }, + isOpenLoop: { + type: GT.NonNull(GT.Boolean), + description: "True for a network-branded prepaid card spendable anywhere.", + }, + logoUrl: { type: GT.String, description: "Brand artwork at purchase time, if any." }, + }), +}) + +const GiftCardClaimCode = GT.Object({ + name: "GiftCardClaimCode", + description: "One redeemable code on a fulfilled card.", + fields: () => ({ + label: { + type: GT.String, + description: + "What the vendor calls this code, e.g. 'Card number', 'PIN'. Null when the " + + "card has a single unlabelled code.", + }, + value: { + type: GT.NonNull(GT.String), + description: + "The code itself. Bearer data: show it, let the customer copy it, never log it.", + }, + }), +}) + +const GiftCardBarcode = GT.Object({ + name: "GiftCardBarcode", + description: "A scannable representation of the claim, when the vendor supplies one.", + fields: () => ({ + chars: { + type: GT.NonNull(GT.String), + description: "The characters to encode into the barcode.", + }, + type: { + type: GT.NonNull(GT.String), + description: + "The barcode symbology, e.g. 'CODE128', 'QR'. Vendor-supplied; render accordingly.", + }, + }), +}) + +const GiftCardClaim = GT.Object({ + name: "GiftCardClaim", + description: + "The redemption data for a FULFILLED order. Bearer data: anyone holding it can " + + "spend the card. Returned only to the owning account, only once fulfilled, and " + + "never in a list — fetch it with giftCardOrder(id:).", + fields: () => ({ + codes: { + type: GT.NonNullList(GiftCardClaimCode), + description: + "The code(s) to redeem. Usually one; some cards carry a number and a PIN.", + }, + claimLink: { + type: GT.String, + description: + "A vendor page where the card can be viewed or redeemed, when one exists.", + }, + barcode: { + type: GiftCardBarcode, + description: + "Scannable form of the claim for in-store use, when the vendor supplies one.", + }, + }), +}) + +/** + * What a resolver hands the `GiftCardOrder` type. + * + * An explicit allow-list rather than the domain order itself: the domain row + * carries `claimCiphertext` and `claimKeyId`, and the safest way to guarantee + * neither can ever reach the wire — through a future field, a debug resolver, + * or a misnamed alias — is for the object GraphQL resolves against to not have + * them at all. `toGiftCardOrderSource` is the ONLY way to build one. + */ +export type GiftCardOrderSource = { + id: GiftCardOrderId + status: GiftCardOrderStatus + product: GiftCardProductSnapshot + value: number + currency: string + quantity: number + paidSats: Satoshis | null + claim: GiftCardClaim | null + createdAt: Date + fulfilledAt: Date | null + failureReason: string | null +} + +/** + * `claim` is attached only when the order is FULFILLED, whatever the caller + * passes. The app layer (`getGiftCardOrderForAccount`) already decrypts only + * for the owner and only once fulfilled; this is the belt to that braces. + */ +export const toGiftCardOrderSource = ( + order: GiftCardOrder, + claim: GiftCardClaim | null = null, +): GiftCardOrderSource => ({ + id: order.id, + status: order.status, + product: order.productSnapshot, + value: order.valueMinor, + currency: order.currency, + quantity: order.quantity, + paidSats: order.paidSats, + claim: order.status === OrderStatus.Fulfilled ? claim : null, + createdAt: order.createdAt, + fulfilledAt: order.fulfilledAt, + failureReason: order.failureReason, +}) + +const GiftCardOrder = GT.Object({ + name: "GiftCardOrder", + description: + "A gift card purchase, from creation through fulfilment. Poll giftCardOrder(id:) " + + "while `status` is transient; the claim appears once it is FULFILLED.", + fields: () => ({ + id: { + type: GT.NonNullID, + description: + "Pass to giftCardOrder(id:) to poll. Stable for the life of the order.", + }, + status: { + type: GT.NonNull(GiftCardOrderStatusEnum), + description: + "Where the order is in its lifecycle. See each value for what to show.", + }, + product: { + type: GT.NonNull(GiftCardProductSnapshot), + description: "The card that was bought, as it was at purchase time.", + }, + value: { + type: GT.NonNull(CentAmount), + description: "Face value of ONE card, minor units of `currency`.", + }, + currency: { + type: GT.NonNull(GT.String), + description: "ISO 4217 currency of `value`.", + }, + quantity: { + type: GT.NonNull(GT.Int), + description: "How many cards the order is for.", + }, + paidSats: { + type: SatAmount, + description: + "What actually left the wallet, in sats. Null until the payment settles " + + "(and forever null for an order that never paid).", + }, + claim: { + type: GiftCardClaim, + description: + "The redemption data. Present ONLY when status is FULFILLED and ONLY via " + + "giftCardOrder(id:) or the purchase payload — never in giftCardOrders.", + }, + createdAt: { + type: GT.NonNull(Timestamp), + description: "When the order was placed.", + }, + fulfilledAt: { + type: Timestamp, + description: "When the card was issued. Null until FULFILLED.", + }, + failureReason: { + type: GT.String, + description: + "Short machine-oriented reason for a FAILED / PAYMENT_FAILED / REFUND_REQUIRED " + + "order, for support and diagnostics. Not customer wording; choose the copy from " + + "`status`.", + }, + }), +}) + +export const { connectionType: GiftCardOrderConnection } = connectionDefinitions({ + nodeType: GiftCardOrder, +}) + +export default GiftCardOrder diff --git a/src/graphql/public/types/object/gift-card-product.ts b/src/graphql/public/types/object/gift-card-product.ts new file mode 100644 index 000000000..e7854dab0 --- /dev/null +++ b/src/graphql/public/types/object/gift-card-product.ts @@ -0,0 +1,122 @@ +import { connectionDefinitions } from "@graphql/connections" +import { GT } from "@graphql/index" +import CentAmount from "@graphql/public/types/scalar/cent-amount" +import CountryCode from "@graphql/public/types/scalar/country-code" + +/** + * The INTERNAL value behind each enum member — what the catalog row actually + * carries, so GraphQL can serialize the member's name onto the wire. + * + * Typed as the domain's `GiftCardDenominationType` for the reason the Fygaro + * enums type theirs (`fygaro-topup-status.ts`): as bare literals the two wire + * strings would live in the adapter's normaliser AND here with nothing linking + * them, and a rename on either side would compile, pass every test, then throw + * `Enum "GiftCardDenominationType" cannot represent value` at every customer + * opening the catalog. + */ +const DENOMINATION_TYPE_VALUES: Record< + string, + { value: GiftCardDenominationType; description: string } +> = { + FIXED: { + value: "fixed", + description: + "The card is sold only at the amounts listed in `denominations`; `minValue` and " + + "`maxValue` are null. A purchase at any other value is refused.", + }, + VARIABLE: { + value: "variable", + description: + "Any whole amount from `minValue` to `maxValue` inclusive; `denominations` is empty.", + }, +} + +export const GiftCardDenominationTypeEnum = GT.Enum({ + name: "GiftCardDenominationType", + values: DENOMINATION_TYPE_VALUES, +}) + +const GiftCardProduct = GT.Object({ + name: "GiftCardProduct", + description: + "One purchasable gift card in the catalog for a country. Money fields are minor " + + "units of `currency` (cents for USD); they are face values, not what the customer " + + "pays in sats — ask giftCardQuote for that.", + fields: () => ({ + id: { + type: GT.NonNullID, + description: + "Pass to giftCardQuote and giftCardPurchase. Opaque; stable across catalog " + + "refreshes for the same card.", + }, + name: { + type: GT.NonNull(GT.String), + description: "Display name of the card, e.g. 'Amazon.com Gift Card'.", + }, + brand: { + type: GT.NonNull(GT.String), + description: + "The merchant whose card this is. Catalog order is by brand, then name.", + }, + countryCode: { + type: GT.NonNull(CountryCode), + description: "The country the card is redeemable in.", + }, + currency: { + type: GT.NonNull(GT.String), + description: "ISO 4217 currency of every money field on this product.", + }, + denominationType: { + type: GT.NonNull(GiftCardDenominationTypeEnum), + description: "Whether the card is sold at fixed amounts or any amount in a range.", + }, + denominations: { + type: GT.NonNullList(CentAmount), + description: + "The purchasable face values for a FIXED card, minor units, ascending. Empty " + + "for a VARIABLE card.", + }, + minValue: { + type: CentAmount, + description: + "Smallest face value for a VARIABLE card, minor units. Null for FIXED.", + }, + maxValue: { + type: CentAmount, + description: "Largest face value for a VARIABLE card, minor units. Null for FIXED.", + }, + isOpenLoop: { + type: GT.NonNull(GT.Boolean), + description: + "True for a network-branded prepaid card (Visa/Mastercard-style) spendable " + + "anywhere, as opposed to a single-merchant card. Only listed when the operator " + + "allows them.", + }, + categories: { + type: GT.NonNullList(GT.String), + description: + "Vendor-supplied grouping labels, e.g. 'Shopping'. Any one of them can be " + + "passed back as giftCardCatalog(category:) to filter.", + }, + logoUrl: { + type: GT.String, + description: "Brand artwork, when the vendor supplies one.", + }, + termsUrl: { + type: GT.String, + description: "The card's terms and conditions, when the vendor supplies a link.", + }, + rewardBps: { + type: GT.NonNull(GT.Int), + description: + "Vendor reward on face value, in basis points (100 = 1%). Informational; the " + + "sats actually rebated for a given purchase are on the quote as rewardSats.", + }, + }), +}) + +export const { connectionType: GiftCardProductConnection } = connectionDefinitions({ + nodeType: GiftCardProduct, +}) + +export default GiftCardProduct diff --git a/src/graphql/public/types/object/gift-card-quote.ts b/src/graphql/public/types/object/gift-card-quote.ts new file mode 100644 index 000000000..0555b6865 --- /dev/null +++ b/src/graphql/public/types/object/gift-card-quote.ts @@ -0,0 +1,86 @@ +import { GT } from "@graphql/index" +import CentAmount from "@graphql/public/types/scalar/cent-amount" +import SatAmount from "@graphql/shared/types/scalar/sat-amount" +import Timestamp from "@graphql/shared/types/scalar/timestamp" + +/** + * What the resolver hands this type. The domain `GiftCardQuote` names its money + * fields `valueMinor` / `fiatCostMinor`; the wire drops the suffix because the + * `CentAmount` scalar already says minor units. The rename happens ONCE, in + * `toGiftCardQuoteSource`, so a field added to the domain type cannot reach the + * wire under the wrong name by accident. + */ +export type GiftCardQuoteSource = { + productId: string + value: number + currency: string + quantity: number + fiatCost: number + satsCost: Satoshis + rewardSats: Satoshis + expiresAt: Date +} + +export const toGiftCardQuoteSource = (quote: GiftCardQuote): GiftCardQuoteSource => ({ + productId: quote.productId, + value: quote.valueMinor, + currency: quote.currency, + quantity: quote.quantity, + fiatCost: quote.fiatCostMinor, + satsCost: quote.satsCost, + rewardSats: quote.rewardSats, + expiresAt: quote.expiresAt, +}) + +const GiftCardQuote = GT.Object({ + name: "GiftCardQuote", + description: + "What a gift card purchase would cost right now. Not a reservation: nothing is " + + "held and no limit is consumed. giftCardPurchase re-prices at order time and " + + "refuses to pay if the vendor's invoice drifted past tolerance from this, so a " + + "stale quote costs a retry, never money.", + fields: () => ({ + productId: { + type: GT.NonNullID, + description: "The product this quote is for, echoed back.", + }, + value: { + type: GT.NonNull(CentAmount), + description: "Face value of ONE card, minor units of `currency`, echoed back.", + }, + currency: { + type: GT.NonNull(GT.String), + description: "ISO 4217 currency of `value` and `fiatCost`.", + }, + quantity: { + type: GT.NonNull(GT.Int), + description: "How many cards the quote covers, echoed back.", + }, + fiatCost: { + type: GT.NonNull(CentAmount), + description: + "What the vendor charges for the whole order, minor units of `currency`. Can " + + "differ from value x quantity when the vendor prices below face.", + }, + satsCost: { + type: GT.NonNull(SatAmount), + description: + "What leaves the wallet for the whole order, in sats, at the vendor's current " + + "price. This is the number to show as the price.", + }, + rewardSats: { + type: GT.NonNull(SatAmount), + description: + "Sats the vendor rebates on this order once fulfilled. Zero when the card " + + "carries no reward.", + }, + expiresAt: { + type: GT.NonNull(Timestamp), + description: + "After this the vendor no longer honours the price. Ask again before " + + "purchasing; the purchase itself re-quotes regardless.", + }, + }), +}) + +export default GiftCardQuote diff --git a/src/graphql/public/types/object/globals.ts b/src/graphql/public/types/object/globals.ts index 3c2de64fe..381ff6087 100644 --- a/src/graphql/public/types/object/globals.ts +++ b/src/graphql/public/types/object/globals.ts @@ -60,6 +60,13 @@ const Globals = GT.Object({ instance-wide referralReward feature flag; when false the referral payout is disabled, so reward-promising UI must stay hidden.`, }, + giftCardsEnabled: { + type: GT.NonNull(GT.Boolean), + description: dedent`Whether gift card entry points (the storefront tab, "buy a + gift card" actions) should be shown to the user. True only when the + instance-wide giftCards feature flag is on AND at least one gift card + provider is enabled; when false every giftCard* field refuses.`, + }, fygaroTopup: { type: FygaroTopupInfo, description: dedent`Fee parameters for Fygaro card top-ups so the app can diff --git a/src/graphql/public/types/payload/gift-card-purchase.ts b/src/graphql/public/types/payload/gift-card-purchase.ts new file mode 100644 index 000000000..c3621f707 --- /dev/null +++ b/src/graphql/public/types/payload/gift-card-purchase.ts @@ -0,0 +1,21 @@ +import { GT } from "@graphql/index" +import GiftCardOrder from "@graphql/public/types/object/gift-card-order" +import IError from "@graphql/shared/types/abstract/error" + +const GiftCardPurchasePayload = GT.Object({ + name: "GiftCardPurchasePayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + order: { + type: GiftCardOrder, + description: + "The order, in whatever state the purchase reached before returning — often " + + "already FULFILLED with the claim attached, sometimes still PAID or " + + "PAYMENT_PENDING. Poll giftCardOrder(id:) while the status is transient. " + + "Absent when the purchase was refused before an order existed; may be present " + + "ALONGSIDE an error when the card was issued but its claim could not be read.", + }, + }), +}) + +export default GiftCardPurchasePayload diff --git a/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts new file mode 100644 index 000000000..d3c7f43b4 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts @@ -0,0 +1,414 @@ +// jest.mock calls are hoisted before imports + +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockPurchaseGiftCard = jest.fn() +const mockGetGiftCardOrderForAccount = jest.fn() +const mockConsumeLimiter = jest.fn() + +jest.mock("@app/gift-cards", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), + purchaseGiftCard: (...a: unknown[]) => mockPurchaseGiftCard(...a), + getGiftCardOrderForAccount: (...a: unknown[]) => mockGetGiftCardOrderForAccount(...a), +})) + +jest.mock("@services/rate-limit", () => ({ + consumeLimiter: (...a: unknown[]) => mockConsumeLimiter(...a), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { InvalidIdempotencyKeyError } from "@domain/errors" +import { + GiftCardClaimCryptoError, + GiftCardIdempotencyKeyReuseError, + GiftCardInvalidValueError, + GiftCardLevelNotEligibleError, + GiftCardLimitExceededError, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, + GiftCardProviderUnavailableError, + GiftCardQuoteMismatchError, + GiftCardsDisabledError, + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, + UnknownGiftCardError, +} from "@domain/gift-cards" +import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" +import { InputValidationError } from "@graphql/error" +import GiftCardPurchaseMutation, { + GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH, + GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH, +} from "@graphql/public/root/mutation/gift-card-purchase" +import type { GiftCardOrderSource } from "@graphql/public/types/object/gift-card-order" + +import { makeOrder, WALLET_ID } from "test/flash/unit/app/gift-cards/fixtures" + +const ACCOUNT_ID = "account-001" as AccountId +const PRODUCT_ID = "bitcoinCompany:amazon-us" +const IDEMPOTENCY_KEY = "2f1c9c1e-4b7d-4a10-9f33-6c8e2d4b7a51" + +const ctx = { + domainAccount: { id: ACCOUNT_ID, kratosUserId: "kratos-1", level: 1 }, +} as unknown as GraphQLPublicContextAuth + +const CLAIM: GiftCardClaim = { + codes: [{ label: null, value: "ABCD-EFGH-1234" }], + claimLink: null, + barcode: null, +} + +type PurchaseResult = { + errors: Array<{ code?: string; message?: string }> + order?: GiftCardOrderSource +} + +const input = (overrides: Record = {}) => ({ + productId: PRODUCT_ID, + value: 2500, + quantity: 1, + walletId: WALLET_ID, + idempotencyKey: IDEMPOTENCY_KEY, + ...overrides, +}) + +const resolve = async ( + inputOverrides: Record = {}, +): Promise => { + const mutation = GiftCardPurchaseMutation as unknown as { + resolve: ( + source: null, + args: { input: Record }, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise + } + return mutation.resolve(null, { input: input(inputOverrides) }, ctx, {} as never) +} + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCountry.mockResolvedValue("JM") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockPurchaseGiftCard.mockResolvedValue(makeOrder({ status: "PAID" })) + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ status: "FULFILLED" }), + claim: CLAIM, + }) + mockConsumeLimiter.mockResolvedValue(true) +}) + +describe("giftCardPurchase resolver", () => { + describe("argument validation (before the gate, before any purchase)", () => { + it("returns the CentAmount scalar's error for a bad value", async () => { + const result = await resolve({ + value: new InputValidationError({ message: "Invalid value for CentAmount" }), + }) + + expect(result.errors).toEqual([{ message: "Invalid value for CentAmount" }]) + expect(result.order).toBeUndefined() + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + }) + + it("returns the WalletId scalar's error for a bad wallet id", async () => { + const result = await resolve({ + walletId: new InputValidationError({ message: "Invalid value for WalletId" }), + }) + + expect(result.errors).toEqual([{ message: "Invalid value for WalletId" }]) + expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + }) + + describe("idempotencyKey", () => { + const isRefusal = (result: PurchaseResult) => + result.errors.length === 1 && + (result.errors[0].message ?? "").includes("idempotencyKey") && + result.order === undefined + + it("refuses a key shorter than the minimum", async () => { + const result = await resolve({ + idempotencyKey: "x".repeat(GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH - 1), + }) + + expect(isRefusal(result)).toBe(true) + expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + }) + + it("refuses a key longer than the maximum", async () => { + const result = await resolve({ + idempotencyKey: "x".repeat(GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH + 1), + }) + + expect(isRefusal(result)).toBe(true) + expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + }) + + it("refuses a key containing whitespace", async () => { + // The app layer trims, so " " would reach it as "" and come back + // as a confusing UUID-format error. Refuse it plainly here. + expect(isRefusal(await resolve({ idempotencyKey: " " }))).toBe(true) + expect(isRefusal(await resolve({ idempotencyKey: "abcd efgh" }))).toBe(true) + expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + }) + + it("accepts both boundary lengths", async () => { + await resolve({ + idempotencyKey: "x".repeat(GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH), + }) + await resolve({ + idempotencyKey: "x".repeat(GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH), + }) + + expect(mockPurchaseGiftCard).toHaveBeenCalledTimes(2) + }) + + it("pins the documented bounds", () => { + expect(GIFT_CARD_IDEMPOTENCY_KEY_MIN_LENGTH).toBe(8) + expect(GIFT_CARD_IDEMPOTENCY_KEY_MAX_LENGTH).toBe(64) + }) + }) + }) + + describe("master gate", () => { + it("returns GIFT_CARDS_DISABLED in the payload when the rail is off, without purchasing", async () => { + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + const result = await resolve() + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe("GIFT_CARDS_DISABLED") + expect(result.order).toBeUndefined() + expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + }) + + it("gates on the calling account's country", async () => { + await resolve() + + expect(mockResolveCountry).toHaveBeenCalledWith(ctx.domainAccount) + expect(mockMasterGate).toHaveBeenCalledWith("JM") + }) + + it("returns GIFT_CARD_PROVIDER_UNAVAILABLE when no provider serves the country", async () => { + mockMasterGate.mockReturnValue({ + ok: false, + error: new GiftCardProviderUnavailableError(), + }) + + const result = await resolve() + + expect(result.errors[0].code).toBe("GIFT_CARD_PROVIDER_UNAVAILABLE") + }) + }) + + describe("the purchase call", () => { + it("passes every input through, with the account from the session and the value as minor units", async () => { + await resolve({ quantity: 3 }) + + expect(mockPurchaseGiftCard).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + walletId: WALLET_ID, + productId: PRODUCT_ID, + valueMinor: 2500, + quantity: 3, + idempotencyKey: IDEMPOTENCY_KEY, + }) + }) + + it("passes the idempotencyKey through UNCHANGED", async () => { + // The replay guarantee hangs on the key reaching the store exactly as the + // client will send it again. Any normalisation here would make a retry + // with the same key look like a new purchase. + const key = "ULID-01J7M4QZ8X-mixedCase_and.dots" + await resolve({ idempotencyKey: key }) + + expect(mockPurchaseGiftCard).toHaveBeenCalledWith( + expect.objectContaining({ idempotencyKey: key }), + ) + }) + + it("defaults quantity to 1 when the input omits it", async () => { + await resolve({ quantity: undefined }) + expect(mockPurchaseGiftCard).toHaveBeenLastCalledWith( + expect.objectContaining({ quantity: 1 }), + ) + + await resolve({ quantity: null }) + expect(mockPurchaseGiftCard).toHaveBeenLastCalledWith( + expect.objectContaining({ quantity: 1 }), + ) + }) + + it("does NOT consume the purchase rate limiter itself — purchaseGiftCard does", async () => { + await resolve() + + expect(mockConsumeLimiter).not.toHaveBeenCalled() + }) + }) + + describe("error mapping", () => { + // Every ApplicationError the use case can return, and the code the client + // must see for it. A new error class that is not in the error map would + // throw `assertUnreachable` here instead of reaching the customer as a 500. + const CASES: Array<[string, Error, string]> = [ + ["disabled", new GiftCardsDisabledError(), "GIFT_CARDS_DISABLED"], + [ + "provider unavailable", + new GiftCardProviderUnavailableError(), + "GIFT_CARD_PROVIDER_UNAVAILABLE", + ], + [ + "product not found", + new GiftCardProductNotFoundError(), + "GIFT_CARD_PRODUCT_NOT_FOUND", + ], + [ + "not available in country", + new GiftCardProductNotAvailableInCountryError(), + "GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY", + ], + ["invalid value", new GiftCardInvalidValueError(), "GIFT_CARD_INVALID_VALUE"], + ["limit exceeded", new GiftCardLimitExceededError(), "GIFT_CARD_LIMIT_EXCEEDED"], + [ + "level not eligible", + new GiftCardLevelNotEligibleError(), + "GIFT_CARD_LEVEL_NOT_ELIGIBLE", + ], + ["quote mismatch", new GiftCardQuoteMismatchError(), "GIFT_CARD_QUOTE_MISMATCH"], + [ + "vendor rejected", + new GiftCardVendorRejectedOrderError(), + "GIFT_CARD_VENDOR_REJECTED", + ], + [ + "vendor unavailable", + new GiftCardVendorUnavailableError(), + "GIFT_CARD_VENDOR_UNAVAILABLE", + ], + [ + "idempotency key reuse", + new GiftCardIdempotencyKeyReuseError(), + "GIFT_CARD_IDEMPOTENCY_KEY_REUSE", + ], + [ + "rate limited", + new GiftCardPurchaseRateLimiterExceededError(), + "GIFT_CARD_PURCHASE_RATE_LIMITED", + ], + ["unknown", new UnknownGiftCardError(), "GIFT_CARD_UNKNOWN"], + [ + "bad idempotency key (app layer)", + new InvalidIdempotencyKeyError(), + "INVALID_INPUT", + ], + ] + + it.each(CASES)("maps %s into the payload", async (_label, error, code) => { + mockPurchaseGiftCard.mockResolvedValue(error) + + const result = await resolve() + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe(code) + expect(result.order).toBeUndefined() + expect(mockGetGiftCardOrderForAccount).not.toHaveBeenCalled() + }) + + it("relays the use case's message so the client shows the wording it chose", async () => { + mockPurchaseGiftCard.mockResolvedValue( + new GiftCardQuoteMismatchError("The gift card price changed; please try again"), + ) + + const result = await resolve() + + expect(result.errors[0].message).toBe( + "The gift card price changed; please try again", + ) + }) + }) + + describe("success", () => { + it("returns the order, without a claim, while it is still in flight", async () => { + const order = makeOrder({ status: "PAYMENT_PENDING" }) + mockPurchaseGiftCard.mockResolvedValue(order) + + const result = await resolve() + + expect(result.errors).toEqual([]) + expect(result.order).toMatchObject({ + id: order.id, + status: "PAYMENT_PENDING", + value: 2500, + currency: "USD", + quantity: 1, + claim: null, + }) + // No claim to read yet — the owner-scoped read is not even attempted. + expect(mockGetGiftCardOrderForAccount).not.toHaveBeenCalled() + }) + + it("returns a replayed order as-is (same key, same parameters)", async () => { + // purchaseGiftCard hands back the existing order for a replay; the + // mutation must not treat that as anything other than success. + mockPurchaseGiftCard.mockResolvedValue(makeOrder({ status: "EXPIRED" })) + + const result = await resolve() + + expect(result.errors).toEqual([]) + expect(result.order?.status).toBe("EXPIRED") + }) + + it("attaches the claim when the order is already FULFILLED, via the owner-scoped read", async () => { + const order = makeOrder({ + status: "FULFILLED", + claimCiphertext: "enc:...", + claimKeyId: "k1", + }) + mockPurchaseGiftCard.mockResolvedValue(order) + + const result = await resolve() + + expect(mockGetGiftCardOrderForAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + orderId: order.id, + }) + expect(result.errors).toEqual([]) + expect(result.order?.status).toBe("FULFILLED") + expect(result.order?.claim).toEqual(CLAIM) + }) + + it("still returns a FULFILLED order when its claim cannot be read — with the error alongside", async () => { + // The card IS issued; hiding the order would tell the customer nothing + // happened. But a FULFILLED order with a silently empty claim is worse, so + // the read failure rides along in `errors`. + mockPurchaseGiftCard.mockResolvedValue(makeOrder({ status: "FULFILLED" })) + mockGetGiftCardOrderForAccount.mockResolvedValue(new GiftCardClaimCryptoError()) + + const result = await resolve() + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe("GIFT_CARD_CLAIM_UNAVAILABLE") + expect(result.order?.status).toBe("FULFILLED") + expect(result.order?.claim).toBeNull() + }) + + it("never carries the ciphertext or key id onto the payload", async () => { + mockPurchaseGiftCard.mockResolvedValue( + makeOrder({ + status: "FULFILLED", + claimCiphertext: "enc:should-never-leave", + claimKeyId: "k1", + }), + ) + + const result = await resolve() + + expect(result.order).not.toHaveProperty("claimCiphertext") + expect(result.order).not.toHaveProperty("claimKeyId") + expect(JSON.stringify(result)).not.toContain("should-never-leave") + }) + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/gift-card-catalog.spec.ts b/test/flash/unit/graphql/public/root/query/gift-card-catalog.spec.ts new file mode 100644 index 000000000..6eec47cd9 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/gift-card-catalog.spec.ts @@ -0,0 +1,220 @@ +// jest.mock calls are hoisted before imports + +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockListGiftCardProducts = jest.fn() + +jest.mock("@app/gift-cards", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), + listGiftCardProducts: (...a: unknown[]) => mockListGiftCardProducts(...a), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { + GiftCardCatalogUnavailableError, + GiftCardProviderUnavailableError, + GiftCardsDisabledError, +} from "@domain/gift-cards" +import { InputValidationError } from "@graphql/error" +import GiftCardCatalogQuery from "@graphql/public/root/query/gift-card-catalog" + +import { makeProduct } from "test/flash/unit/app/gift-cards/fixtures" + +const ACCOUNT_ID = "account-001" as AccountId +const KRATOS_USER_ID = "kratos-1" as UserId + +const ctx = { + domainAccount: { id: ACCOUNT_ID, kratosUserId: KRATOS_USER_ID, level: 1 }, +} as unknown as GraphQLPublicContextAuth + +type Connection = { + edges: { node: GiftCardProduct; cursor: string }[] + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor: string | null + endCursor: string | null + } +} + +const resolve = async (args: Record = {}): Promise => { + const query = GiftCardCatalogQuery as unknown as { + resolve: ( + source: null, + args: Record, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise + } + return query.resolve(null, args, ctx, {} as never) +} + +const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64") + +const AMAZON = makeProduct({ id: "bitcoinCompany:amazon-us" as GiftCardProductId }) +const UBER = makeProduct({ + id: "bitcoinCompany:uber-us" as GiftCardProductId, + providerProductId: "uber-us", + name: "Uber", + brand: "Uber", +}) + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCountry.mockResolvedValue("JM") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockListGiftCardProducts.mockResolvedValue({ + products: [AMAZON, UBER], + hasNextPage: true, + endCursor: b64(UBER.id), + stale: false, + }) +}) + +describe("giftCardCatalog resolver", () => { + describe("master gate", () => { + it("throws GIFT_CARDS_DISABLED when the rail is off, without reading the catalog", async () => { + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + await expect(resolve()).rejects.toMatchObject({ + extensions: { code: "GIFT_CARDS_DISABLED" }, + }) + expect(mockListGiftCardProducts).not.toHaveBeenCalled() + }) + + it("throws GIFT_CARD_PROVIDER_UNAVAILABLE when no enabled provider serves the country", async () => { + mockMasterGate.mockReturnValue({ + ok: false, + error: new GiftCardProviderUnavailableError(), + }) + + await expect(resolve()).rejects.toMatchObject({ + extensions: { code: "GIFT_CARD_PROVIDER_UNAVAILABLE" }, + }) + expect(mockListGiftCardProducts).not.toHaveBeenCalled() + }) + }) + + describe("country", () => { + it("defaults to the calling account's country, and lists THAT country", async () => { + await resolve() + + expect(mockResolveCountry).toHaveBeenCalledWith(ctx.domainAccount) + expect(mockMasterGate).toHaveBeenCalledWith("JM") + expect(mockListGiftCardProducts).toHaveBeenCalledWith( + expect.objectContaining({ countryCode: "JM" }), + ) + }) + + it("uses an explicit countryCode for both the gate and the list, without resolving the account's", async () => { + await resolve({ countryCode: "US" }) + + expect(mockResolveCountry).not.toHaveBeenCalled() + expect(mockMasterGate).toHaveBeenCalledWith("US") + expect(mockListGiftCardProducts).toHaveBeenCalledWith( + expect.objectContaining({ countryCode: "US" }), + ) + }) + + it("throws the scalar's validation error for a malformed countryCode", async () => { + await expect( + resolve({ + countryCode: new InputValidationError({ + message: "Invalid value for CountryCode", + }), + }), + ).rejects.toMatchObject({ extensions: { code: "INVALID_INPUT" } }) + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockListGiftCardProducts).not.toHaveBeenCalled() + }) + }) + + it("passes the filters and page arguments through, dropping nulls", async () => { + await resolve({ category: "Shopping", search: "ama", first: 10, after: "abc" }) + + expect(mockListGiftCardProducts).toHaveBeenCalledWith({ + countryCode: "JM", + category: "Shopping", + search: "ama", + first: 10, + after: "abc", + }) + + await resolve({ category: null, search: null, first: null, after: null }) + + expect(mockListGiftCardProducts).toHaveBeenLastCalledWith({ + countryCode: "JM", + category: undefined, + search: undefined, + first: undefined, + after: undefined, + }) + }) + + it("refuses a non-positive page size before the gate", async () => { + await expect(resolve({ first: 0 })).rejects.toMatchObject({ + extensions: { code: "INVALID_INPUT" }, + }) + expect(mockListGiftCardProducts).not.toHaveBeenCalled() + }) + + describe("connection shape", () => { + it("wraps the page as relay edges with the app layer's cursor encoding", async () => { + const result = await resolve() + + expect(result.edges).toEqual([ + { node: AMAZON, cursor: b64(AMAZON.id) }, + { node: UBER, cursor: b64(UBER.id) }, + ]) + expect(result.pageInfo).toEqual({ + hasNextPage: true, + hasPreviousPage: false, + startCursor: b64(AMAZON.id), + endCursor: b64(UBER.id), + }) + }) + + it("stamps every edge with a cursor the app layer's `after` accepts", async () => { + // The app decodes `after` as base64 of the product id and resumes AFTER + // it. An edge cursor in any other encoding would restart the list from + // the top on every page — a paginator that never advances. + const result = await resolve() + + for (const edge of result.edges) { + expect(Buffer.from(edge.cursor, "base64").toString("utf8")).toBe(edge.node.id) + } + expect(result.pageInfo.endCursor).toBe(result.edges[1].cursor) + }) + + it("returns an empty connection with null cursors for an empty page", async () => { + mockListGiftCardProducts.mockResolvedValue({ + products: [], + hasNextPage: false, + endCursor: null, + stale: false, + }) + + const result = await resolve() + + expect(result.edges).toEqual([]) + expect(result.pageInfo).toEqual({ + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }) + }) + }) + + it("throws the mapped app error when the catalog cannot be read", async () => { + mockListGiftCardProducts.mockResolvedValue(new GiftCardCatalogUnavailableError()) + + await expect(resolve()).rejects.toMatchObject({ + extensions: { code: "GIFT_CARD_CATALOG_UNAVAILABLE" }, + }) + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts b/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts new file mode 100644 index 000000000..93af669ea --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts @@ -0,0 +1,198 @@ +// jest.mock calls are hoisted before imports + +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockGetGiftCardOrderForAccount = jest.fn() + +jest.mock("@app/gift-cards", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), + getGiftCardOrderForAccount: (...a: unknown[]) => mockGetGiftCardOrderForAccount(...a), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { + GiftCardClaimCryptoError, + GiftCardOrderNotFoundError, + GiftCardsDisabledError, +} from "@domain/gift-cards" +import GiftCardOrderQuery from "@graphql/public/root/query/gift-card-order" +import type { GiftCardOrderSource } from "@graphql/public/types/object/gift-card-order" + +import { makeOrder } from "test/flash/unit/app/gift-cards/fixtures" + +const ACCOUNT_ID = "account-001" as AccountId +const ORDER_ID = "order-1" + +const ctx = { + domainAccount: { id: ACCOUNT_ID, kratosUserId: "kratos-1", level: 1 }, +} as unknown as GraphQLPublicContextAuth + +const CLAIM: GiftCardClaim = { + codes: [{ label: "Code", value: "ABCD-EFGH-1234" }], + claimLink: "https://vendor.example/claim/abc", + barcode: { chars: "1234567890", type: "CODE128" }, +} + +const resolve = async (id: string = ORDER_ID): Promise => { + const query = GiftCardOrderQuery as unknown as { + resolve: ( + source: null, + args: { id: string }, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise + } + return query.resolve(null, { id }, ctx, {} as never) +} + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCountry.mockResolvedValue("JM") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ status: "PAID", paidSats: 40_100 as Satoshis }), + claim: null, + }) +}) + +describe("giftCardOrder resolver", () => { + it("still reads the order when the rail is off (owner-scoped read of a paid order)", async () => { + // Switching gift cards off must never hide codes a customer already paid + // for, so this query deliberately does not consult the master gate. + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + await expect(resolve()).resolves.toMatchObject({ id: ORDER_ID }) + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockGetGiftCardOrderForAccount).toHaveBeenCalledTimes(1) + }) + + it("asks only about THIS account's order", async () => { + await resolve() + + // The account id comes from the session, never from the argument — the + // order id alone must not be able to name whose order is being read. + expect(mockGetGiftCardOrderForAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + orderId: ORDER_ID, + }) + }) + + it("returns null for an unknown id or another account's order — indistinguishably", async () => { + // The app layer answers GiftCardOrderNotFoundError for a non-owner, the + // same as for a missing id. Null here, not an error, so the endpoint cannot + // be used to confirm that someone else's order exists. + mockGetGiftCardOrderForAccount.mockResolvedValue(new GiftCardOrderNotFoundError()) + + expect(await resolve("someone-elses-order")).toBeNull() + }) + + it("throws the mapped error when the order exists but its claim cannot be read", async () => { + // Not null: null means "not yours / does not exist", and this order is + // both the caller's and real. A fault reading it is reported as a fault. + mockGetGiftCardOrderForAccount.mockResolvedValue(new GiftCardClaimCryptoError()) + + await expect(resolve()).rejects.toMatchObject({ + extensions: { code: "GIFT_CARD_CLAIM_UNAVAILABLE" }, + }) + }) + + describe("the claim", () => { + it("is absent before the order is FULFILLED", async () => { + const result = await resolve() + + expect(result?.status).toBe("PAID") + expect(result?.claim).toBeNull() + }) + + it("is dropped even if a claim arrives on a non-FULFILLED order", async () => { + // Belt and braces: the app layer only decrypts once fulfilled, and the + // mapper refuses to attach one otherwise. + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ status: "PAID" }), + claim: CLAIM, + }) + + expect((await resolve())?.claim).toBeNull() + }) + + it("is returned, intact, for a FULFILLED order", async () => { + const fulfilledAt = new Date("2026-09-09T10:00:00Z") + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ + status: "FULFILLED", + paidSats: 40_100 as Satoshis, + fulfilledAt, + claimCiphertext: "enc:...", + claimKeyId: "k1", + }), + claim: CLAIM, + }) + + const result = await resolve() + + expect(result?.status).toBe("FULFILLED") + expect(result?.claim).toEqual(CLAIM) + expect(result?.fulfilledAt).toBe(fulfilledAt) + expect(result?.paidSats).toBe(40_100) + }) + }) + + it("never carries the ciphertext or key id onto the GraphQL source", async () => { + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ + status: "FULFILLED", + claimCiphertext: "enc:should-never-leave", + claimKeyId: "k1", + }), + claim: CLAIM, + }) + + const result = await resolve() + + expect(result).not.toHaveProperty("claimCiphertext") + expect(result).not.toHaveProperty("claimKeyId") + expect(JSON.stringify(result)).not.toContain("should-never-leave") + }) + + it("maps the order fields onto the wire names", async () => { + const createdAt = new Date("2026-09-09T09:00:00Z") + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ + status: "FAILED", + valueMinor: 2500, + currency: "USD", + quantity: 3, + createdAt, + failureReason: "vendor-create-failed: GiftCardVendorRejectedOrderError", + }), + claim: null, + }) + + const result = await resolve() + + expect(result).toEqual({ + id: "order-1", + status: "FAILED", + product: { + name: "Amazon US", + brand: "Amazon", + countryCode: "US", + currency: "USD", + isOpenLoop: false, + logoUrl: null, + }, + value: 2500, + currency: "USD", + quantity: 3, + paidSats: null, + claim: null, + createdAt, + fulfilledAt: null, + failureReason: "vendor-create-failed: GiftCardVendorRejectedOrderError", + }) + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/gift-card-orders.spec.ts b/test/flash/unit/graphql/public/root/query/gift-card-orders.spec.ts new file mode 100644 index 000000000..260655769 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/gift-card-orders.spec.ts @@ -0,0 +1,204 @@ +// jest.mock calls are hoisted before imports + +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockListGiftCardOrdersForAccount = jest.fn() + +jest.mock("@app/gift-cards", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), + listGiftCardOrdersForAccount: (...a: unknown[]) => + mockListGiftCardOrdersForAccount(...a), +})) + +jest.mock("@services/logger", () => ({ + // The error map binds `logger[error.level]`; a repository fault is Critical. + baseLogger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + critical: jest.fn(), + fatal: jest.fn(), + }, +})) + +import { UnknownRepositoryError } from "@domain/errors" +import { GiftCardsDisabledError } from "@domain/gift-cards" +import GiftCardOrdersQuery, { + decodeGiftCardOrderCursor, + encodeGiftCardOrderCursor, +} from "@graphql/public/root/query/gift-card-orders" +import type { GiftCardOrderSource } from "@graphql/public/types/object/gift-card-order" + +import { makeOrder } from "test/flash/unit/app/gift-cards/fixtures" + +const ACCOUNT_ID = "account-001" as AccountId + +const ctx = { + domainAccount: { id: ACCOUNT_ID, kratosUserId: "kratos-1", level: 1 }, +} as unknown as GraphQLPublicContextAuth + +type Connection = { + edges: { node: GiftCardOrderSource; cursor: string }[] + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor: string | null + endCursor: string | null + } +} + +const resolve = async (args: Record = {}): Promise => { + const query = GiftCardOrdersQuery as unknown as { + resolve: ( + source: null, + args: Record, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise + } + return query.resolve(null, args, ctx, {} as never) +} + +const NEWER_AT = new Date("2026-09-09T10:00:00.123Z") +const OLDER_AT = new Date("2026-09-08T10:00:00.456Z") + +const NEWER = makeOrder({ + id: "order-newer" as GiftCardOrderId, + status: "FULFILLED", + createdAt: NEWER_AT, + claimCiphertext: "enc:never-in-a-list", + claimKeyId: "k1", +}) +const OLDER = makeOrder({ id: "order-older" as GiftCardOrderId, createdAt: OLDER_AT }) + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCountry.mockResolvedValue("JM") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockListGiftCardOrdersForAccount.mockResolvedValue({ + orders: [NEWER, OLDER], + hasNextPage: true, + endCursor: OLDER_AT, + }) +}) + +describe("gift card order cursors", () => { + it("round-trip to the millisecond", () => { + const at = new Date("2026-09-09T10:00:00.123Z") + expect(decodeGiftCardOrderCursor(encodeGiftCardOrderCursor(at))?.getTime()).toBe( + at.getTime(), + ) + }) + + it("reject garbage instead of decoding it to a date", () => { + expect(decodeGiftCardOrderCursor("not-base64-of-a-date")).toBeNull() + expect( + decodeGiftCardOrderCursor(Buffer.from("yesterday").toString("base64")), + ).toBeNull() + }) +}) + +describe("giftCardOrders resolver", () => { + it("still lists orders when the rail is off (owner-scoped read of paid orders)", async () => { + // Switching gift cards off must never hide codes a customer already paid + // for, so this query deliberately does not consult the master gate. + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + await expect(resolve()).resolves.toBeDefined() + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockListGiftCardOrdersForAccount).toHaveBeenCalledTimes(1) + }) + + it("lists only THIS account's orders", async () => { + await resolve({ first: 5 }) + + expect(mockListGiftCardOrdersForAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + first: 5, + after: undefined, + }) + }) + + describe("arguments", () => { + it("refuses a non-positive page size", async () => { + await expect(resolve({ first: 0 })).rejects.toMatchObject({ + extensions: { code: "INVALID_INPUT" }, + }) + expect(mockListGiftCardOrdersForAccount).not.toHaveBeenCalled() + }) + + it("refuses an undecodable cursor", async () => { + await expect(resolve({ after: "garbage" })).rejects.toMatchObject({ + extensions: { code: "INVALID_INPUT" }, + }) + expect(mockListGiftCardOrdersForAccount).not.toHaveBeenCalled() + }) + + it("decodes `after` into the Date the app layer pages on", async () => { + await resolve({ after: encodeGiftCardOrderCursor(OLDER_AT) }) + + expect(mockListGiftCardOrdersForAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + first: undefined, + after: OLDER_AT, + }) + }) + }) + + describe("connection shape", () => { + it("wraps the page as relay edges with createdAt cursors", async () => { + const result = await resolve() + + expect(result.edges.map((e) => e.node.id)).toEqual(["order-newer", "order-older"]) + expect(result.edges.map((e) => e.cursor)).toEqual([ + encodeGiftCardOrderCursor(NEWER_AT), + encodeGiftCardOrderCursor(OLDER_AT), + ]) + expect(result.pageInfo).toEqual({ + hasNextPage: true, + hasPreviousPage: false, + startCursor: encodeGiftCardOrderCursor(NEWER_AT), + endCursor: encodeGiftCardOrderCursor(OLDER_AT), + }) + }) + + it("never includes a claim in a listing, even for a FULFILLED order", async () => { + const result = await resolve() + + expect(result.edges[0].node.status).toBe("FULFILLED") + expect(result.edges[0].node.claim).toBeNull() + expect(JSON.stringify(result)).not.toContain("never-in-a-list") + for (const edge of result.edges) { + expect(edge.node).not.toHaveProperty("claimCiphertext") + expect(edge.node).not.toHaveProperty("claimKeyId") + } + }) + + it("returns an empty connection with null cursors for an empty page", async () => { + mockListGiftCardOrdersForAccount.mockResolvedValue({ + orders: [], + hasNextPage: false, + endCursor: null, + }) + + const result = await resolve() + + expect(result.edges).toEqual([]) + expect(result.pageInfo).toEqual({ + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }) + }) + }) + + it("throws the mapped error when the store cannot be read", async () => { + mockListGiftCardOrdersForAccount.mockResolvedValue(new UnknownRepositoryError()) + + await expect(resolve()).rejects.toMatchObject({ + extensions: { code: expect.any(String) }, + }) + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts b/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts new file mode 100644 index 000000000..a981f0e72 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts @@ -0,0 +1,169 @@ +// jest.mock calls are hoisted before imports + +const mockMasterGate = jest.fn() +const mockResolveCountry = jest.fn() +const mockQuoteGiftCard = jest.fn() + +jest.mock("@app/gift-cards", () => ({ + giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), + quoteGiftCard: (...a: unknown[]) => mockQuoteGiftCard(...a), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { + GiftCardInvalidValueError, + GiftCardProductNotFoundError, + GiftCardsDisabledError, +} from "@domain/gift-cards" +import { InputValidationError } from "@graphql/error" +import GiftCardQuoteQuery from "@graphql/public/root/query/gift-card-quote" +import type { GiftCardQuoteSource } from "@graphql/public/types/object/gift-card-quote" + +const ACCOUNT_ID = "account-001" as AccountId +const PRODUCT_ID = "bitcoinCompany:amazon-us" +const EXPIRES_AT = new Date("2026-09-10T12:00:00Z") + +const ctx = { + domainAccount: { id: ACCOUNT_ID, kratosUserId: "kratos-1", level: 1 }, +} as unknown as GraphQLPublicContextAuth + +const QUOTE: GiftCardQuote = { + productId: PRODUCT_ID as GiftCardProductId, + valueMinor: 2500, + currency: "USD", + quantity: 2, + fiatCostMinor: 4900, + satsCost: 80_000 as Satoshis, + rewardSats: 800 as Satoshis, + bitcoinPriceMinor: 6_250_000, + expiresAt: EXPIRES_AT, +} + +const resolve = async (args: Record): Promise => { + const query = GiftCardQuoteQuery as unknown as { + resolve: ( + source: null, + args: Record, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise + } + return query.resolve(null, args, ctx, {} as never) +} + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCountry.mockResolvedValue("JM") + mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockQuoteGiftCard.mockResolvedValue(QUOTE) +}) + +describe("giftCardQuote resolver", () => { + describe("argument validation", () => { + it("throws the CentAmount scalar's error without quoting", async () => { + await expect( + resolve({ + productId: PRODUCT_ID, + value: new InputValidationError({ message: "Invalid value for CentAmount" }), + }), + ).rejects.toMatchObject({ extensions: { code: "INVALID_INPUT" } }) + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockQuoteGiftCard).not.toHaveBeenCalled() + }) + + it("refuses a zero or negative quantity before the gate", async () => { + await expect( + resolve({ productId: PRODUCT_ID, value: 2500, quantity: 0 }), + ).rejects.toMatchObject({ extensions: { code: "INVALID_INPUT" } }) + await expect( + resolve({ productId: PRODUCT_ID, value: 2500, quantity: -1 }), + ).rejects.toMatchObject({ extensions: { code: "INVALID_INPUT" } }) + expect(mockQuoteGiftCard).not.toHaveBeenCalled() + }) + + it("defaults quantity to 1 when the argument is absent or null", async () => { + await resolve({ productId: PRODUCT_ID, value: 2500 }) + expect(mockQuoteGiftCard).toHaveBeenLastCalledWith( + expect.objectContaining({ quantity: 1 }), + ) + + await resolve({ productId: PRODUCT_ID, value: 2500, quantity: null }) + expect(mockQuoteGiftCard).toHaveBeenLastCalledWith( + expect.objectContaining({ quantity: 1 }), + ) + }) + + it("leaves the upper quantity bound to the app layer (GIFT_CARD_INVALID_VALUE)", async () => { + mockQuoteGiftCard.mockResolvedValue( + new GiftCardInvalidValueError("Quantity must be between 1 and 10"), + ) + + await expect( + resolve({ productId: PRODUCT_ID, value: 2500, quantity: 11 }), + ).rejects.toMatchObject({ + extensions: { code: "GIFT_CARD_INVALID_VALUE" }, + message: "Quantity must be between 1 and 10", + }) + }) + }) + + it("throws GIFT_CARDS_DISABLED when the rail is off, without quoting", async () => { + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + await expect(resolve({ productId: PRODUCT_ID, value: 2500 })).rejects.toMatchObject({ + extensions: { code: "GIFT_CARDS_DISABLED" }, + }) + expect(mockResolveCountry).toHaveBeenCalledWith(ctx.domainAccount) + expect(mockMasterGate).toHaveBeenCalledWith("JM") + expect(mockQuoteGiftCard).not.toHaveBeenCalled() + }) + + it("quotes for THIS account, with the value as minor units", async () => { + await resolve({ productId: PRODUCT_ID, value: 2500, quantity: 2 }) + + // The account id comes from the session, never from an argument. + expect(mockQuoteGiftCard).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + productId: PRODUCT_ID, + valueMinor: 2500, + quantity: 2, + }) + }) + + it("maps the domain quote onto the wire names, one field each, dropping the vendor price", async () => { + const result = await resolve({ productId: PRODUCT_ID, value: 2500, quantity: 2 }) + + // Every source value is distinct so a swap between any two fields fails. + expect(result).toEqual({ + productId: PRODUCT_ID, + value: 2500, + currency: "USD", + quantity: 2, + fiatCost: 4900, + satsCost: 80_000, + rewardSats: 800, + expiresAt: EXPIRES_AT, + }) + expect(result).not.toHaveProperty("bitcoinPriceMinor") + expect(result).not.toHaveProperty("valueMinor") + expect(result).not.toHaveProperty("fiatCostMinor") + }) + + it("hands expiresAt to the Timestamp scalar as a Date", async () => { + const result = await resolve({ productId: PRODUCT_ID, value: 2500 }) + + expect(result.expiresAt).toBeInstanceOf(Date) + }) + + it("throws the mapped app error when the quote is refused", async () => { + mockQuoteGiftCard.mockResolvedValue(new GiftCardProductNotFoundError()) + + await expect(resolve({ productId: PRODUCT_ID, value: 2500 })).rejects.toMatchObject({ + extensions: { code: "GIFT_CARD_PRODUCT_NOT_FOUND" }, + }) + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts b/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts new file mode 100644 index 000000000..a90d0f53c --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts @@ -0,0 +1,74 @@ +// jest.mock calls are hoisted before imports + +import GlobalsQuery from "@graphql/public/root/query/globals" + +import { makeGiftCardsConfig } from "test/flash/unit/app/gift-cards/fixtures" + +let mockGiftCardsConfig = makeGiftCardsConfig() + +jest.mock("@config", () => ({ + ...jest.requireActual("@config"), + get GiftCardsConfig() { + return mockGiftCardsConfig + }, +})) + +jest.mock("@services/fygaro/webhook-server/fygaro-settings", () => ({ + getFygaroSettings: jest.fn().mockResolvedValue(undefined), +})) + +type GlobalsResult = { giftCardsEnabled: boolean } + +const resolveGlobals = async (): Promise => { + const query = GlobalsQuery as unknown as { + resolve: ( + source: null, + args: Record, + context: unknown, + info: never, + ) => Promise + } + return query.resolve(null, {}, {}, undefined as never) +} + +const providersOff = () => { + const base = makeGiftCardsConfig() + return { + bitcoinCompany: { ...base.providers.bitcoinCompany, enabled: false }, + bitrefill: { ...base.providers.bitrefill, enabled: false }, + } +} + +describe("globals query — giftCardsEnabled", () => { + it("is true when the rail is on and at least one provider is enabled", async () => { + mockGiftCardsConfig = makeGiftCardsConfig() // enabled, bitcoinCompany on + + expect((await resolveGlobals()).giftCardsEnabled).toBe(true) + }) + + it("is false when the rail flag is off, even with a provider enabled", async () => { + mockGiftCardsConfig = makeGiftCardsConfig({ enabled: false }) + + expect((await resolveGlobals()).giftCardsEnabled).toBe(false) + }) + + it("is false when the rail is on but every provider is disabled", async () => { + // Every giftCard* field would answer GIFT_CARD_PROVIDER_UNAVAILABLE, so the + // entry point must stay hidden: the flag alone is not the truth. + mockGiftCardsConfig = makeGiftCardsConfig({ providers: providersOff() }) + + expect((await resolveGlobals()).giftCardsEnabled).toBe(false) + }) + + it("counts ANY enabled provider, not only the default route", async () => { + const base = makeGiftCardsConfig() + mockGiftCardsConfig = makeGiftCardsConfig({ + providers: { + bitcoinCompany: { ...base.providers.bitcoinCompany, enabled: false }, + bitrefill: { ...base.providers.bitrefill, enabled: true }, + }, + }) + + expect((await resolveGlobals()).giftCardsEnabled).toBe(true) + }) +}) diff --git a/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts b/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts new file mode 100644 index 000000000..b2b8a1b3a --- /dev/null +++ b/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts @@ -0,0 +1,211 @@ +import { GiftCardOrderStatus } from "@domain/gift-cards" +import GiftCardOrder, { + GiftCardOrderConnection, + GiftCardOrderStatusEnum, + toGiftCardOrderSource, +} from "@graphql/public/types/object/gift-card-order" +import GiftCardProduct, { + GiftCardDenominationTypeEnum, + GiftCardProductConnection, +} from "@graphql/public/types/object/gift-card-product" + +import { makeOrder } from "test/flash/unit/app/gift-cards/fixtures" + +const CLAIM: GiftCardClaim = { + codes: [{ label: "PIN", value: "1234" }], + claimLink: null, + barcode: null, +} + +describe("GiftCardOrderStatus enum", () => { + it("serializes every domain status to a member of the same name", () => { + // The domain constants ARE the wire spelling, so each must round-trip to + // itself. A status added to the domain without a member here fails this + // at the `serialize` call rather than at the customer polling their order. + const statuses = Object.values(GiftCardOrderStatus) + expect(statuses).toHaveLength(9) + + for (const status of statuses) { + expect(GiftCardOrderStatusEnum.serialize(status)).toBe(status) + } + }) + + it("has exactly the members the design doc lists, and no others", () => { + expect( + GiftCardOrderStatusEnum.getValues() + .map((v) => v.name) + .sort(), + ).toEqual( + [ + "CREATED", + "INVOICE_ISSUED", + "PAYMENT_PENDING", + "PAID", + "FULFILLED", + "FAILED", + "PAYMENT_FAILED", + "EXPIRED", + "REFUND_REQUIRED", + ].sort(), + ) + }) + + it("refuses a value no member carries", () => { + // The assertions above are only worth something if serialize can say no. + expect(() => GiftCardOrderStatusEnum.serialize("fulfilled")).toThrow() + expect(() => GiftCardOrderStatusEnum.serialize("DELIVERED")).toThrow() + }) + + it("describes every member", () => { + for (const value of GiftCardOrderStatusEnum.getValues()) { + expect(value.description).toBeTruthy() + } + }) +}) + +describe("GiftCardDenominationType enum", () => { + it("serializes the domain's lower-case values to upper-case members", () => { + expect(GiftCardDenominationTypeEnum.serialize("fixed")).toBe("FIXED") + expect(GiftCardDenominationTypeEnum.serialize("variable")).toBe("VARIABLE") + }) + + it("refuses the wire spelling as an internal value", () => { + expect(() => GiftCardDenominationTypeEnum.serialize("FIXED")).toThrow() + }) +}) + +describe("toGiftCardOrderSource", () => { + it("exposes exactly the public fields, and nothing the domain row carries beyond them", () => { + const source = toGiftCardOrderSource( + makeOrder({ claimCiphertext: "enc:secret", claimKeyId: "k1" }), + ) + + expect(Object.keys(source).sort()).toEqual( + [ + "id", + "status", + "product", + "value", + "currency", + "quantity", + "paidSats", + "claim", + "createdAt", + "fulfilledAt", + "failureReason", + ].sort(), + ) + expect(source).not.toHaveProperty("claimCiphertext") + expect(source).not.toHaveProperty("claimKeyId") + expect(source).not.toHaveProperty("paymentRequest") + expect(source).not.toHaveProperty("idempotencyKey") + expect(source).not.toHaveProperty("walletId") + expect(JSON.stringify(source)).not.toContain("enc:secret") + }) + + it("attaches the claim only for a FULFILLED order", () => { + expect( + toGiftCardOrderSource(makeOrder({ status: "FULFILLED" }), CLAIM).claim, + ).toEqual(CLAIM) + + for (const status of Object.values(GiftCardOrderStatus)) { + if (status === GiftCardOrderStatus.Fulfilled) continue + expect(toGiftCardOrderSource(makeOrder({ status }), CLAIM).claim).toBeNull() + } + }) + + it("defaults the claim to null", () => { + expect(toGiftCardOrderSource(makeOrder({ status: "FULFILLED" })).claim).toBeNull() + }) + + it("renames the money field and hands Dates through for the Timestamp scalar", () => { + const createdAt = new Date("2026-09-09T09:00:00Z") + const fulfilledAt = new Date("2026-09-09T09:01:00Z") + const source = toGiftCardOrderSource( + makeOrder({ + status: "FULFILLED", + valueMinor: 7_500, + paidSats: 123_456 as Satoshis, + createdAt, + fulfilledAt, + }), + ) + + expect(source.value).toBe(7_500) + expect(source.paidSats).toBe(123_456) + expect(source.createdAt).toBe(createdAt) + expect(source.fulfilledAt).toBe(fulfilledAt) + }) +}) + +describe("GraphQL type shapes", () => { + it("GiftCardOrder declares no field that could carry encrypted claim material", () => { + const fields = Object.keys(GiftCardOrder.getFields()) + + expect(fields).not.toContain("claimCiphertext") + expect(fields).not.toContain("claimKeyId") + expect(fields).not.toContain("paymentRequest") + expect(fields).toEqual( + expect.arrayContaining([ + "id", + "status", + "product", + "value", + "currency", + "quantity", + "paidSats", + "claim", + "createdAt", + "fulfilledAt", + "failureReason", + ]), + ) + }) + + it("GiftCardProduct declares the storefront fields and hides the vendor plumbing", () => { + const fields = Object.keys(GiftCardProduct.getFields()) + + expect(fields).not.toContain("providerId") + expect(fields).not.toContain("providerProductId") + expect(fields).not.toContain("inStock") + expect(fields.sort()).toEqual( + [ + "id", + "name", + "brand", + "countryCode", + "currency", + "denominationType", + "denominations", + "minValue", + "maxValue", + "isOpenLoop", + "categories", + "logoUrl", + "termsUrl", + "rewardBps", + ].sort(), + ) + }) + + it("describes every field on the public types", () => { + for (const type of [GiftCardOrder, GiftCardProduct]) { + for (const [name, field] of Object.entries(type.getFields())) { + expect({ + type: type.name, + field: name, + described: Boolean(field.description), + }).toEqual({ type: type.name, field: name, described: true }) + } + } + }) + + it("names the connections the way the design doc does", () => { + expect(GiftCardOrderConnection.name).toBe("GiftCardOrderConnection") + expect(GiftCardProductConnection.name).toBe("GiftCardProductConnection") + expect(Object.keys(GiftCardOrderConnection.getFields()).sort()).toEqual([ + "edges", + "pageInfo", + ]) + }) +}) From c16153d4024259753b759780d670961cabba8107 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 10 Sep 2026 00:02:05 -0700 Subject: [PATCH 03/14] fix(gift-cards): wire giftCardPurchase rate limit to its own getter; 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 Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf --- dev/apollo-federation/supergraph.graphql | 3 ++- src/domain/rate-limit/index.ts | 2 +- src/graphql/public/root/gift-card-gate.ts | 10 +++++++--- src/graphql/public/schema.graphql | 3 ++- src/graphql/public/types/object/globals.ts | 3 ++- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index c579378fa..1155b8300 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1722,7 +1722,8 @@ type Globals Whether gift card entry points (the storefront tab, "buy a gift card" actions) should be shown to the user. True only when the instance-wide giftCards feature flag is on AND at least one gift card - provider is enabled; when false every giftCard* field refuses. + provider is enabled; when false the catalog, quote, and purchase fields + refuse. Order history and delivered codes stay readable regardless. """ giftCardsEnabled: Boolean! diff --git a/src/domain/rate-limit/index.ts b/src/domain/rate-limit/index.ts index 97249a32a..0eee3b4e4 100644 --- a/src/domain/rate-limit/index.ts +++ b/src/domain/rate-limit/index.ts @@ -119,7 +119,7 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = { }, giftCardPurchase: { key: RateLimitPrefix.giftCardPurchase, - limits: getFygaroCheckoutCreateAttemptLimits(), + limits: getGiftCardPurchaseAttemptLimits(), error: GiftCardPurchaseRateLimiterExceededError, }, // The read side of the same ERPNext dependency, and the CHEAPER one to abuse: diff --git a/src/graphql/public/root/gift-card-gate.ts b/src/graphql/public/root/gift-card-gate.ts index 2c130cdd6..688c74a3b 100644 --- a/src/graphql/public/root/gift-card-gate.ts +++ b/src/graphql/public/root/gift-card-gate.ts @@ -1,15 +1,19 @@ import { giftCardsMasterGate, resolveAccountCountryCodeOrUnknown } from "@app/gift-cards" /** - * The gate every gift-card root field opens with, in one place. + * The gate the catalog, quote, and purchase root fields open with, in one place. + * + * `giftCardOrder` / `giftCardOrders` deliberately do NOT use it: they are + * owner-scoped reads of orders the customer already paid for, and switching the + * rail off must never hide codes they own. * * Resolves the routing country (the explicit argument when a field takes one, * otherwise the account's), runs `giftCardsMasterGate` on it, and hands back * the country it gated on so the caller passes THAT country down — not a * second resolution that could disagree with the one the gate saw. * - * Queries throw the mapped error; the mutation returns it in `errors`. Both - * start here so the catalog can never show what the purchase would refuse. + * Gated queries throw the mapped error; the mutation returns it in `errors`. + * Both start here so the catalog can never show what the purchase would refuse. */ export type GiftCardGate = | { ok: true; providerId: GiftCardProviderId; countryCode: string } diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 6a45a7688..77506aeed 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1451,7 +1451,8 @@ type Globals { Whether gift card entry points (the storefront tab, "buy a gift card" actions) should be shown to the user. True only when the instance-wide giftCards feature flag is on AND at least one gift card - provider is enabled; when false every giftCard* field refuses. + provider is enabled; when false the catalog, quote, and purchase fields + refuse. Order history and delivered codes stay readable regardless. """ giftCardsEnabled: Boolean! diff --git a/src/graphql/public/types/object/globals.ts b/src/graphql/public/types/object/globals.ts index 381ff6087..0d0995429 100644 --- a/src/graphql/public/types/object/globals.ts +++ b/src/graphql/public/types/object/globals.ts @@ -65,7 +65,8 @@ const Globals = GT.Object({ description: dedent`Whether gift card entry points (the storefront tab, "buy a gift card" actions) should be shown to the user. True only when the instance-wide giftCards feature flag is on AND at least one gift card - provider is enabled; when false every giftCard* field refuses.`, + provider is enabled; when false the catalog, quote, and purchase fields + refuse. Order history and delivered codes stay readable regardless.`, }, fygaroTopup: { type: FygaroTopupInfo, From 1b4c9791ab6dec4927505e53b97bbee54621a6fb Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 10 Sep 2026 00:02:05 -0700 Subject: [PATCH 04/14] docs(gift-cards): architecture, flows, API, config, alerting, runbook, 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 Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf --- docs/gift-cards/ALERTING.md | 110 ++++++++++++++ docs/gift-cards/API.md | 204 ++++++++++++++++++++++++++ docs/gift-cards/ARCHITECTURE.md | 224 ++++++++++++++++++++++++++++ docs/gift-cards/CONFIG.md | 124 ++++++++++++++++ docs/gift-cards/FLOWS.md | 189 ++++++++++++++++++++++++ docs/gift-cards/README.md | 45 ++++++ docs/gift-cards/RUNBOOK.md | 251 ++++++++++++++++++++++++++++++++ docs/gift-cards/TESTING.md | 113 ++++++++++++++ 8 files changed, 1260 insertions(+) create mode 100644 docs/gift-cards/ALERTING.md create mode 100644 docs/gift-cards/API.md create mode 100644 docs/gift-cards/ARCHITECTURE.md create mode 100644 docs/gift-cards/CONFIG.md create mode 100644 docs/gift-cards/FLOWS.md create mode 100644 docs/gift-cards/README.md create mode 100644 docs/gift-cards/RUNBOOK.md create mode 100644 docs/gift-cards/TESTING.md diff --git a/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md new file mode 100644 index 000000000..f2c7a649c --- /dev/null +++ b/docs/gift-cards/ALERTING.md @@ -0,0 +1,110 @@ +# Gift Card Alerting + +Two signals: the **ops-event feed** (Discord embeds, flow `giftcard`, +`src/services/alerts/ops-events.ts`) for the funnel and for humans, and +**tracing** (span attributes and recorded exceptions) for counting and paging. +The feed is fire-and-forget, does nothing when `OPS_DISCORD_WEBHOOK_URL` is +unset, and drops its oldest entries on overflow (50-deep queue), so alert +rules belong on traces, not on Discord. + +Nothing gift-card-related goes through the Bridge `AlertService` +(`docs/bridge-integration/ALERTING.md`); there is no PagerDuty wiring yet. +"Page" below means "should be wired to page". + +## Ops events + +Embed title is `🎁 Giftcard — `; colour follows +`status` (green success, amber pending, red failed). Every order-shaped event +(`notifyGiftCardOpsEvent`, `src/app/gift-cards/ops.ts`) carries `account`, +`amount` (display units, e.g. `25.00 USD`), `orderId`, `providerId`, +`productId`, `env`, plus the phase's own `meta`. Claim data is never in an +event by construction: the helper takes an order, not a claim. + +| Phase | Status | Emitted by | Meaning | Action | +| --- | --- | --- | --- | --- | +| `order-created` | pending | `purchaseGiftCard` | Row written, vendor not yet called | none | +| `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight | none; worker resolves. Warn if the same order is still pending after 60 min (see log line below) | +| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card | none | +| `order-fulfilled` | success | `settleOrderFromVendor` | Claim stored; push sent | none | +| `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment (`meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: `, `payment-failed`, `expired`, `vendor-failed: ...`) | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | +| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | +| `claim-encrypt-failed` | failed | `settleOrderFromVendor` | Card issued at the vendor; Flash could not encrypt the claim (key missing/invalid). Order stays `PAID` | **Page.** Fix `claimDataEncryptionKey`; the next poll retries | +| `vendor-fulfilled-unexpected` | failed | `settleOrderFromVendor` | Vendor says fulfilled but our order is terminal-unpaid (`meta.orderStatus`). Someone paid; our records say not us | **Page.** Reconcile by hand | +| `would-reject` | pending | `authorizeGiftCardPurchase` (log-only) | A limits check would have refused; `meta.reason`, `meta.level`, `meta.productId`, `error` | Review weekly before flipping to enforce (RUNBOOK f). `reason: limits-unavailable` must be zero | +| `rejected` | failed | `authorizeGiftCardPurchase` (enforce) | A limits check refused | Warn on rate; investigate `limits-unavailable` immediately | +| `catalog-synced` | success | `syncGiftCardCatalogForProvider` | `meta.products`, `meta.countries`, `meta.durationMs` | none. Absence for > `syncIntervalSeconds` x 2 is the signal | +| `catalog-sync-failed` | failed | `syncGiftCardCatalogForProvider` | Vendor pull or Redis write failed for one provider (`error`, `meta.providerId`) | Warn. Two consecutive failures: RUNBOOK (g) | +| `catalog-stale` | pending | `listGiftCardProducts` | A request was served a catalog older than `catalog.ttlSeconds`. Coalesced to one per provider per 10 min | Warn. The catalog will vanish at `staleAfterSeconds` | + +`would-reject` and `rejected` are not coalesced; they are bounded by the +caller's attempt budget. `catalog-stale` is coalesced +(`GIFT_CARD_STALE_EVENT_COALESCE_MS`). + +## What should page vs warn + +| Page (someone acts within the hour) | Warn (review same day) | +| --- | --- | +| `refund-required` (any) | `catalog-stale`, `catalog-sync-failed` | +| `claim-encrypt-failed`, `vendor-fulfilled-unexpected` | `order-failed` rate above baseline | +| TBC auth failure: span exception `BitcoinCompanyAuthError` (Critical) | vendor 5xx / network: span exceptions `BitcoinCompanyApiError` (>= 500) and `BitcoinCompanyNetworkError` (Warn) | +| `BitcoinCompanyResponseShapeError` (Critical): the vendor changed its API | An order in `PAID` longer than 15 min (RUNBOOK b) | +| `limits-unavailable` in enforce mode (`UnknownGiftCardError`, Critical) | An order in `PAYMENT_PENDING` longer than 60 min (log line below; RUNBOOK c) | +| `GiftCardOrderStateError` recorded from `purchaseGiftCard` after payment ("payment settled but PAID transition failed") | `would-reject` volume while in log-only | + +Suggested thresholds, to tune once there is traffic: + +- `refund-required`: >= 1 in 5 min pages. +- `BitcoinCompanyAuthError`: >= 1 pages (every purchase and poll fails until fixed). +- Vendor 5xx/network (`giftcard.provider = bitcoinCompany`, exception level Warn): > 5 in 5 min warns, > 20 in 5 min pages. +- `order-failed` with `meta.reason` starting `quote-mismatch`: > 3 in 15 min warns (tolerance too tight or vendor volatility). +- `catalog-synced` absent for 2 x `syncIntervalSeconds` (12 h at defaults) warns; `catalog-stale` firing at all warns. +- Reconcile summary `refundRequired > 0` or `scanned` at the 200 batch cap warns. + +## Log lines worth a rule + +All from `baseLogger` with `module: "gift-cards.reconcile"` unless noted: + +| Level | Message | Meaning | +| --- | --- | --- | +| warn | `Gift card payment has been pending for over an hour with no resolvable status` | PAYMENT_PENDING with no `providerPaymentRef` or an IBEX lookup that never resolves; carries `orderId`, `paymentHash`, `providerPaymentRef` | +| warn | `Expired gift card invoice still has an in-flight payment; not expiring` | IBEX says pending on an expired invoice; watch it | +| error | `Gift card reconcile failed for order` | One order threw; the batch continued | +| error | `Gift card payment settled but PAID transition failed` (purchase) | Money moved, bookkeeping lost a race; the worker will re-read IBEX | +| warn | `Bitcoin Company reported "" without claim data; holding as pending` (adapter) | Vendor said done with nothing to redeem; polling continues | +| warn | `Bitcoin Company returned unknown order status ""; holding as pending` | New vendor status; add it to `VENDOR_STATUS_TABLE` | + +## Span attributes + +Every attribute is a string unless noted. Namespaces: +`services.gift-cards.bitcoin-company` (adapter), `services.gift-cards.catalog-cache`, +`app.gift-cards` (`syncGiftCardCatalogs`, `listGiftCardProducts`, `getGiftCardProduct`); +the other use cases are wrapped by `src/app/index.ts` like every `@app` export, +and the jobs run under `cron.`. + +| Attribute | Set by | Values | +| --- | --- | --- | +| `giftcard.provider` | adapter, client, purchase, quote, settle | `bitcoinCompany` | +| `giftcard.op` | adapter, client | `listProducts`, `quote`, `createOrder`, `getOrder`, `login`, `refreshToken` | +| `giftcard.productId`, `giftcard.valueMinor` (number), `giftcard.quantity` (number) | purchase, quote | request args | +| `giftcard.orderId` | purchase, settle, get-order | our UUID | +| `giftcard.replay` | purchase | `true` when an existing order was returned for the key | +| `giftcard.quoteSats`, `giftcard.invoiceSats` (numbers) | purchase | the tolerance comparison inputs | +| `giftcard.providerPaymentRef` | purchase | IBEX `transaction.id`, or `""` | +| `giftcard.status` | settle, get-order | order status before the settle / at read | +| `giftcard.vendorStatus` | settle | `awaitingPayment`, `paidPendingFulfillment`, `fulfilled`, `failed`, `refunded` | +| `giftcard.limits.mode`, `giftcard.limits.reason`, `giftcard.limits.error` | authorize (on any would-reject/reject) | mode; `level-not-eligible`, `account-too-new`, `per-card-cap`, `daily-cap`, `vendor-daily-cap`, `vendor-card-cap`, `velocity`, `open-loop-not-allowed`, `limits-unavailable`; error class name | +| `giftcard.country.source`, `giftcard.country.candidates` (number) | master gate | phone-country resolution | +| `giftcard.reconcile.skipped` | reconcile | `lock-held` | +| `giftcard.reconcile.scanned/fulfilled/refundRequired/expired/paymentSettled` (numbers) | reconcile | per-run summary | + +Count limits outcomes from `giftcard.limits.reason`, not from Discord, for the +same reason `docs/send-guard.md` counts from `sendGuard.rejection`. + +Recorded exceptions (`recordExceptionInCurrentSpan`) and their levels: +`BitcoinCompanyApiError` Warn, `BitcoinCompanyNetworkError` Warn, +`BitcoinCompanyUnauthorizedError` Warn, `BitcoinCompanyResponseShapeError` +Critical, `BitcoinCompanyAuthError` Critical, unexpected client throw +Critical; settle-order records `GiftCardOrderStateError` (vendor fulfilled on a +terminal order) and `GiftCardClaimCryptoError` (Critical); purchase records the +INVOICE_ISSUED and PAID transition failures; reconcile records every per-order +throw. diff --git a/docs/gift-cards/API.md b/docs/gift-cards/API.md new file mode 100644 index 000000000..023fed53a --- /dev/null +++ b/docs/gift-cards/API.md @@ -0,0 +1,204 @@ +# Gift Cards GraphQL API Reference + +All fields require an authenticated session (`GraphQLPublicContextAuth`). +`giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` open with +`gateGiftCardsForAccount` (`src/graphql/public/root/gift-card-gate.ts`): +queries throw the mapped error, the mutation returns it in `errors`. +`giftCardOrder` and `giftCardOrders` are **not** gated: they are owner-scoped +reads of orders the customer already paid for, and switching the rail off must +never hide codes they own. Ownership is enforced in the app layer +(`getGiftCardOrderForAccount` answers not-found for a non-owner). Money on +products, quotes and orders is in +**minor units** of the product currency (`CentAmount`); what the customer pays +is in sats (`SatAmount`). + +Resolvers on disk at the time of writing: +`src/graphql/public/root/query/gift-card-catalog.ts`, `gift-card-quote.ts`, +`gift-card-order.ts`, `gift-card-orders.ts`, +`src/graphql/public/root/mutation/gift-card-purchase.ts`, wired in +`src/graphql/public/queries.ts` and `mutations.ts`. + +## `globals.giftCardsEnabled` + +`Boolean!` on `Globals` (`src/graphql/public/types/object/globals.ts`, +resolved in `root/query/globals.ts`). True only when `giftCards.enabled` is on +**and** at least one provider is enabled. When false, hide every gift card +entry point: `giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` refuse. +`giftCardOrder` and `giftCardOrders` keep resolving so a customer can always +reach a card they already bought. + +## Queries + +### `giftCardCatalog` + +```graphql +query GiftCardCatalog($countryCode: CountryCode, $first: Int, $after: String) { + giftCardCatalog(countryCode: $countryCode, category: null, search: null, first: $first, after: $after) { + edges { + cursor + node { + id name brand countryCode currency + denominationType # FIXED | VARIABLE + denominations minValue maxValue + isOpenLoop categories logoUrl termsUrl rewardBps + } + } + pageInfo { hasNextPage endCursor } + } +} +``` + +- `countryCode` defaults to the calling account's country, which is also what + the purchase routes by. Browsing another country's catalog is allowed; buying + from it is refused with `GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY`. +- In stock only; open-loop cards only when `giftCards.allowOpenLoop` is on. + Ordered by brand, name, id. `first` 1..200, default 50. An unknown `after` + restarts from the top (repeat possible, never a gap). +- Served from Redis. A stale catalog is still served; a missing one throws + `GIFT_CARD_CATALOG_UNAVAILABLE`. + +### `giftCardQuote` + +```graphql +query GiftCardQuote($productId: ID!, $value: CentAmount!, $quantity: Int) { + giftCardQuote(productId: $productId, value: $value, quantity: $quantity) { + productId value currency quantity + fiatCost # vendor charge for the whole order, minor units + satsCost # what leaves the wallet; show this as the price + rewardSats + expiresAt + } +} +``` + +Refuses exactly what the purchase would refuse (same first four steps, same +errors), so the client can surface problems before the confirm screen. Not a +reservation: nothing is held, no limit is consumed. Quotes are good for ~60 s +(`QUOTE_TTL_MS`); the purchase re-quotes regardless. + +### `giftCardOrder` + +```graphql +query GiftCardOrder($id: ID!) { + giftCardOrder(id: $id) { + id status product { name brand countryCode currency isOpenLoop logoUrl } + value currency quantity paidSats createdAt fulfilledAt failureReason + claim { codes { label value } claimLink barcode { chars type } } + } +} +``` + +The endpoint to poll after a purchase and the **only** place `claim` is +returned. Returns `null` for an unknown id **and** for another account's +order; the two are deliberately indistinguishable +(`getGiftCardOrderForAccount`). Not behind the master gate (see the top of +this page). + +`failureReason` is a short machine-oriented string for support and +diagnostics (`vendor-create-failed: GiftCardVendorRejectedOrderError`, +`quote-mismatch: quoted 41234 invoiced 42000`, `expired`, +`fulfillment-timeout`, ...). It is **not** customer copy; choose the wording +from `status`. + +**Claim rule.** `claim` is non-null only when all of the following hold: the +caller owns the order, `status` is `FULFILLED`, and the ciphertext decrypts +under the currently configured key. This is enforced twice: the app layer +decrypts only for the owner once fulfilled, and `toGiftCardOrderSource` drops +any claim unless `status === FULFILLED`. A `FULFILLED` order whose claim cannot +be decrypted throws `GIFT_CARD_CLAIM_UNAVAILABLE` rather than returning an +empty claim. + +### `giftCardOrders` + +```graphql +query GiftCardOrders($first: Int, $after: String) { + giftCardOrders(first: $first, after: $after) { + edges { cursor node { id status product { brand } value currency quantity createdAt } } + pageInfo { hasNextPage endCursor } + } +} +``` + +Newest first, keyset-paged on `createdAt` (cursor = base64 ISO timestamp). +`first` 1..100, default 20. `claim` is always null in a listing. Not behind +the master gate. + +## Mutation + +### `giftCardPurchase` + +```graphql +mutation GiftCardPurchase($input: GiftCardPurchaseInput!) { + giftCardPurchase(input: $input) { + errors { message code } + order { + id status paidSats failureReason + claim { codes { label value } claimLink barcode { chars type } } + } + } +} +``` + +`GiftCardPurchaseInput`: `productId: ID!`, `value: CentAmount!` (one card), +`quantity: Int` (1..10, default 1), `walletId: WalletId!` (must belong to the +caller), `idempotencyKey: String!` (8 to 64 chars, no whitespace; a UUID is +ideal). + +Behaviour: + +- Re-prices at order time and refuses to pay if the vendor invoice drifted past + `quoteToleranceBps` from the quote. No money moves on any refusal. +- Money moves at most once per `idempotencyKey`. Retry a timed-out call with + the **same** key: same key + same parameters returns the existing order in + whatever state it reached; same key + different parameters is refused. +- `order` is returned in whatever state the purchase reached: often already + `FULFILLED` with `claim` attached, otherwise `PAID` or `PAYMENT_PENDING` + (poll `giftCardOrder`), or `FAILED` / `PAYMENT_FAILED` (see + `failureReason`). `order` is absent when refused before a row existed. It + can be present **alongside** an error when the card was issued but the + claim could not be read. +- Attempt budget: 10 per minute per account, 5-minute block + (`RateLimitConfig.giftCardPurchase`), charged before any vendor call. + +### `GiftCardOrderStatus` + +| Value | Meaning for the client | +| --- | --- | +| `CREATED`, `INVOICE_ISSUED`, `PAYMENT_PENDING`, `PAID` | Transient. Keep polling. Never purchase again. `PAYMENT_PENDING` can take minutes | +| `FULFILLED` | Terminal. `claim` and `fulfilledAt` are set | +| `FAILED` | Terminal, nothing paid: vendor rejected, price moved past tolerance, or unreadable invoice. Safe to retry with a new key | +| `PAYMENT_FAILED` | Terminal, nothing paid: the Lightning payment failed. Safe to retry with a new key | +| `EXPIRED` | Terminal, nothing paid: invoice not paid in time | +| `REFUND_REQUIRED` | Terminal. The wallet paid, no card arrived; Flash has been paged. Do **not** tell the customer to buy again | + +## Error codes + +From `src/graphql/error-map.ts`. On queries they arrive as a GraphQL error with +`extensions.code`; on the mutation as `errors[].code`. + +| Code | Fires when | Client should | +| --- | --- | --- | +| `GIFT_CARDS_DISABLED` | `giftCards.enabled` is off, or an open-loop card while `allowOpenLoop` is off | Hide the feature; `globals.giftCardsEnabled` should already be false | +| `GIFT_CARD_PROVIDER_UNAVAILABLE` | No enabled provider routed for the account's country | "Not available in your region"; deterministic until config changes | +| `GIFT_CARD_PRODUCT_NOT_FOUND` | Malformed id, unknown provider prefix, cache miss, out of stock, or an open-loop card hidden by config | Refresh the catalog | +| `GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY` | Product's provider is not the one routed for the account | Show the account-country catalog | +| `GIFT_CARD_INVALID_VALUE` | Value not a listed denomination / outside min..max, or quantity not 1..10 | Fix the input from the product's denomination fields | +| `GIFT_CARD_LIMIT_EXCEEDED` | Limits `enforce` mode: account too new, per-card, daily, vendor cap, or velocity | Show the message; it names the cap | +| `GIFT_CARD_LEVEL_NOT_ELIGIBLE` | Level 0, below `minAccountLevel`, or level < 2 for open-loop (enforce mode) | Route to the account upgrade flow, not a retry | +| `GIFT_CARD_QUOTE_MISMATCH` | Vendor invoice exceeded the quote by more than tolerance; order is `FAILED`, nothing paid | Re-quote and retry with a new key | +| `GIFT_CARD_ORDER_NOT_FOUND` | Not thrown by `giftCardOrder` (returns null); can surface from internal reads | Treat as not found | +| `GIFT_CARD_ORDER_STATE` | An illegal transition was attempted (e.g. INVOICE_ISSUED write lost a race) | Poll `giftCardOrder`; do not retry the purchase | +| `GIFT_CARD_VENDOR_REJECTED` | Vendor returned a definitive 4xx on order creation, or an unreadable invoice; `FAILED`, nothing paid | Show message; retry later with a new key | +| `GIFT_CARD_VENDOR_UNAVAILABLE` | Vendor unreachable, 5xx, auth failure, or unexpected response shape | "Temporarily unavailable"; retry later | +| `GIFT_CARD_CATALOG_UNAVAILABLE` | Catalog key missing from Redis or Redis unreadable | Retry later; the sync job repopulates | +| `GIFT_CARD_CLAIM_UNAVAILABLE` | Claim ciphertext could not be decrypted (key missing, rotated, corrupt) | Show "contact support"; the order is still `FULFILLED` | +| `GIFT_CARD_IDEMPOTENCY_KEY_REUSE` | Same key, different product/value/quantity | Generate a new key for a new purchase | +| `GIFT_CARD_PURCHASE_RATE_LIMITED` | More than 10 purchase attempts in a minute | Back off 5 minutes | +| `GIFT_CARD_UNKNOWN` | Limits store unreadable (`limits-unavailable` in enforce mode) or an adapter threw | Retry later; this pages on the Flash side | + +Errors that are **not** `GIFT_CARD_*` can also come back from the mutation: +scalar validation (`CentAmount`, `WalletId`) and a bad `idempotencyKey` length +return a plain `errors[].message` with no code; a wallet that does not belong +to the caller is a generic validation error; and the payment step passes +through the send guard's and IBEX's own errors (e.g. rate limit or limit +errors from `authorizeSend`, `IbexError` subclasses) with their existing codes. diff --git a/docs/gift-cards/ARCHITECTURE.md b/docs/gift-cards/ARCHITECTURE.md new file mode 100644 index 000000000..53f0c36fb --- /dev/null +++ b/docs/gift-cards/ARCHITECTURE.md @@ -0,0 +1,224 @@ +# Gift Cards Architecture + +## System overview + +```ascii ++-------------+ GraphQL +----------------+ REST (bearer) +----------------+ +| Mobile App | <----------> | Flash Backend | <---------------> | Gift card | ++-------------+ | (api pod) | | vendor (TBC) | + +----------------+ +----------------+ + | | ^ ^ + Mongo | | | Redis (catalog, locks, | bolt11 + (orders) v v | holds, auth tokens) | paid by IBEX + +-----+ +-----+ +----------------+ + |Mongo| |Redis| | IBEX | + +-----+ +-----+ +----------------+ + ^ ^ + | cron (15m) + trigger interval (30s) | + +----------------------------------------------+ + | reconcile worker / catalog sync | + +----------------------------------------------+ +``` + +Flash holds no gift card inventory and no balances of its own: user funds sit +at IBEX, so a purchase is a Lightning payment from the user's IBEX account to +the vendor's invoice. Flash's job is ordering, paying exactly once, and +delivering the claim. + +## Layers and files + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Domain | `src/domain/gift-cards/index.ts`, `index.types.d.ts`, `primitives.ts`, `errors.ts` | Status enum + transition table, product/quote/order/claim types, the `IGiftCardProvider` port, product-id codec, value/quantity validation, every `GiftCard*` error | +| Registry | `src/services/gift-cards/registry.ts`, `index.ts` | Adapters self-register at import; config-driven routing (`routing.byCountry[cc] ?? routing.default`, gated by `providers..enabled`) | +| Adapter (TBC) | `src/services/gift-cards/bitcoin-company/{index,client,mapping,schemas,errors}.ts` | HTTP transport, auth token lifecycle in Redis, retries (idempotent reads only), zod validation, vendor-to-domain mapping. Nothing vendor-shaped leaves this directory | +| Catalog cache | `src/services/gift-cards/catalog-cache.ts` | Redis read model of the catalog; sync job writes, every request reads | +| Claim crypto | `src/services/gift-cards/claim-crypto.ts` | AES-256-GCM at rest for claim data; `claimKeyId` derivation | +| Use cases | `src/app/gift-cards/` | `purchase-gift-card.ts`, `quote-gift-card.ts`, `authorize-purchase.ts` (limits), `reservation-store.ts` (daily-cap holds), `settle-order.ts` (the single settlement path), `reconcile-orders.ts` (worker), `sync-catalog.ts`, `list-products.ts`, `get-order.ts`, `list-orders.ts`, `gift-cards-master-gate.ts`, `ops.ts`, `send-fulfilled-notification.ts` | +| Payment rail | `src/app/payments/pay-invoice-via-ibex.ts` (+ `idempotency.ts`, `authorize-send.ts`) | Pays a bolt11 from the wallet's IBEX account under `withPaymentIdempotency` with the ENG-573 send guard | +| Persistence | `src/services/mongoose/gift-card-orders.ts`, `gift-card-orders.mapping.ts`, `schema.ts` (`GiftCardOrderSchema`), `src/migrations/20260909120000-gift-card-orders-indexes.ts` | `giftcardorders` collection; every status move is one conditional `findOneAndUpdate` | +| GraphQL | `src/graphql/public/root/gift-card-gate.ts`, `root/query/gift-card-{catalog,quote,order,orders}.ts`, `root/mutation/gift-card-purchase.ts`, `types/object/gift-card-{product,quote,order}.ts`, `types/input/gift-card-purchase-input.ts`, `types/payload/gift-card-purchase.ts` | Public contract; see [API.md](API.md) | +| Jobs | `src/servers/cron.ts` (`syncGiftCardCatalogsJob`, `reconcileGiftCardOrdersJob`), `src/servers/trigger.ts` (`startGiftCardReconcileInterval`) | Catalog refresh and the fulfilment safety net | +| Ops events | `src/app/gift-cards/ops.ts`, `src/services/alerts/ops-events.ts` (flow `giftcard`) | Discord funnel; see [ALERTING.md](ALERTING.md) | + +Error names all carry the `GiftCard` prefix because `error-map.ts` switches on +the constructor name across every error module; a collision would be a silent +mis-mapping (`src/domain/gift-cards/errors.ts`). + +## The provider port + +`IGiftCardProvider` (`src/domain/gift-cards/index.types.d.ts`) is pure vendor +I/O. Flash policy (fees, limits, open-loop gating, tolerance) never lives in an +adapter. + +| Method | Called by | Notes | +| --- | --- | --- | +| `listProducts()` | `sync-catalog.ts` only | Never on a request path | +| `quote({ product, valueMinor, quantity })` | `quoteGiftCard`, `purchaseGiftCard` | Vendor price in sats, `expiresAt` | +| `createOrder({ product, valueMinor, quantity, reference })` | `purchaseGiftCard` | Returns `providerOrderId`, `paymentRequest` (bolt11), `amountSats`, `expiresAt`. `reference` is our order id, sent as the vendor label. Never retried | +| `getOrder({ providerOrderId, paymentRequest })` | `fetchAndSettle` | Returns `awaitingPayment`, `paidPendingFulfillment`, `fulfilled` (+claim), `failed`, `refunded` | + +Provider ids are `bitcoinCompany` and `bitrefill` (`GIFT_CARD_PROVIDER_IDS`). +**Only `bitcoinCompany` has an adapter.** Routing a country to `bitrefill` +yields `GiftCardProviderUnavailableError` because nothing is registered under +that id, whatever `providers.bitrefill.enabled` says. + +TBC specifics (`bitcoin-company/mapping.ts`): vendor money is in major units, +the domain is minor units; a `fulfilled` status without claim data is held as +`paidPendingFulfillment` with a warning, never reported fulfilled; unknown +vendor statuses are also held as pending; order status is looked up by +**invoice**, not order id, so `getOrder` refuses a ref with no `paymentRequest`. +Quote TTL (60s) and order TTL (15m) are assumed by the adapter, not returned by +the vendor. + +## Routing and the master gate + +`giftCardsMasterGate(countryCode)` (`src/app/gift-cards/gift-cards-master-gate.ts`) +checks `giftCards.enabled`, then resolves a provider for the country and +requires it to be enabled. `giftCardCatalog`, `giftCardQuote`, +`giftCardPurchase` and the quote/purchase use cases open with it, so the +catalog can never show what the purchase would refuse. The order reads +(`giftCardOrder`, `giftCardOrders`) are deliberately **not** gated: they are +owner-scoped (`getGiftCardOrderForAccount` answers not-found for a non-owner) +and a customer must be able to reach a card they already paid for while the +rail is off. + +The account's country comes from the phone number only (`resolvePhoneCountries` +in `src/app/bridge/kyc-gate.ts`); Bridge KYC stores no country. An ambiguous +calling code (NANP) resolves to the sentinel `"XX"`, which can never match a +`routing.byCountry` entry and so always takes `routing.default`. + +## Order state machine + +Copied from `GIFT_CARD_TRANSITIONS` (`src/domain/gift-cards/index.ts`): + +``` +CREATED -> INVOICE_ISSUED | FAILED | EXPIRED +INVOICE_ISSUED -> PAYMENT_PENDING | PAID | PAYMENT_FAILED | EXPIRED | FAILED +PAYMENT_PENDING -> PAID | PAYMENT_FAILED +PAID -> FULFILLED | REFUND_REQUIRED +FULFILLED -> (terminal) +FAILED -> (terminal) +PAYMENT_FAILED -> (terminal) +EXPIRED -> (terminal) +REFUND_REQUIRED -> (terminal) +``` + +`REFUND_REQUIRED` is the only state meaning "money left Flash and no card +arrived". `FAILED`, `PAYMENT_FAILED` and `EXPIRED` all mean nothing was paid. +The repository refuses an illegal move before touching Mongo, and the write is +`findOneAndUpdate({ id, status: { $in: from } })`, so of two racing settlers +exactly one wins and the loser gets `GiftCardOrderStateError`. + +Who moves what: + +| Transition | Where | +| --- | --- | +| `CREATED -> INVOICE_ISSUED` | `purchaseGiftCard`, after `provider.createOrder` and the tolerance check | +| `CREATED/INVOICE_ISSUED -> FAILED` | `purchaseGiftCard` (`failUnpaidOrder`), `settleOrderFromVendor` on vendor `failed`/`refunded` before payment | +| `INVOICE_ISSUED -> PAID / PAYMENT_PENDING / PAYMENT_FAILED` | `purchaseGiftCard` from the `PaymentSendStatus` | +| `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX | +| `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment | +| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`, after one payment re-read | +| `PAID -> FULFILLED` | `settleOrderFromVendor` (`fulfil`) with the encrypted claim | +| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24h in PAID (`fulfillment-timeout`) | + +## Decisive facts + +**Balances live at IBEX.** The purchase pays with `payLnInvoiceViaIbex` +(`src/app/payments/pay-invoice-via-ibex.ts`), the IBEX-custodial rail that +`lnInvoicePaymentSend` uses. It does **not** use `payInvoiceByWalletId` in +`send-lightning.ts`, which is the upstream-galoy LND rail and is dormant on +this deployment. On this rail the IBEX account id is the Flash wallet id. + +**Order first, vendor second, payment third.** `purchaseGiftCard` writes the +`CREATED` row before calling `provider.createOrder`, and calls the vendor +before paying. A crash at any point leaves a row the reconcile worker can +reason about; a vendor order with no Flash row cannot exist. + +**Money moves at most once per order.** Two layers: + +- The order row is unique on `(walletId, idempotencyKey)`; a replay with the + same key and parameters returns the existing order without reaching the + vendor, and the same key with different parameters is + `GiftCardIdempotencyKeyReuseError`. +- The Lightning send runs under `withPaymentIdempotency` with key + `giftcard:` and fingerprint `ln||giftcard|`, + so a different order can never replay a previous success. The cached result + lives at `payment-idempotency::giftcard:` + (`src/app/payments/idempotency.ts`). + +**Quote tolerance.** The invoice Flash is about to pay is decoded and +`max(decodedSats, vendorStatedSats)` must be `<= quoteSats * (1 + toleranceBps/10000)`. +`GiftCardsConfig.quoteToleranceBps` wins over the domain constant +`GIFT_CARD_QUOTE_TOLERANCE_BPS` (both default 100). Over tolerance: `FAILED`, +nothing paid. + +**Claim data is ciphertext at rest.** `encryptGiftCardClaim` produces +AES-256-GCM, base64 of `version(0x01) | iv(12) | tag(16) | data`, with a fresh +iv per call. `claimKeyId` (first 16 hex chars of sha256 of the raw key bytes) +is stored next to each ciphertext; a mismatch on read is reported as +`GiftCardClaimCryptoError`, never guessed around. Plaintext exists only inside +`settle-order.ts` (in) and `get-order.ts` (out); it is never logged, traced, +or put in an ops event, `toJSON` on the schema drops `claimCiphertext`, and the +GraphQL source object (`toGiftCardOrderSource`) has no ciphertext field at all. + +**Catalog is served from Redis only.** No request path calls +`listProducts`. Between `catalog.ttlSeconds` and `catalog.staleAfterSeconds` +the catalog is served with `stale: true`; past `staleAfterSeconds` Redis drops +the key and reads become `GiftCardCatalogUnavailableError`. + +**`feeBps` is not applied anywhere.** The config key exists and defaults to 0; +no code path reads it. Revenue today is the vendor reward only. + +## Redis key inventory + +| Key | Value | TTL | Writer | +| --- | --- | --- | --- | +| `giftcards:catalog::` | `{ syncedAt, products[] }` | `catalog.staleAfterSeconds` | `GiftCardCatalogCache.write` | +| `giftcards:catalog::countries` | `string[]` (written last) | `catalog.staleAfterSeconds` | same | +| `giftcards:product:` | `GiftCardProduct` | `catalog.staleAfterSeconds` | same | +| `giftcards:catalog-sync:lock` | token | 10 min, `NX` | `syncGiftCardCatalogs` | +| `giftcards:catalog-sync:last-run` | ISO timestamp | `catalog.syncIntervalSeconds`, `NX` | same (throttle marker) | +| `giftcards:reconcile:lock` | token | 5 min, `NX` | `reconcileGiftCardOrders` | +| `giftcards:auth:bitcoinCompany` | `{ accessToken, refreshToken, accessExpiresAt }` | 21 days | TBC client (`RedisTokenStore`) | +| `giftcards:auth:bitcoinCompany:lock` | nonce | 10 s, `NX` | same | +| `giftcards:reservation::` | amount (minor) | 24 h | `reservation-store.ts` | +| `giftcards:reservations:` | zset, score = expiry ms, member `:` | pruned by score on read | same | +| `payment-idempotency::giftcard:` | cached send result | see `idempotency.ts` | `withPaymentIdempotency` | +| rate-limiter bucket, prefix `gift_card_purchase` | per `accountId` | 60 s window, 300 s block | `consumeLimiter` (`RateLimitConfig.giftCardPurchase`) | + +Every module that touches Redis imports `@services/redis` lazily so unit tests +never open a connection. + +## Mongo: `giftcardorders` + +One document per purchase attempt; fields as in `GiftCardOrder` +(`index.types.d.ts`). Indexes, identical in `GiftCardOrderSchema` and the +migration so `syncIndexes()` on boot has nothing to rebuild: + +| Name | Keys | Options | Purpose | +| --- | --- | --- | --- | +| `id_1` | `{ id: 1 }` | unique | Our UUID, the only id the API shows | +| `accountId_1_createdAt_-1` | `{ accountId: 1, createdAt: -1 }` | | Account history, keyset paging | +| `providerId_1_providerOrderId_1` | `{ providerId: 1, providerOrderId: 1 }` | unique, partial `{ providerOrderId: { $type: "string" } }` | One vendor order maps to one Flash order; partial (not sparse) so two orders still awaiting a vendor id do not collide | +| `status_1_updatedAt_1` | `{ status: 1, updatedAt: 1 }` | | Reconciler sweep, stalest first | +| `walletId_1_idempotencyKey_1` | `{ walletId: 1, idempotencyKey: 1 }` | unique | Client retry / concurrent same-key purchase yields one order | + +The E11000 on `walletId_1_idempotencyKey_1` is mapped to +`GiftCardOrderDuplicateKeyError`; any other duplicate stays a generic +`DuplicateKeyForPersistError` because it is a bug. + +## Jobs + +| Job | Where | Cadence | Guard | +| --- | --- | --- | --- | +| Catalog sync | `cron.ts` `syncGiftCardCatalogsJob` | every cron run, throttled to `catalog.syncIntervalSeconds` by the Redis marker | `giftCards.enabled`; distributed lock | +| Reconcile | `trigger.ts` `startGiftCardReconcileInterval` (30 s) and `cron.ts` `reconcileGiftCardOrdersJob` | 30 s in the trigger pod; cron as safety net | `giftCards.enabled`; shared Redis lock | + +Reconcile PAID backoff: 5s, 15s, 60s, 5m, then every 15m from the PAID +transition. Attempt timing is process memory, so the cron (a fresh process) +polls each PAID order once per run and the trigger interval carries the real +schedule. Stuck-order horizons: PAID for 24h becomes `REFUND_REQUIRED`; +PAYMENT_PENDING with no resolvable IBEX status logs a warning after 60 min and +stays put. diff --git a/docs/gift-cards/CONFIG.md b/docs/gift-cards/CONFIG.md new file mode 100644 index 000000000..a9bff1f24 --- /dev/null +++ b/docs/gift-cards/CONFIG.md @@ -0,0 +1,124 @@ +# Gift Cards Configuration + +The `giftCards` block is defined in `src/config/schema.ts` (types in +`src/config/schema.types.d.ts`), defaulted in `dev/config/base-config.yaml`, +and read through `GiftCardsConfig` (`src/config/yaml.ts`). Like the rest of +`yamlConfig` it is read **once at process start** from `--configPath`; a change +needs the api, trigger and cron pods replaced (same discipline as +`docs/send-guard.md`, "Rolling back"). + +Local overrides go in `$CONFIG_PATH/dev-overrides.yaml` +(`dev/config/set-overrides.sh`), never in `base-config.yaml`. + +## Keys + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | Master switch. Off: `giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` return `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, both jobs are no-ops. `giftCardOrder` / `giftCardOrders` still resolve (owner-scoped reads of paid orders) | +| `allowOpenLoop` | boolean | `false` | Show and sell open-loop (Visa/Mastercard-style) cards. Off: hidden from the catalog, `GIFT_CARD_PRODUCT_NOT_FOUND` by id, `GIFT_CARDS_DISABLED` on purchase. On: still requires account level >= 2 | +| `feeBps` | integer | `0` | Flash markup on face value, basis points. **Not read by any code path today** (reserved) | +| `claimDataEncryptionKey` | string | `""` | 32-byte AES-256-GCM key: 64 hex chars, or base64 of exactly 32 bytes. Empty key: fulfilment leaves orders `PAID` and pages (`claim-encrypt-failed`); reads throw `GIFT_CARD_CLAIM_UNAVAILABLE` | +| `quoteToleranceBps` | integer | `100` | Max the vendor invoice may exceed the quote (1% default) before the purchase is refused with `GIFT_CARD_QUOTE_MISMATCH`. Overrides the domain constant `GIFT_CARD_QUOTE_TOLERANCE_BPS` | +| `routing.default` | `bitcoinCompany` \| `bitrefill` | `bitcoinCompany` | Provider for any country not listed in `byCountry`, including unknown (`"XX"`) | +| `routing.byCountry` | map CC -> provider id | `{}` | ISO 3166-1 alpha-2 (upper case; lookups are normalised) -> provider id | +| `providers.bitcoinCompany.enabled` | boolean | `false` | Provider switch. A country routed to a disabled provider gets `GIFT_CARD_PROVIDER_UNAVAILABLE` | +| `providers.bitcoinCompany.baseUrl` | string | `https://api.dev.thebitcoincompany.com` | Sandbox (Mutinynet). Production is `https://api.thebitcoincompany.com` | +| `providers.bitcoinCompany.email` / `password` | string | `""` | Login credentials for `POST /auth/login`. Empty: every authenticated call fails with `GIFT_CARD_VENDOR_UNAVAILABLE` (`BitcoinCompanyAuthError`) | +| `providers.bitcoinCompany.referralCode` | string | `""` | **Not read by the client today** | +| `providers.bitcoinCompany.timeoutMs` | integer | `10000` | Per-request axios timeout | +| `providers.bitrefill.*` | | disabled, empty | Schema only. **No Bitrefill adapter exists**; routing to it is always unavailable | +| `catalog.syncIntervalSeconds` | integer | `21600` (6 h) | Minimum gap between catalog pulls, enforced by the `giftcards:catalog-sync:last-run` marker | +| `catalog.ttlSeconds` | integer | `21600` (6 h) | Age past which a catalog is served `stale: true` and a `catalog-stale` ops event posts | +| `catalog.staleAfterSeconds` | integer | `86400` (24 h) | Redis TTL on every catalog key. Past this the catalog is gone and reads throw `GIFT_CARD_CATALOG_UNAVAILABLE`. Must be > `ttlSeconds` and > `syncIntervalSeconds` | +| `limits.mode` | `off` \| `log-only` \| `enforce` | `log-only` | See "Limits modes" | +| `limits.minAccountLevel` | integer | `1` | Level floor. Level 0 is refused regardless | +| `limits.minAccountAgeHours` | integer | `24` | New-account cooldown | +| `limits.maxOrdersPerHour` | integer | `5` | Velocity: orders created in the trailing hour, any status | +| `limits.vendorDailyCapCents` | integer | `1000000` ($10,000) | TBC per-user daily cap (FinCEN prepaid-access exemption). Applied on top of the level cap | +| `limits.vendorOpenLoopCardCapCents` | integer | `100000` ($1,000) | TBC per-order cap for open-loop cards | +| `limits.vendorClosedLoopCardCapCents` | integer | `200000` ($2,000) | TBC per-order cap for closed-loop cards | +| `limits.perLevel.level{0..3}.perCardCents` | integer | 0 / 20000 / 50000 / 100000 | Flash per-order cap by account level (value x quantity) | +| `limits.perLevel.level{0..3}.dailyCents` | integer | 0 / 50000 / 200000 / 500000 | Flash trailing-24h cap by level, summed over non-failed orders plus live holds | + +A level the config does not name (a future level 4) inherits `level3` +(`levelLimits` in `src/app/gift-cards/authorize-purchase.ts`). + +Every sub-object is `additionalProperties: false`; a typo in an override fails +config validation at boot rather than being ignored. + +## Environment variables + +| Var | Used for | +| --- | --- | +| `OPS_DISCORD_WEBHOOK_URL` | The `giftcard` ops-event feed (`src/config/env.ts`). Unset: events are dropped silently | +| `MONGODB_CON`, `REDIS_*` | Orders and the catalog/locks/holds, same as the rest of the service | + +There is no gift-card-specific env var; everything else is YAML. + +## Per-environment guidance + +**Never commit real credentials or a real claim key.** `base-config.yaml` ships +`email`, `password`, `referralCode` and `claimDataEncryptionKey` empty on +purpose; put real values in the deployment's values file / secret overrides +and in `$CONFIG_PATH/dev-overrides.yaml` locally. + +| | Sandbox / staging | Production | +| --- | --- | --- | +| `providers.bitcoinCompany.baseUrl` | `https://api.dev.thebitcoincompany.com` (Mutinynet; pays with test sats) | `https://api.thebitcoincompany.com` | +| Credentials | TBC sandbox account | TBC production reseller account | +| `claimDataEncryptionKey` | Any 32-byte key; may differ from prod | Generated once, stored as a secret, backed up: losing it makes every stored claim unreadable | +| `limits.mode` | `log-only` or `enforce` | `log-only` until the would-reject review (RUNBOOK f), then `enforce` | +| `enabled` / `providers.*.enabled` | `true` when testing | Flip together; see RUNBOOK (d) | + +Generating a key: + +```sh +openssl rand -hex 32 # 64 hex chars +# or +openssl rand -base64 32 # 44 chars, decodes to 32 bytes +``` + +Both formats are accepted; surrounding whitespace from an override is +tolerated. The key id stored on orders is derived from the raw bytes, so the +same key in either encoding yields the same `claimKeyId`. + +## How routing works + +`resolveGiftCardProviderIdForCountry(cc)` (`src/services/gift-cards/registry.ts`): + +1. Normalise `cc` (trim, upper case). +2. `routing.byCountry[cc]`, else `routing.default`. +3. The result must be enabled (`enabled && providers..enabled`) **and** + registered by an adapter; otherwise `GiftCardProviderUnavailableError`. + +The account's country is its phone country when that is unambiguous +(`resolveAccountCountryCode`); otherwise the sentinel `"XX"`, which is +user-assigned in ISO 3166 and can never appear in `byCountry`, so unknown +countries always take `routing.default`. `giftCardCatalog(countryCode:)` lets +the client browse another country, but the purchase always routes by the +account's country. + +Because only `bitcoinCompany` is registered, the only useful `byCountry` +entries today are none; the map exists so a second provider is config, not +code (`{ JM: bitrefill }` once that adapter lands). + +## Limits modes + +`limits.mode` mirrors `sendGuard.mode` (`docs/send-guard.md`): + +| Mode | Checks run | On failure | `reservationId` | +| --- | --- | --- | --- | +| `off` | none | n/a | `null` (nothing held) | +| `log-only` | all | `giftcard / would-reject` ops event (status pending) + span attributes, then **allowed** | set, or `null` if the hold write failed (logged, allowed) | +| `enforce` | all | `giftcard / rejected` ops event (status failed), purchase refused with the check's error | set; a hold write failure refuses with `GIFT_CARD_UNKNOWN` | + +Checks, in order (`evaluate` in `authorize-purchase.ts`): level, account age, +open-loop switch and level >= 2, per-card cap = `min(perLevel, vendor card cap)`, +velocity, daily cap = `min(perLevel.dailyCents, vendorDailyCapCents)` over +non-failed orders in the trailing 24 h plus live Redis holds. `REFUND_REQUIRED` +orders count toward the daily sum (money left); `FAILED`, `PAYMENT_FAILED`, +`EXPIRED` do not. A Mongo or Redis fault is `limits-unavailable`: allowed in +`log-only`, refused (`GIFT_CARD_UNKNOWN`, level Critical) in `enforce`. + +Independent of mode: the 10/min purchase attempt limiter and the ENG-573 send +guard on the payment itself always run. diff --git a/docs/gift-cards/FLOWS.md b/docs/gift-cards/FLOWS.md new file mode 100644 index 000000000..aa1e48001 --- /dev/null +++ b/docs/gift-cards/FLOWS.md @@ -0,0 +1,189 @@ +# Gift Card Flows + +Function names are the real ones. `App` is the mobile client, `API` the +resolver in `src/graphql/public/root/mutation/gift-card-purchase.ts`, `UC` the +use case in `src/app/gift-cards/`, `Repo` `GiftCardOrdersRepository`, `Vendor` +the `IGiftCardProvider` adapter, `IBEX` the payment rail via +`payLnInvoiceViaIbex`, `Worker` `reconcileGiftCardOrders`. + +Every purchase opens the same way (steps 1-7 below): gate, product, validate, +`provider.quote`, `authorizeGiftCardPurchase`, `repo.create` (CREATED), +`releaseGiftCardReservation`. The diagrams that follow start after that unless +they say otherwise. + +## 1. Purchase, happy path + +```ascii +App API UC (purchaseGiftCard) Repo Vendor IBEX + | giftCardPurchase(input) | | | | + |------------->| | | | | + | | gateGiftCardsForAccount | | | + | |------------------>| consumeLimiter (10/min) | | | + | | | findByIdempotencyKey --> | (miss) | | + | | | giftCardsMasterGate | | | + | | | getGiftCardProduct (Redis)| | | + | | | checkedGiftCardValue/Quantity | | + | | | provider.quote ------------------------>| | + | | | authorizeGiftCardPurchase (limits, hold)| | + | | | repo.create ------------>| CREATED | | + | | | releaseGiftCardReservation| | | + | | | [ops: order-created] | | | + | | | provider.createOrder(reference=orderId)>| | + | | | <-- { providerOrderId, bolt11, sats } | + | | | decodeInvoice + tolerance check | | + | | | transition CREATED->INVOICE_ISSUED --> | | + | | | payLnInvoiceViaIbex(key giftcard:) ------------->| + | | | authorize: send guard, then Ibex.payInvoice | + | | | onResponse: capture transaction.id | + | | | <-- Success | + | | | transition INVOICE_ISSUED->PAID (paidSats, ref) -->| | + | | | [ops: order-paid] | | | + | | | fetchAndSettle -> provider.getOrder --->| | + | | | <-- fulfilled + claim | + | | | settleOrderFromVendor: encryptGiftCardClaim | + | | | transition PAID->FULFILLED (ciphertext, keyId) ---->| | + | | | [ops: order-fulfilled] + push notification | + | |<------------------| FULFILLED order | | | + | | getGiftCardOrderForAccount (decrypt for owner) | | + |<-------------| { order: FULFILLED, claim } | | +``` + +If the first `fetchAndSettle` returns pending or errors, the mutation returns +the `PAID` order and the worker finishes the job (flow 4's second half). + +## 2. Vendor rejects the order + +```ascii +UC (purchaseGiftCard) Repo Vendor + | repo.create -------------->| CREATED | + | provider.createOrder ---------------------->| + | <-- 4xx with message (GiftCardVendorRejectedOrderError) + | or 5xx / network (GiftCardVendorUnavailableError) + | failUnpaidOrder: | | + | transition CREATED->FAILED (reason vendor-create-failed: ) + | [ops: order-failed] | | + | return error -> API returns { errors: [GIFT_CARD_VENDOR_REJECTED + | | GIFT_CARD_VENDOR_UNAVAILABLE] } +``` + +Nothing was paid. The client may retry with a **new** idempotency key. The +same applies to an undecodable invoice (`vendor-invoice-undecodable`). + +## 3. Quote mismatch + +```ascii +UC Repo Vendor + | provider.quote ------------------------------>| satsCost = Q + | repo.create -----------------> CREATED | + | provider.createOrder ------------------------>| bolt11 for S sats, amountSats = A + | invoiceSats = decode(bolt11).amount | + | charged = max(invoiceSats, A) | + | max = floor(Q * (1 + quoteToleranceBps/10000))| + | charged > max ? | + | failUnpaidOrder: CREATED->FAILED | + | reason "quote-mismatch: quoted Q invoiced charged" + | [ops: order-failed] | + | return GiftCardQuoteMismatchError -> GIFT_CARD_QUOTE_MISMATCH +``` + +The vendor's invoice is left unpaid and expires on its side. The customer sees +"The gift card price changed; please try again" and re-quotes. + +## 4. Payment pending, settled by the worker + +```ascii +UC Repo IBEX Worker (30s) Vendor + | payLnInvoiceViaIbex ----------------------->| | | + | <-- Pending (transaction.id captured) | | + | INVOICE_ISSUED->PAYMENT_PENDING (providerPaymentRef) | | | + | [ops: payment-pending]; return PAYMENT_PENDING order | | | + | listByStatus(PAYMENT_PENDING) | + | lookupSentPaymentStatus: | + | Ibex.getTransactionDetails(ref) + | paymentSendStatusFromIbex | + | settled -> settleAsPaid: | + | PAYMENT_PENDING->PAID | + | [ops: order-paid] | + | fetchAndSettle ------------>| + | <-- fulfilled+claim | + | PAID->FULFILLED, push | + | failed -> PAYMENT_PENDING->PAYMENT_FAILED + | [ops: order-failed] + | pending/unknown -> leave; warn after 60 min +``` + +An order with **no** `providerPaymentRef` (crash between the IBEX call and the +transition that writes it) answers "unknown" every run: there is no +hash-based fallback on the IBEX rail. See RUNBOOK (c). + +If the vendor reports `fulfilled` while Flash still shows `INVOICE_ISSUED` or +`PAYMENT_PENDING`, `settleOrderFromVendor` treats the vendor's word as proof of +payment and moves the order to `PAID` (reason `vendor-reported-fulfilled`) and +then `FULFILLED`. + +## 5. Paid, then the vendor cancels (REFUND_REQUIRED) + +```ascii +Worker Repo Vendor + | listByStatus(PAID) | | + | nextGiftCardPollAt due? | | + | fetchAndSettle -> provider.getOrder(providerOrderId, bolt11) -->| + | <-- { kind: failed | refunded, reason } | + | settleOrderFromVendor -> vendorFailed: | + | order.status == PAID: | + | transition PAID->REFUND_REQUIRED (reason vendor-: ) + | [ops: refund-required, status failed] <- PAGE | +``` + +The same terminal state is reached without any vendor answer when an order has +been `PAID` for `GIFT_CARD_PAID_TIMEOUT_MS` (24 h): `processPaid` writes +`REFUND_REQUIRED` with reason `fulfillment-timeout`. From here on the order is +an operator's problem: RUNBOOK (a). The customer must not be told to buy again. + +## 6. Expiry + +```ascii +Worker Repo IBEX + | listByStatus(CREATED, INVOICE_ISSUED) | + | filter expiresAt < now | | + | processExpiry: | | + | INVOICE_ISSUED? lookupSentPaymentStatus ----------------->| + | settled -> settleAsPaid (payment-settled-on-reconcile) | + | pending -> warn, do not expire | + | unknown/failed -> fall through | + | transition CREATED|INVOICE_ISSUED->EXPIRED (reason expired) + | [ops: order-failed, reason expired] | +``` + +`expiresAt` is `min(now + 15 min, vendor expiry)` set at INVOICE_ISSUED. An +order that reached INVOICE_ISSUED but crashed before the IBEX call has no +payment and is correctly expired. An order that crashed *after* the IBEX call +but before the transition that writes `providerPaymentRef` looks the same to +the worker and is also expired; that is the one documented window where a paid +invoice can be written off, and it is logged. + +## 7. Idempotent replay / double tap + +```ascii +App API UC Repo + | giftCardPurchase(key K, P, V, Q) | | + |--------------------->|--------------------->| findByIdempotencyKey(wallet, K) --> hit + | | | providerId/product/value/quantity equal? + | | | yes -> return existing order (any state) + | | | span giftcard.replay=true + | | | no -> GiftCardIdempotencyKeyReuseError + |<---------------------| { order } or { errors: [GIFT_CARD_IDEMPOTENCY_KEY_REUSE] } + +Concurrent double tap (both miss the lookup): + | tap 1 --> repo.create -> CREATED (wins the unique index) + | tap 2 --> repo.create -> GiftCardOrderDuplicateKeyError + | -> findByIdempotencyKey again -> returns tap 1's order + | tap 1 continues alone to the vendor and IBEX. +``` + +The replay never reaches the vendor and never spends attempt budget beyond the +single `consumeLimiter` call at the top. A replay of an order that already +paid also cannot re-pay: even if the row check were bypassed, +`withPaymentIdempotency` returns the cached result for `giftcard:` and +`onResponse` does not run, so `providerPaymentRef` stays whatever the first +call wrote. diff --git a/docs/gift-cards/README.md b/docs/gift-cards/README.md new file mode 100644 index 000000000..d9e5a041d --- /dev/null +++ b/docs/gift-cards/README.md @@ -0,0 +1,45 @@ +# Gift cards + +Flash users buy third-party gift cards (Amazon, Visa prepaid, etc.) from inside +the app and pay for them in sats from their Flash wallet. Flash resells cards +from an external vendor; the first vendor is The Bitcoin Company (TBC). + +**Architecture in one sentence:** the vendor's catalog is mirrored into Redis +by a cron job, a purchase creates a Mongo order row and then a vendor order, +Flash pays the vendor's Lightning invoice from the user's IBEX-custodial wallet +through the same idempotent send path every other payment uses, and a +reconcile worker polls the vendor until the card's claim data arrives and is +stored encrypted on the order. + +- Linear: project **ENG-574** (this doc set is ENG-588). +- Design document: *Gift Cards — Architecture Design*. The code is + authoritative where the two disagree; each file below notes where it does. + +## Documents + +| File | What it covers | +| --- | --- | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Layers and files, the provider port, the order state machine, payment rail, idempotency, claim encryption, Redis keys, Mongo indexes, jobs | +| [FLOWS.md](FLOWS.md) | Sequence diagrams: happy path, vendor reject, quote mismatch, pending payment, refund required, expiry, idempotent replay | +| [API.md](API.md) | The public GraphQL contract and every `GIFT_CARD_*` error code | +| [CONFIG.md](CONFIG.md) | Every `giftCards.*` key, per-environment guidance, routing, limits modes | +| [ALERTING.md](ALERTING.md) | Every `giftcard` ops event, what pages vs warns, span attributes | +| [RUNBOOK.md](RUNBOOK.md) | Operator procedures: refunds, stuck orders, kill switches, credential and key rotation, enforce flip, stale catalog, adding a provider | +| [TESTING.md](TESTING.md) | Unit suites, the Mongo integration spec, the planned sandbox e2e, what is not covered | + +## Where the code lives + +| Layer | Path | +| --- | --- | +| Domain (types, errors, state machine) | `src/domain/gift-cards/` | +| Provider registry, catalog cache, claim crypto | `src/services/gift-cards/` | +| TBC adapter | `src/services/gift-cards/bitcoin-company/` | +| Use cases (purchase, settle, reconcile, sync, reads) | `src/app/gift-cards/` | +| Payment rail | `src/app/payments/pay-invoice-via-ibex.ts` | +| Persistence | `src/services/mongoose/gift-card-orders.ts`, `src/migrations/20260909120000-gift-card-orders-indexes.ts` | +| GraphQL | `src/graphql/public/root/query/gift-card-*.ts`, `src/graphql/public/root/mutation/gift-card-purchase.ts` | +| Config | `src/config/schema.ts` (`giftCards`), `dev/config/base-config.yaml` | +| Jobs | `src/servers/cron.ts`, `src/servers/trigger.ts` | + +The feature ships **off** (`giftCards.enabled: false`, every provider disabled, +limits in `log-only`). See [CONFIG.md](CONFIG.md) for what to turn on where. diff --git a/docs/gift-cards/RUNBOOK.md b/docs/gift-cards/RUNBOOK.md new file mode 100644 index 000000000..25964e9bb --- /dev/null +++ b/docs/gift-cards/RUNBOOK.md @@ -0,0 +1,251 @@ +# Gift Cards Runbook + +Operator procedures. Mongo queries use the node + mongoose one-liner pattern +from `docs/bridge-integration/ENG-276-IMPLEMENTATION-RESUME.md` (the repo does +not ship `mongosh`); run them from a pod or a jumpbox with `MONGODB_CON` set. +Redis commands assume `redis-cli` against the cluster's Redis (locally, +`make redis-cli`). Config changes follow `docs/send-guard.md`: edit the values +file, `helm upgrade`, then roll the api, trigger **and** cron workloads; the +YAML is read once at process start. + +Helper used throughout (replace the query): + +```bash +export Q='{"status":"REFUND_REQUIRED"}' +node -e "const m=require('mongoose');(async()=>{await m.connect(process.env.MONGODB_CON);const r=await m.connection.db.collection('giftcardorders').find(JSON.parse(process.env.Q),{projection:{_id:0,claimCiphertext:0,claimKeyId:0}}).sort({updatedAt:1}).limit(50).toArray();console.log(JSON.stringify(r,null,2));await m.disconnect();})().catch(async e=>{console.error(e);process.exit(1)})" +``` + +The projection **excludes** `claimCiphertext` and `claimKeyId`; never print +them, and never decrypt a claim outside the owner-scoped read +(`getGiftCardOrderForAccount`). + +## (a) A `REFUND_REQUIRED` order + +The only state that means money left Flash and no card came. It pages +(`giftcard / refund-required`, see ALERTING.md). + +1. Find it. From the page, `meta.orderId`; or list them all: + `Q='{"status":"REFUND_REQUIRED"}'`. Read `id`, `accountId`, `walletId`, + `valueMinor`, `quantity`, `currency`, `paidSats`, `paymentHash`, + `providerId`, `providerOrderId`, `paymentRequest`, `providerPaymentRef` + (the IBEX transaction id), `failureReason`, and `statusHistory` (when PAID + was written and why REFUND_REQUIRED followed: `vendor-failed: ...`, + `vendor-refunded: ...`, or `fulfillment-timeout`). +2. Confirm our payment. In the IBEX dashboard (or `Ibex.getTransactionDetails` + with `providerPaymentRef`) verify the send for `paymentHash` settled. If + IBEX shows it failed, the order should never have been PAID; note that and + continue, since the state is terminal. +3. Confirm the vendor side (TBC). Log in with the same credentials as + `giftCards.providers.bitcoinCompany` against the configured `baseUrl`, then: + ```bash + TOKEN=$(curl -s -X POST "$BASE_URL/auth/login" -H 'content-type: application/json' \ + -d '{"email":"...","password":"..."}' | jq -r .result.accessToken) + # status keyed by invoice, the same call the adapter makes + curl -s -X POST "$BASE_URL/giftcards/invoice-status" -H "authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' -d "{\"invoice\":\"$PAYMENT_REQUEST\"}" | jq '.result.status' + # the account's orders, for the order labelled with our order id + curl -s "$BASE_URL/user/giftcards" -H "authorization: Bearer $TOKEN" | jq '.result' + ``` + Do not pipe the second response through anything that logs it: it may + contain claim data. If TBC now reports a fulfilled card with claim data, the + order was escalated on a timeout and the card exists: skip to step 5b. +4. Chase the refund. Open a ticket with TBC support quoting our order id (their + label), their order uuid (`providerOrderId`), the invoice, and the paid + amount. TBC refunds to the account balance or by Lightning; record which. +5. Record the outcome. There is no transition out of `REFUND_REQUIRED` + (`GIFT_CARD_TRANSITIONS`), by design. Either: + - **5a. Refund received:** credit the user's wallet by the standard + support path for a manual credit, then annotate the order with a + `$set` of `failureReason` to + `"; refunded "` (only that field). Reply to + the page thread with the order id and the credit reference. + - **5b. Card actually issued:** ask engineering to run the settlement by + hand (`fetchAndSettle(order)` will refuse because the order is terminal; + `settleOrderFromVendor` on a terminal order pages + `vendor-fulfilled-unexpected`). This case needs a code path or a + one-off script that re-encrypts the claim onto the order; do not paste + claim codes anywhere. +6. Tell the customer. They must not be told to buy again; the app already + shows the `REFUND_REQUIRED` copy. + +## (b) Order stuck in `PAID` > 15 min + +Normal fulfilment is seconds. The worker polls at 5s/15s/60s/5m then every +15m, and escalates to `REFUND_REQUIRED` only at 24 h. A PAID order older than +15 min is a vendor delay or a worker problem. + +1. `Q='{"status":"PAID","updatedAt":{"$lt":{"$date":""}}}'`. + Note the JSON date form; adjust the timestamp. +2. Is the worker running? In the trigger pod logs look for + `gift card reconcile finished` (cron) or reconcile tick warnings. If every + run says `skipped: lock-held`, a run died holding the lock; it lapses after + 5 min (`giftcards:reconcile:lock`). Check `giftCards.enabled` is true in the + trigger and cron config, not just the api. +3. Check the vendor for that invoice (step (a)3). `unpaid`/`underpaid`/ + `confirming` while we show PAID means IBEX settled and TBC has not seen it: + compare `paidSats` with the invoice amount and escalate to TBC. `pending` / + `senttofulfillment` is a vendor delay: wait for the 15-minute poll. + `completed` without `claimData` is held as pending by design; ask TBC. +4. Do not move the order by hand. If the vendor confirms the card will not + come, the 24 h timeout will write `REFUND_REQUIRED`; to bring that forward, + engineering can run `settleOrderFromVendor(order, { kind: "failed", reason })` + from a script. + +## (c) Order stuck in `PAYMENT_PENDING` with no `providerPaymentRef` + +The worker cannot re-read IBEX without the transaction id, and there is no +hash-based fallback on this rail (`lookupSentPaymentStatus`). After 60 min it +logs `Gift card payment has been pending for over an hour with no resolvable +status` every run. + +1. `Q='{"status":"PAYMENT_PENDING","providerPaymentRef":null}'`. Read + `paymentHash`, `paymentRequest`, `walletId`, `invoiceSats`. +2. In the IBEX dashboard, find the outgoing payment on account `walletId` + for `paymentHash`. +3. Settled: `$set` `providerPaymentRef` to the IBEX transaction id (only that + field). The next worker run re-reads IBEX, moves the order to `PAID`, and + polls the vendor. +4. Failed at IBEX: same `$set` of the transaction id; the worker will write + `PAYMENT_FAILED`. If IBEX has no record at all, the send never left; ask + engineering to transition `PAYMENT_PENDING -> PAYMENT_FAILED` with reason + `payment-failed` via the repository, not by editing `status` directly (the + `statusHistory` push matters for audits). + +Orders that show `PAYMENT_PENDING` **with** a ref for more than an hour are an +IBEX question: the transaction exists but reports no recognised payment +status. Escalate to IBEX with the transaction id. + +## (d) Disable the feature or one provider + +One config change, then roll api + trigger + cron: + +- Whole rail: `giftCards.enabled: false`. Catalog, quote and purchase return + `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, both jobs + no-op. `giftCardOrder` / `giftCardOrders` keep working so customers can + still read cards they own. **In-flight orders stop being reconciled**; + expect to come back within 24 h or handle PAID orders by hand. +- One provider: `giftCards.providers.bitcoinCompany.enabled: false`. Countries + routed to it get `GIFT_CARD_PROVIDER_UNAVAILABLE`; `fetchAndSettle` refuses + those orders (`getEnabledGiftCardProvider`), so the same in-flight caveat + applies to that provider's orders. +- Open-loop only: `giftCards.allowOpenLoop: false`. +- Kill new purchases but keep settling: there is no such switch today; the + closest is `limits.mode: enforce` with `perLevel.*.perCardCents: 0`, which + refuses every purchase with `GIFT_CARD_LIMIT_EXCEEDED` while the worker + keeps running. + +Not live-reloaded; budget for a pod roll. + +## (e) Rotate TBC credentials; rotate the claim key + +**TBC credentials.** Change `providers.bitcoinCompany.email`/`password` in the +overrides, roll the pods, then clear the cached session so no pod keeps using +the old refresh token: + +``` +redis-cli DEL giftcards:auth:bitcoinCompany giftcards:auth:bitcoinCompany:lock +``` + +The next authenticated call logs in fresh (`Bitcoin Company login succeeded`). +If TBC revokes the old credentials first, calls fail with +`GIFT_CARD_VENDOR_UNAVAILABLE` and a Critical `BitcoinCompanyAuthError` until +the new ones are deployed; the refresh path retries login on every call, so +nothing else needs resetting. + +**Claim key.** `claimDataEncryptionKey` is a single key; there is no multi-key +map yet. `claimKeyId` (first 16 hex chars of sha256 of the key bytes) is stored +on every `FULFILLED` order so a reader can tell which key sealed it. What +happens on a rotation: + +- New orders encrypt under the new key with the new `claimKeyId`. +- Every existing `FULFILLED` order's `claimKeyId` no longer matches; + `decryptGiftCardClaim` returns `GiftCardClaimCryptoError("key rotated")` and + the customer sees `GIFT_CARD_CLAIM_UNAVAILABLE`. + +So a rotation today is a re-encryption migration: decrypt under the old key, +encrypt under the new, update `claimCiphertext` + `claimKeyId`, order by +order, in a script that never logs plaintext. Count what is affected first: + +``` +Q='{"status":"FULFILLED","claimKeyId":{"$ne":""}}' +``` + +Get the new key id from `giftCardClaimKeyId()` in +`src/services/gift-cards/claim-crypto.ts` with the new key configured. Do not +rotate without the script; losing the old key makes every stored claim +unreadable for good. + +## (f) Flip limits `log-only -> enforce` + +Mirror `docs/send-guard.md` "Flipping to enforce": + +1. Run at least a week in `log-only` with real traffic. +2. Count `giftcard / would-reject` by `meta.reason` from the span attribute + `giftcard.limits.reason` (not from Discord): + - `limits-unavailable` must be **zero**. On enforce it refuses the purchase + with `GIFT_CARD_UNKNOWN` and pages. Non-zero means Mongo or Redis faults on + the read path; fix those first. + - `daily-cap`, `per-card-cap`, `velocity`: real customers the caps would + refuse. If any of it is legitimate, raise `perLevel.*` or the account's + level **before** enforcing. + - `vendor-daily-cap`, `vendor-card-cap`: never raise these; they are TBC's + FinCEN exemption limits. + - `level-not-eligible`, `account-too-new`, `open-loop-not-allowed`: + expected; confirm the counts match the product decision. +3. Confirm the reservation hold path is healthy: no + `Could not write gift card reservation; allowing in log-only mode` warnings. + On enforce that warning becomes a refusal. +4. Set `giftCards.limits.mode: enforce`, `helm upgrade`, roll the api pods. +5. Watch `giftcard / rejected` for the first day. Roll back with `log-only` + (or `off` if the limits code itself is the outage), same pod roll. + +## (g) Catalog stale or sync failing + +Symptoms: `catalog-stale` events, then `GIFT_CARD_CATALOG_UNAVAILABLE` once +`staleAfterSeconds` passes; `catalog-sync-failed` events; no +`catalog-synced` for > 2 x `syncIntervalSeconds`. + +1. Read the last sync time: `redis-cli GET giftcards:catalog-sync:last-run` + and `redis-cli TTL giftcards:catalog:bitcoinCompany:countries`. +2. Cron logs: `gift card catalog sync finished` with `summaries: []` means it + was skipped (lock held, marker within interval, or Redis down) or every + provider failed. `gift card catalog sync failed` carries the error. +3. Vendor down (`GiftCardVendorUnavailableError`, `listProducts` retries 3x + on 5xx/network): nothing to do but wait; a failed run releases the interval + marker so the next cron tick retries. Confirm + `GET $BASE_URL/giftcards?size=1&offset=0` answers (no auth needed). +4. Stuck lock: `redis-cli TTL giftcards:catalog-sync:lock`; it lapses in 10 min. + Delete it only if the run that took it is confirmed dead. +5. Force a refresh now: `redis-cli DEL giftcards:catalog-sync:last-run` and + run the cron job (`syncGiftCardCatalogsJob`) once. There is no CLI entry + point; trigger the k8s CronJob manually. +6. Rows skipped during mapping (`no-country`, `unknown-denomination-type`, + `no-denominations`) are logged by `Bitcoin Company catalog mapped`; a sudden + jump means the vendor changed a field. See `mapVendorProduct`. + +## (h) Adding a new provider + +Checklist, using `src/services/gift-cards/bitcoin-company/` as the template: + +1. Add the id to `GiftCardProviderId` and `GIFT_CARD_PROVIDER_IDS` + (`src/domain/gift-cards/index.types.d.ts`, `primitives.ts`); add + `providers.` to `src/config/schema.ts`, `schema.types.d.ts`, + `dev/config/base-config.yaml`, and the `routing` enums. +2. Write the adapter: `client.ts` (transport, auth, retries on idempotent + reads only, never retry order creation), `schemas.ts` (zod on every + response), `mapping.ts` (pure vendor -> domain, minor units, a status table + that never reports `fulfilled` without a claim), `index.ts` exporting an + idempotent `registerProvider()`. +3. Register it in `src/services/gift-cards/index.ts` (one import + call). +4. Redact: add the vendor's token/claim field names to the log redaction set + if the client logs bodies. +5. Tests: a `client.spec.ts`, a `mapping.spec.ts`, and a `contract.spec.ts` + that calls `runGiftCardProviderContract` + (`test/flash/unit/services/gift-cards/provider-contract.ts`). +6. If the vendor pushes webhooks, route them into `settleOrderFromVendor`; + do not add a second settlement path. +7. Config: `providers..enabled: true` and `routing.byCountry` entries; + the registry, master gate, catalog sync and `globals.giftCardsEnabled` + pick it up with no further code. +8. Add its ops-event/alert specifics to ALERTING.md and its credential + rotation to (e) above. diff --git a/docs/gift-cards/TESTING.md b/docs/gift-cards/TESTING.md new file mode 100644 index 000000000..578a051d7 --- /dev/null +++ b/docs/gift-cards/TESTING.md @@ -0,0 +1,113 @@ +# Gift Cards Testing + +## Unit suites + +`yarn test:unit` sources `./.env` and runs jest with +`test/flash/unit/jest.config.js`; `TEST` narrows the path. No Mongo, no Redis: +every gift card module imports `@services/redis` lazily and the specs mock +`@config`, `@services/mongoose`, `@services/redis`, `@services/tracing` and +the logger. + +```bash +TEST=test/flash/unit/app/gift-cards yarn test:unit +TEST=test/flash/unit/services/gift-cards yarn test:unit +TEST=test/flash/unit/services/mongoose/gift-card-orders yarn test:unit +TEST=test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts yarn test:unit +``` + +| Spec | Covers | +| --- | --- | +| `test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts` | Happy path to FULFILLED on the first poll; PAID returned when the poll is pending or errors; `providerPaymentRef` captured on PAID / PAYMENT_PENDING / PAYMENT_FAILED and null on replay; idempotent replay, key reuse refusal, unique-index race; every refusal before a row exists (rate limit, wallet ownership, gate, routing, value, quantity, limits); vendor reject / 5xx / undecodable invoice / tolerance boundary; payment Failure and send errors; claim never in ops events or logs | +| `authorize-purchase.spec.ts` | Levels x amounts x modes table; account age; open-loop switch and level floor; velocity; daily cap including REFUND_REQUIRED and live Redis holds; vendor caps; reservations write/release; `limits-unavailable` in each mode; would-reject / rejected event shape | +| `settle-order.spec.ts` | PAID -> FULFILLED with encrypted claim, event and push; idempotent on FULFILLED; vendor-first payment (PAYMENT_PENDING / INVOICE_ISSUED -> PAID -> FULFILLED); terminal-unpaid + vendor fulfilled pages; encryption failure leaves PAID; losing the FULFILLED race; failed/refunded on PAID -> REFUND_REQUIRED, on INVOICE_ISSUED -> FAILED, on PAYMENT_PENDING left alone; `fetchAndSettle` guards | +| `reconcile-orders.spec.ts` | Poll schedule (`nextGiftCardPollAt`); lock acquire/release/foreign token; expiry with and without a settled or in-flight payment; PAYMENT_PENDING settled/failed/pending/unknown, no-ref, IBEX error, unconfirmed; PAID backoff and fresh-process behaviour; 24 h escalation; per-order isolation; job guard and error propagation | +| `sync-catalog.spec.ts` | Per-provider write and counts; vendor failure, adapter throw, cache write failure; multi-provider isolation; lock TTL and ownership; Redis-down and registry-throw safety; interval marker claim/skip/release | +| `list-products.spec.ts` | Stock and open-loop filters; category/search; ordering; cursor paging without gaps or repeats; unknown cursor restart; `first` defaults and caps; routing and cache errors; stale-catalog event coalescing; `getGiftCardProduct` | +| `gift-cards-master-gate.spec.ts` | Rail off, provider disabled, case-insensitive routing, country routed to a disabled provider, unknown-country sentinel; phone-country resolution (single, ambiguous, none, user load failure, never throws) | +| `test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts` | Login/cache/refresh/401-retry/fallback-to-login/credentials-missing/lock serialisation/Redis-down; catalog paging (500 + short page), row skipping, GET retries and 4xx no-retry; purchase never retried, 4xx-with-message -> rejected, envelope-with-null-result; invoice-status retries; numeric-string coercion; timeout applied; log hygiene (`redactForLog`) | +| `bitcoin-company/mapping.spec.ts` | Unit conversion; product mapping (fixed/variable/VariableNoCents, dedupe, country, currency, logo, terms, categories); `stripBrand`; quote/purchase/claim mapping; full vendor status table, case-insensitivity, unknown status held as pending | +| `bitcoin-company/contract.spec.ts` | Runs `runGiftCardProviderContract` (`test/flash/unit/services/gift-cards/provider-contract.ts`, the shared port contract every adapter must pass) plus TBC-specific assertions and registration idempotency | +| `catalog-cache.spec.ts` | Per-country grouping, per-product keys, countries index written last, `staleAfterSeconds` retention, write failure short-circuits, fresh/stale boundary, normalisation, missing/unreadable/garbage records | +| `claim-crypto.spec.ts` | Round trip, envelope layout, fresh iv per call, tamper detection on payload/tag/iv, version and length checks, key id, key rotation mismatch, hex/base64/whitespace/wrong-length key loading | +| `test/flash/unit/services/mongoose/gift-card-orders.spec.ts`, `.mapping.spec.ts`, `.schema.spec.ts` | Repository create/find/list/transition against a mocked model (the exact `findOneAndUpdate` issued); record -> domain mapping; transition table derived from the domain; duplicate-key attribution; the five indexes match the migration; `toJSON` drops `claimCiphertext`; status enum enforced | +| `test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts` | Pays from the wallet's IBEX account; Success/Pending/unrecognised mapping; `IbexError` pass-through; `onResponse` gets the raw 200 and never runs on a guard rejection or replay; the idempotency wrapper receives key, fingerprint and guard unchanged | + +Shared fixtures: `test/flash/unit/app/gift-cards/fixtures.ts` +(`makeGiftCardsConfig`, `makeLimitsConfig`, order/product builders) and +`test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts` (vendor +response shapes, `BASE_URL`). Note the unit fixture's `perLevel` values differ +from `base-config.yaml`; specs assert against the fixture. + +## Integration spec (real Mongo) + +`test/flash/integration/gift-cards/gift-card-orders.spec.ts` runs the +repository against a live collection because index and race behaviour cannot +be asserted on a mock: + +- two racing `transition`s from the same state: exactly one wins, the other + gets `GiftCardOrderStateError`; +- `{ walletId, idempotencyKey }` unique -> `GiftCardOrderDuplicateKeyError`; +- `{ providerId, providerOrderId }` is **partial** unique: two orders with + `providerOrderId: null` both insert (a sparse index would fail this); +- `toJSON` drops `claimCiphertext`. + +It calls `GiftCardOrders.syncIndexes()` in `beforeAll` so it never silently +tests an index-less collection. What it needs: the integration harness +(`test/flash/integration/jest.setup.ts`) with `MONGODB_CON` and Redis reachable +and IBEX mocked; locally `make integration` (`reset-deps-integration` brings +the containers up) or directly: + +```bash +TEST=test/flash/integration/gift-cards yarn test:integration +``` + +It lives under `test/flash/integration/gift-cards` because the integration +jest config ignores `test/flash/integration/services/*`. + +Migration behaviour (idempotent create, drop-and-recreate on spec drift, +`down` drops by name and keeps documents) is exercised by the CI migrate job +(`make test-migrate`), not by a dedicated spec. + +## Planned sandbox e2e (not yet on disk) + +Mirrors `test/flash/bridge-sandbox-e2e/` (opt-in via +`RUN_BRIDGE_SANDBOX_E2E`; `README.md` there describes the pattern): a +`test/flash/giftcard-sandbox-e2e/` suite gated on `RUN_GIFTCARD_SANDBOX_E2E=true` +that runs the real purchase path against TBC's sandbox +(`https://api.dev.thebitcoincompany.com`, Mutinynet). + +What it needs, per the TBC wiki: + +- TBC sandbox credentials in `.env.local` / `dev-overrides.yaml` + (`giftCards.providers.bitcoinCompany.email` / `password`), never committed. +- A Mutinynet Lightning wallet to pay sandbox invoices: a Voltage node on + Mutinynet funded from the Mutinynet faucet. The IBEX rail cannot pay + Mutinynet invoices, so the e2e either pays the bolt11 from that node in + place of `payLnInvoiceViaIbex` (mock the rail, pay out of band, then let the + worker settle) or asserts only up to `INVOICE_ISSUED`. +- `giftCards.enabled: true`, `providers.bitcoinCompany.enabled: true`, a + throwaway `claimDataEncryptionKey`, `limits.mode: off` for the test account. + +Suggested cases: catalog sync writes real products; quote for a fixed and a +variable card; purchase reaches `INVOICE_ISSUED` with a decodable Mutinynet +invoice within tolerance; after out-of-band payment the worker moves the order +to `FULFILLED` and `giftCardOrder` returns a claim for the owner and null for +another account; a second purchase with the same key replays. + +## Not yet covered + +- GraphQL resolver tests for `giftCardCatalog`, `giftCardQuote`, + `giftCardOrder`, `giftCardOrders`, `giftCardPurchase` and + `globals.giftCardsEnabled` (scalar validation, gate errors thrown vs returned, + the claim-only-when-FULFILLED rule at the wire, cursor codecs). +- `quote-gift-card.ts`, `get-order.ts`, `list-orders.ts`, + `send-fulfilled-notification.ts` and `ops.ts` have no direct spec; they are + exercised indirectly through purchase and settle specs. +- The end-to-end Lightning payment: `payLnInvoiceViaIbex` is mocked in every + suite, the integration harness mocks `@services/ibex/client`, and the + sandbox e2e above does not exist yet. +- `src/servers/cron.ts` / `trigger.ts` wiring of the two jobs. +- The registry's routing with two registered providers (only one adapter + exists). +- Claim key rotation / re-encryption: the crypto spec proves the mismatch is + detected; there is no migration and no test for one. From 3fc2191efb589a4c56814365d04405b91c896e5f Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 11 Sep 2026 20:56:12 -0700 Subject: [PATCH 05/14] =?UTF-8?q?fix(gift-cards):=20address=20review=20rou?= =?UTF-8?q?nd=201=20=E2=80=94=20payment=20seam,=20kill=20switch,=20quote?= =?UTF-8?q?=20budget,=20pagination=20(#508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf --- dev/apollo-federation/supergraph.graphql | 4 +- docs/gift-cards/ALERTING.md | 10 +- docs/gift-cards/API.md | 32 +- docs/gift-cards/ARCHITECTURE.md | 17 +- docs/gift-cards/CONFIG.md | 9 +- docs/gift-cards/FLOWS.md | 79 ++++- docs/gift-cards/RUNBOOK.md | 39 +- docs/gift-cards/TESTING.md | 9 +- src/app/gift-cards/get-order.ts | 28 +- src/app/gift-cards/purchase-gift-card.ts | 332 ++++++++++++++---- src/app/gift-cards/quote-gift-card.ts | 30 +- src/app/gift-cards/reconcile-orders.ts | 222 +++++++++--- src/app/gift-cards/settle-order.ts | 13 +- src/config/yaml.ts | 23 ++ src/domain/gift-cards/index.types.d.ts | 7 +- src/domain/rate-limit/errors.ts | 1 + src/domain/rate-limit/index.ts | 13 + src/graphql/error-map.ts | 7 + .../root/mutation/gift-card-purchase.ts | 9 +- .../public/root/query/gift-card-order.ts | 32 +- .../public/root/query/gift-card-quote.ts | 1 + src/graphql/public/schema.graphql | 4 +- .../public/types/object/gift-card-order.ts | 6 +- src/servers/cron.ts | 7 +- src/servers/trigger.ts | 5 +- .../gift-cards/bitcoin-company/client.ts | 36 +- .../gift-cards/bitcoin-company/index.ts | 2 +- .../gift-cards/bitcoin-company/mapping.ts | 14 +- src/services/gift-cards/registry.ts | 21 +- .../authentication/ops-events-hooks.spec.ts | 2 + .../request-code-destination.spec.ts | 2 + .../unit/app/gift-cards/get-order.spec.ts | 173 +++++++++ .../app/gift-cards/purchase-gift-card.spec.ts | 290 ++++++++++++++- .../app/gift-cards/quote-gift-card.spec.ts | 72 ++++ .../app/gift-cards/reconcile-orders.spec.ts | 311 ++++++++++++++-- .../unit/app/gift-cards/settle-order.spec.ts | 13 +- test/flash/unit/config/rate-limits.spec.ts | 53 ++- .../root/mutation/gift-card-purchase.spec.ts | 35 +- .../public/root/query/gift-card-order.spec.ts | 103 +++++- .../public/root/query/gift-card-quote.spec.ts | 28 ++ .../gift-cards/bitcoin-company/client.spec.ts | 116 ++++-- .../bitcoin-company/contract.spec.ts | 3 +- .../gift-cards/bitcoin-company/fixtures.ts | 22 ++ .../bitcoin-company/mapping.spec.ts | 12 +- .../services/gift-cards/provider-contract.ts | 6 +- .../unit/services/gift-cards/registry.spec.ts | 108 ++++++ 46 files changed, 2064 insertions(+), 297 deletions(-) create mode 100644 test/flash/unit/app/gift-cards/get-order.spec.ts create mode 100644 test/flash/unit/services/gift-cards/registry.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 1155b8300..8cc999535 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1495,7 +1495,7 @@ enum GiftCardOrderStatus PAID @join__enumValue(graph: PUBLIC) """ - Terminal. The Lightning payment failed and nothing left the wallet. Safe to try again with a new idempotencyKey. + Terminal. Only proven refusals land here (insufficient balance, a rejected send, a bad idempotency key): nothing left the wallet and it is safe to try again with a new idempotencyKey. An indeterminate send error goes to PAYMENT_PENDING instead, never here. """ PAYMENT_FAILED @join__enumValue(graph: PUBLIC) @@ -2904,7 +2904,7 @@ type Query ): GiftCardProductConnection! """ - One of the calling account's gift card orders, by id — the endpoint to poll after giftCardPurchase, and the ONLY place the claim is returned. Null when the id is unknown or belongs to another account; the two are deliberately indistinguishable. + One of the calling account's gift card orders, by id — the endpoint to poll after giftCardPurchase, and the ONLY place the claim is returned. Null when the id is unknown or belongs to another account; the two are deliberately indistinguishable. A FULFILLED order whose claim cannot currently be read is still returned, with `claim` null. """ giftCardOrder( """A GiftCardOrder.id from giftCardPurchase or giftCardOrders.""" diff --git a/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md index f2c7a649c..03d2536ee 100644 --- a/docs/gift-cards/ALERTING.md +++ b/docs/gift-cards/ALERTING.md @@ -23,11 +23,11 @@ event by construction: the helper takes an order, not a claim. | Phase | Status | Emitted by | Meaning | Action | | --- | --- | --- | --- | --- | | `order-created` | pending | `purchaseGiftCard` | Row written, vendor not yet called | none | -| `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight | none; worker resolves. Warn if the same order is still pending after 60 min (see log line below) | +| `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight (`meta.reason` `payment-pending`), or the send errored without a verdict — socket reset, gateway 5xx, timeout, unreadable 200, busy idempotency lock (`meta.reason` `payment-unconfirmed: `, `error` naming the class) | none; worker resolves against IBEX, then the vendor. Warn if the same order is still pending after 60 min (see log line below). A burst of `payment-unconfirmed` is an IBEX connectivity problem | | `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card | none | | `order-fulfilled` | success | `settleOrderFromVendor` | Claim stored; push sent | none | -| `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment (`meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: `, `payment-failed`, `expired`, `vendor-failed: ...`) | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | -| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | +| `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment (`meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: ` — only for a send IBEX provably refused, `payment-failed`, `expired`, `vendor-failed: ...`) | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | +| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` (24 h in PAID and a final vendor poll did not report fulfilled; `meta.lastVendorPoll` is `not-fulfilled` or the poll error's class) | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | | `claim-encrypt-failed` | failed | `settleOrderFromVendor` | Card issued at the vendor; Flash could not encrypt the claim (key missing/invalid). Order stays `PAID` | **Page.** Fix `claimDataEncryptionKey`; the next poll retries | | `vendor-fulfilled-unexpected` | failed | `settleOrderFromVendor` | Vendor says fulfilled but our order is terminal-unpaid (`meta.orderStatus`). Someone paid; our records say not us | **Page.** Reconcile by hand | | `would-reject` | pending | `authorizeGiftCardPurchase` (log-only) | A limits check would have refused; `meta.reason`, `meta.level`, `meta.productId`, `error` | Review weekly before flipping to enforce (RUNBOOK f). `reason: limits-unavailable` must be zero | @@ -66,7 +66,9 @@ All from `baseLogger` with `module: "gift-cards.reconcile"` unless noted: | Level | Message | Meaning | | --- | --- | --- | -| warn | `Gift card payment has been pending for over an hour with no resolvable status` | PAYMENT_PENDING with no `providerPaymentRef` or an IBEX lookup that never resolves; carries `orderId`, `paymentHash`, `providerPaymentRef` | +| warn | `Gift card payment has been pending for over an hour with no resolvable status` | PAYMENT_PENDING with no `providerPaymentRef` or an IBEX lookup that never resolves, AND the vendor does not report fulfilled; carries `orderId`, `paymentHash`, `providerPaymentRef` | +| warn | `Gift card payment outcome unknown after send error; holding as PAYMENT_PENDING` (purchase) | The send errored without proving IBEX refused it; carries `orderId`, `providerPaymentRef`, `error`. The worker settles it | +| warn | `Final vendor poll before fulfilment timeout failed; escalating` | 24 h in PAID and the vendor could not be reached; the order is now REFUND_REQUIRED and the page names the poll error | | warn | `Expired gift card invoice still has an in-flight payment; not expiring` | IBEX says pending on an expired invoice; watch it | | error | `Gift card reconcile failed for order` | One order threw; the batch continued | | error | `Gift card payment settled but PAID transition failed` (purchase) | Money moved, bookkeeping lost a race; the worker will re-read IBEX | diff --git a/docs/gift-cards/API.md b/docs/gift-cards/API.md index 023fed53a..135a37384 100644 --- a/docs/gift-cards/API.md +++ b/docs/gift-cards/API.md @@ -105,8 +105,13 @@ caller owns the order, `status` is `FULFILLED`, and the ciphertext decrypts under the currently configured key. This is enforced twice: the app layer decrypts only for the owner once fulfilled, and `toGiftCardOrderSource` drops any claim unless `status === FULFILLED`. A `FULFILLED` order whose claim cannot -be decrypted throws `GIFT_CARD_CLAIM_UNAVAILABLE` rather than returning an -empty claim. +be decrypted (key missing, rotated, or one pod on stale config) is **still +returned**, with `claim: null`, so the customer can always see the order they +paid for; the fault is recorded server-side at Critical (span + log, never the +ciphertext) rather than thrown to the client. Only `giftCardPurchase` surfaces +it, as `GIFT_CARD_CLAIM_UNAVAILABLE` alongside the order. A client polling +`giftCardOrder` that sees `FULFILLED` with `claim: null` should show "contact +support" and keep polling; the claim appears once the key is restored. ### `giftCardOrders` @@ -150,13 +155,21 @@ Behaviour: `quoteToleranceBps` from the quote. No money moves on any refusal. - Money moves at most once per `idempotencyKey`. Retry a timed-out call with the **same** key: same key + same parameters returns the existing order in - whatever state it reached; same key + different parameters is refused. + whatever state it reached; same key + different parameters is refused. One + exception: an `INVOICE_ISSUED` order still inside its expiry is **resumed**, + not returned. The server re-enters the payment step under its own + server-side key (`giftcard:`), so a first call that died + mid-payment is finished (cached outcome replayed, or the one send that never + happened made) rather than left to expire. `CREATED` is returned as-is. - `order` is returned in whatever state the purchase reached: often already `FULFILLED` with `claim` attached, otherwise `PAID` or `PAYMENT_PENDING` (poll `giftCardOrder`), or `FAILED` / `PAYMENT_FAILED` (see - `failureReason`). `order` is absent when refused before a row existed. It - can be present **alongside** an error when the card was issued but the - claim could not be read. + `failureReason`). A send whose outcome IBEX could not confirm (network + fault, gateway error or timeout after the request was accepted) comes back + as a `PAYMENT_PENDING` order, **not** as an error: poll, do not buy again. + `order` is absent when refused before a row existed. It can be present + **alongside** an error when the card was issued but the claim could not be + read. - Attempt budget: 10 per minute per account, 5-minute block (`RateLimitConfig.giftCardPurchase`), charged before any vendor call. @@ -164,10 +177,10 @@ Behaviour: | Value | Meaning for the client | | --- | --- | -| `CREATED`, `INVOICE_ISSUED`, `PAYMENT_PENDING`, `PAID` | Transient. Keep polling. Never purchase again. `PAYMENT_PENDING` can take minutes | +| `CREATED`, `INVOICE_ISSUED`, `PAYMENT_PENDING`, `PAID` | Transient. Keep polling. Never purchase again. `PAYMENT_PENDING` can take minutes: the worker re-reads IBEX and, when IBEX cannot account for the send, asks the vendor | | `FULFILLED` | Terminal. `claim` and `fulfilledAt` are set | | `FAILED` | Terminal, nothing paid: vendor rejected, price moved past tolerance, or unreadable invoice. Safe to retry with a new key | -| `PAYMENT_FAILED` | Terminal, nothing paid: the Lightning payment failed. Safe to retry with a new key | +| `PAYMENT_FAILED` | Terminal, nothing paid: IBEX **provably** refused or failed the Lightning payment (insufficient balance, a corroborated payment failure, a send-guard rejection). Only proven refusals land here; a send whose outcome is unknown is `PAYMENT_PENDING`. Safe to retry with a new key | | `EXPIRED` | Terminal, nothing paid: invoice not paid in time | | `REFUND_REQUIRED` | Terminal. The wallet paid, no card arrived; Flash has been paged. Do **not** tell the customer to buy again | @@ -191,9 +204,10 @@ From `src/graphql/error-map.ts`. On queries they arrive as a GraphQL error with | `GIFT_CARD_VENDOR_REJECTED` | Vendor returned a definitive 4xx on order creation, or an unreadable invoice; `FAILED`, nothing paid | Show message; retry later with a new key | | `GIFT_CARD_VENDOR_UNAVAILABLE` | Vendor unreachable, 5xx, auth failure, or unexpected response shape | "Temporarily unavailable"; retry later | | `GIFT_CARD_CATALOG_UNAVAILABLE` | Catalog key missing from Redis or Redis unreadable | Retry later; the sync job repopulates | -| `GIFT_CARD_CLAIM_UNAVAILABLE` | Claim ciphertext could not be decrypted (key missing, rotated, corrupt) | Show "contact support"; the order is still `FULFILLED` | +| `GIFT_CARD_CLAIM_UNAVAILABLE` | Claim ciphertext could not be decrypted (key missing, rotated, corrupt). Returned only by `giftCardPurchase`, alongside the order; `giftCardOrder` returns the order with `claim: null` instead | Show "contact support"; the order is still `FULFILLED` | | `GIFT_CARD_IDEMPOTENCY_KEY_REUSE` | Same key, different product/value/quantity | Generate a new key for a new purchase | | `GIFT_CARD_PURCHASE_RATE_LIMITED` | More than 10 purchase attempts in a minute | Back off 5 minutes | +| `GIFT_CARD_QUOTE_RATE_LIMITED` | More than 30 `giftCardQuote` calls in a minute | Back off 5 minutes; reuse the last quote until its `expiresAt` | | `GIFT_CARD_UNKNOWN` | Limits store unreadable (`limits-unavailable` in enforce mode) or an adapter threw | Retry later; this pages on the Flash side | Errors that are **not** `GIFT_CARD_*` can also come back from the mutation: diff --git a/docs/gift-cards/ARCHITECTURE.md b/docs/gift-cards/ARCHITECTURE.md index 53f0c36fb..096199262 100644 --- a/docs/gift-cards/ARCHITECTURE.md +++ b/docs/gift-cards/ARCHITECTURE.md @@ -30,7 +30,7 @@ delivering the claim. | Layer | Files | Responsibility | | --- | --- | --- | | Domain | `src/domain/gift-cards/index.ts`, `index.types.d.ts`, `primitives.ts`, `errors.ts` | Status enum + transition table, product/quote/order/claim types, the `IGiftCardProvider` port, product-id codec, value/quantity validation, every `GiftCard*` error | -| Registry | `src/services/gift-cards/registry.ts`, `index.ts` | Adapters self-register at import; config-driven routing (`routing.byCountry[cc] ?? routing.default`, gated by `providers..enabled`) | +| Registry | `src/services/gift-cards/registry.ts`, `index.ts` | Adapters self-register at import; config-driven routing (`routing.byCountry[cc] ?? routing.default`, gated by `providers..enabled`) for new money; `getRegisteredGiftCardProviderOrError` (registration only, no `enabled` gate) for orders that already exist | | Adapter (TBC) | `src/services/gift-cards/bitcoin-company/{index,client,mapping,schemas,errors}.ts` | HTTP transport, auth token lifecycle in Redis, retries (idempotent reads only), zod validation, vendor-to-domain mapping. Nothing vendor-shaped leaves this directory | | Catalog cache | `src/services/gift-cards/catalog-cache.ts` | Redis read model of the catalog; sync job writes, every request reads | | Claim crypto | `src/services/gift-cards/claim-crypto.ts` | AES-256-GCM at rest for claim data; `claimKeyId` derivation | @@ -105,7 +105,10 @@ REFUND_REQUIRED -> (terminal) ``` `REFUND_REQUIRED` is the only state meaning "money left Flash and no card -arrived". `FAILED`, `PAYMENT_FAILED` and `EXPIRED` all mean nothing was paid. +arrived". `FAILED`, `PAYMENT_FAILED` and `EXPIRED` all mean nothing was paid: +`PAYMENT_FAILED` is written only for a send IBEX provably refused, a send +whose outcome is unknown goes to `PAYMENT_PENDING`, and an expiring invoice is +checked against IBEX and the vendor first. The repository refuses an illegal move before touching Mongo, and the write is `findOneAndUpdate({ id, status: { $in: from } })`, so of two racing settlers exactly one wins and the loser gets `GiftCardOrderStateError`. @@ -116,12 +119,12 @@ Who moves what: | --- | --- | | `CREATED -> INVOICE_ISSUED` | `purchaseGiftCard`, after `provider.createOrder` and the tolerance check | | `CREATED/INVOICE_ISSUED -> FAILED` | `purchaseGiftCard` (`failUnpaidOrder`), `settleOrderFromVendor` on vendor `failed`/`refunded` before payment | -| `INVOICE_ISSUED -> PAID / PAYMENT_PENDING / PAYMENT_FAILED` | `purchaseGiftCard` from the `PaymentSendStatus` | +| `INVOICE_ISSUED -> PAID / PAYMENT_PENDING / PAYMENT_FAILED` | `purchaseGiftCard` from the `PaymentSendStatus`, or from the send error's class: a proven refusal is `PAYMENT_FAILED`, anything else `PAYMENT_PENDING` (FLOWS 4a). Also entered by a same-key replay of an unpaid `INVOICE_ISSUED` order | | `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX | -| `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment | -| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`, after one payment re-read | +| `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment — including the reconcile worker's vendor fallback when IBEX cannot account for the send | +| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`, after one payment re-read and, when IBEX cannot answer, one vendor poll | | `PAID -> FULFILLED` | `settleOrderFromVendor` (`fulfil`) with the encrypted claim | -| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24h in PAID (`fulfillment-timeout`) | +| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24h in PAID when a final vendor poll does not report fulfilled (`fulfillment-timeout`) | ## Decisive facts @@ -214,7 +217,7 @@ The E11000 on `walletId_1_idempotencyKey_1` is mapped to | Job | Where | Cadence | Guard | | --- | --- | --- | --- | | Catalog sync | `cron.ts` `syncGiftCardCatalogsJob` | every cron run, throttled to `catalog.syncIntervalSeconds` by the Redis marker | `giftCards.enabled`; distributed lock | -| Reconcile | `trigger.ts` `startGiftCardReconcileInterval` (30 s) and `cron.ts` `reconcileGiftCardOrdersJob` | 30 s in the trigger pod; cron as safety net | `giftCards.enabled`; shared Redis lock | +| Reconcile | `trigger.ts` `startGiftCardReconcileInterval` (30 s) and `cron.ts` `reconcileGiftCardOrdersJob` | 30 s in the trigger pod; cron as safety net | any non-terminal order exists (`hasOpenGiftCardOrders`, one `limit: 1` read) — **not** `giftCards.enabled`, so the kill switch never strands a paid order; shared Redis lock | Reconcile PAID backoff: 5s, 15s, 60s, 5m, then every 15m from the PAID transition. Attempt timing is process memory, so the cron (a fresh process) diff --git a/docs/gift-cards/CONFIG.md b/docs/gift-cards/CONFIG.md index a9bff1f24..eaf7adcb6 100644 --- a/docs/gift-cards/CONFIG.md +++ b/docs/gift-cards/CONFIG.md @@ -14,14 +14,14 @@ Local overrides go in `$CONFIG_PATH/dev-overrides.yaml` | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `enabled` | boolean | `false` | Master switch. Off: `giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` return `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, both jobs are no-ops. `giftCardOrder` / `giftCardOrders` still resolve (owner-scoped reads of paid orders) | +| `enabled` | boolean | `false` | Master switch for **new money only**. Off: `giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` return `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, catalog sync is a no-op, a same-key replay of an unpaid `INVOICE_ISSUED` order is not resumed. The reconcile worker keeps running while any non-terminal order exists (settlement and the 24 h `REFUND_REQUIRED` alert outlive the switch; RUNBOOK d). `giftCardOrder` / `giftCardOrders` still resolve (owner-scoped reads of paid orders) | | `allowOpenLoop` | boolean | `false` | Show and sell open-loop (Visa/Mastercard-style) cards. Off: hidden from the catalog, `GIFT_CARD_PRODUCT_NOT_FOUND` by id, `GIFT_CARDS_DISABLED` on purchase. On: still requires account level >= 2 | | `feeBps` | integer | `0` | Flash markup on face value, basis points. **Not read by any code path today** (reserved) | | `claimDataEncryptionKey` | string | `""` | 32-byte AES-256-GCM key: 64 hex chars, or base64 of exactly 32 bytes. Empty key: fulfilment leaves orders `PAID` and pages (`claim-encrypt-failed`); reads throw `GIFT_CARD_CLAIM_UNAVAILABLE` | | `quoteToleranceBps` | integer | `100` | Max the vendor invoice may exceed the quote (1% default) before the purchase is refused with `GIFT_CARD_QUOTE_MISMATCH`. Overrides the domain constant `GIFT_CARD_QUOTE_TOLERANCE_BPS` | | `routing.default` | `bitcoinCompany` \| `bitrefill` | `bitcoinCompany` | Provider for any country not listed in `byCountry`, including unknown (`"XX"`) | | `routing.byCountry` | map CC -> provider id | `{}` | ISO 3166-1 alpha-2 (upper case; lookups are normalised) -> provider id | -| `providers.bitcoinCompany.enabled` | boolean | `false` | Provider switch. A country routed to a disabled provider gets `GIFT_CARD_PROVIDER_UNAVAILABLE` | +| `providers.bitcoinCompany.enabled` | boolean | `false` | Provider switch for new money. A country routed to a disabled provider gets `GIFT_CARD_PROVIDER_UNAVAILABLE`; orders that already exist still settle through the registered adapter | | `providers.bitcoinCompany.baseUrl` | string | `https://api.dev.thebitcoincompany.com` | Sandbox (Mutinynet). Production is `https://api.thebitcoincompany.com` | | `providers.bitcoinCompany.email` / `password` | string | `""` | Login credentials for `POST /auth/login`. Empty: every authenticated call fails with `GIFT_CARD_VENDOR_UNAVAILABLE` (`BitcoinCompanyAuthError`) | | `providers.bitcoinCompany.referralCode` | string | `""` | **Not read by the client today** | @@ -120,5 +120,6 @@ orders count toward the daily sum (money left); `FAILED`, `PAYMENT_FAILED`, `EXPIRED` do not. A Mongo or Redis fault is `limits-unavailable`: allowed in `log-only`, refused (`GIFT_CARD_UNKNOWN`, level Critical) in `enforce`. -Independent of mode: the 10/min purchase attempt limiter and the ENG-573 send -guard on the payment itself always run. +Independent of mode: the 10/min purchase attempt limiter, the 30/min +`giftCardQuote` attempt limiter (`GIFT_CARD_QUOTE_RATE_LIMITED`, 5 min block) and +the ENG-573 send guard on the payment itself always run. diff --git a/docs/gift-cards/FLOWS.md b/docs/gift-cards/FLOWS.md index aa1e48001..2c050fbd4 100644 --- a/docs/gift-cards/FLOWS.md +++ b/docs/gift-cards/FLOWS.md @@ -109,12 +109,35 @@ UC Repo IBEX Worker (30s) | PAID->FULFILLED, push | | failed -> PAYMENT_PENDING->PAYMENT_FAILED | [ops: order-failed] - | pending/unknown -> leave; warn after 60 min + | pending -> leave (IBEX's word beats a vendor poll) + | unknown / no ref -> fetchAndSettle ------------->| + | fulfilled -> PAID -> FULFILLED + | else leave; warn after 60 min ``` -An order with **no** `providerPaymentRef` (crash between the IBEX call and the -transition that writes it) answers "unknown" every run: there is no -hash-based fallback on the IBEX rail. See RUNBOOK (c). +An order with **no** `providerPaymentRef` (the send errored before IBEX handed +back an id, or a crash between the IBEX call and the transition that writes +it) answers "unknown" every run: there is no hash-based fallback on the IBEX +rail. The worker asks the vendor instead: a vendor `fulfilled` is proof of +payment (below). An order the vendor cannot vouch for either stays pending; +RUNBOOK (c) after 60 min. + +### 4a. A send error without a verdict + +`purchaseGiftCard` files a send error under `PAYMENT_FAILED` only when the +error proves IBEX never accepted the send: a send-guard rejection (the guard +runs immediately before the IBEX call), `InvalidIdempotencyKeyError` / +`IdempotencyKeyReuseError` (the wrapper refused before executing), +`InsufficientIbexBalance` (IBEX's 400), `FailedIbexPayment` (a corroborated +FAILED on the 200). Every other error (the generic `IbexError` for a socket +reset, gateway 5xx or timeout after the request was accepted; +`UnconfirmedIbexPayment`; `CompletedInvoice`; a busy idempotency lock while a +concurrent same-key attempt is in flight) says nothing about whether money +moved, so the order goes `INVOICE_ISSUED -> PAYMENT_PENDING` with reason +`payment-unconfirmed: ` in `statusHistory` (`[ops: payment-pending]`, +`error` naming the class) and the **pending order** is returned. This flow +then settles it. A replayed order that lost to a concurrent attempt returns +whatever that attempt wrote. If the vendor reports `fulfilled` while Flash still shows `INVOICE_ISSUED` or `PAYMENT_PENDING`, `settleOrderFromVendor` treats the vendor's word as proof of @@ -135,10 +158,14 @@ Worker Repo Vendor | [ops: refund-required, status failed] <- PAGE | ``` -The same terminal state is reached without any vendor answer when an order has -been `PAID` for `GIFT_CARD_PAID_TIMEOUT_MS` (24 h): `processPaid` writes -`REFUND_REQUIRED` with reason `fulfillment-timeout`. From here on the order is -an operator's problem: RUNBOOK (a). The customer must not be told to buy again. +After `GIFT_CARD_PAID_TIMEOUT_MS` (24 h) in `PAID`, `processPaid` makes one +final `fetchAndSettle` before escalating: a vendor `fulfilled` ends +`FULFILLED` (a worker gap over 24 h must not refund cards that shipped), a +vendor `failed` / `refunded` takes the path above, and only otherwise — +including a vendor error, which the event names in `meta.lastVendorPoll` — does +it write `REFUND_REQUIRED` with reason `fulfillment-timeout`. From here on the +order is an operator's problem: RUNBOOK (a). The customer must not be told to +buy again. ## 6. Expiry @@ -150,17 +177,21 @@ Worker Repo IBEX | INVOICE_ISSUED? lookupSentPaymentStatus ----------------->| | settled -> settleAsPaid (payment-settled-on-reconcile) | | pending -> warn, do not expire | - | unknown/failed -> fall through | + | unknown -> fetchAndSettle (vendor): fulfilled -> PAID -> FULFILLED + | failed / vendor has nothing -> fall through | | transition CREATED|INVOICE_ISSUED->EXPIRED (reason expired) | [ops: order-failed, reason expired] | ``` -`expiresAt` is `min(now + 15 min, vendor expiry)` set at INVOICE_ISSUED. An -order that reached INVOICE_ISSUED but crashed before the IBEX call has no -payment and is correctly expired. An order that crashed *after* the IBEX call -but before the transition that writes `providerPaymentRef` looks the same to -the worker and is also expired; that is the one documented window where a paid -invoice can be written off, and it is logged. +`expiresAt` is `min(order TTL 15 min, decoded BOLT11 expiry, vendor-stated +expiry if the adapter reports one)` set at INVOICE_ISSUED; TBC reports none, so +the invoice governs. An order that reached INVOICE_ISSUED but crashed before the +IBEX call has no payment and is correctly expired. An order that crashed *after* +the IBEX call but before the transition that writes `providerPaymentRef` looks +the same to IBEX, so the worker asks the vendor before expiring it: a card that +shipped is proof of payment and the order settles. Only an invoice neither IBEX +nor the vendor knows as paid is expired. (A same-key replay before expiry +resumes the payment instead: flow 7.) ## 7. Idempotent replay / double tap @@ -169,8 +200,11 @@ App API UC Repo | giftCardPurchase(key K, P, V, Q) | | |--------------------->|--------------------->| findByIdempotencyKey(wallet, K) --> hit | | | providerId/product/value/quantity equal? - | | | yes -> return existing order (any state) + | | | yes -> return existing order (any state) ... | | | span giftcard.replay=true + | | | ... EXCEPT INVOICE_ISSUED still inside + | | | expiresAt -> resume the pay step + | | | (giftcard.replay.resumedPayment=true) | | | no -> GiftCardIdempotencyKeyReuseError |<---------------------| { order } or { errors: [GIFT_CARD_IDEMPOTENCY_KEY_REUSE] } @@ -187,3 +221,16 @@ paid also cannot re-pay: even if the row check were bypassed, `withPaymentIdempotency` returns the cached result for `giftcard:` and `onResponse` does not run, so `providerPaymentRef` stays whatever the first call wrote. + +**Resumed INVOICE_ISSUED.** A first attempt that died between issuing the +invoice and recording the payment's outcome leaves a row that, handed back as +"keep polling", could only ever end in `EXPIRED` — writing the money off if +IBEX had in fact paid. So the replay re-enters `payLnInvoiceViaIbex` (send +guard, IBEX, the same transitions as flow 1/4/4a) under the same +`giftcard:` key: a cached outcome is replayed, an in-flight attempt is +refused (busy lock -> `PAYMENT_PENDING`, flow 4a), or the one send that never +happened is made. Nothing upstream of the pay step (gate, quote, limits, +vendor) runs again. Not resumed: `CREATED` (vendor `createOrder` is not +idempotent), an `INVOICE_ISSUED` row past `expiresAt` (flow 6 handles it), and +any row while `giftCards.enabled` is false (that would be new money leaving; +the worker still settles an invoice the first attempt did pay). diff --git a/docs/gift-cards/RUNBOOK.md b/docs/gift-cards/RUNBOOK.md index 25964e9bb..0262dfa81 100644 --- a/docs/gift-cards/RUNBOOK.md +++ b/docs/gift-cards/RUNBOOK.md @@ -79,8 +79,8 @@ Normal fulfilment is seconds. The worker polls at 5s/15s/60s/5m then every 2. Is the worker running? In the trigger pod logs look for `gift card reconcile finished` (cron) or reconcile tick warnings. If every run says `skipped: lock-held`, a run died holding the lock; it lapses after - 5 min (`giftcards:reconcile:lock`). Check `giftCards.enabled` is true in the - trigger and cron config, not just the api. + 5 min (`giftcards:reconcile:lock`). The worker runs regardless of + `giftCards.enabled`; a tick only skips when no non-terminal order exists. 3. Check the vendor for that invoice (step (a)3). `unpaid`/`underpaid`/ `confirming` while we show PAID means IBEX settled and TBC has not seen it: compare `paidSats` with the invoice amount and escalate to TBC. `pending` / @@ -94,9 +94,12 @@ Normal fulfilment is seconds. The worker polls at 5s/15s/60s/5m then every ## (c) Order stuck in `PAYMENT_PENDING` with no `providerPaymentRef` The worker cannot re-read IBEX without the transaction id, and there is no -hash-based fallback on this rail (`lookupSentPaymentStatus`). After 60 min it -logs `Gift card payment has been pending for over an hour with no resolvable -status` every run. +hash-based fallback on this rail (`lookupSentPaymentStatus`), so every run it +asks the vendor instead (`fetchAndSettle`): a vendor `fulfilled` settles the +order as `PAID` then `FULFILLED` on its own, no operator needed. An order still +`PAYMENT_PENDING` after 60 min is therefore one the vendor shows as unpaid (or +cannot be reached for); the worker logs `Gift card payment has been pending +for over an hour with no resolvable status` every run. 1. `Q='{"status":"PAYMENT_PENDING","providerPaymentRef":null}'`. Read `paymentHash`, `paymentRequest`, `walletId`, `invoiceSats`. @@ -120,19 +123,23 @@ status. Escalate to IBEX with the transaction id. One config change, then roll api + trigger + cron: - Whole rail: `giftCards.enabled: false`. Catalog, quote and purchase return - `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, both jobs - no-op. `giftCardOrder` / `giftCardOrders` keep working so customers can - still read cards they own. **In-flight orders stop being reconciled**; - expect to come back within 24 h or handle PAID orders by hand. + `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, catalog sync + no-ops. **The reconcile worker keeps running** (trigger interval and cron) + for as long as any non-terminal order exists: in-flight orders still settle, + customers who already paid still receive their codes, and the 24 h + `REFUND_REQUIRED` alert still fires. The switch stops new money leaving, + nothing else: a same-key replay of an unpaid `INVOICE_ISSUED` order is not + resumed while it is off (the worker expires that order after checking IBEX + and the vendor). `giftCardOrder` / `giftCardOrders` keep working so + customers can still read cards they own. - One provider: `giftCards.providers.bitcoinCompany.enabled: false`. Countries - routed to it get `GIFT_CARD_PROVIDER_UNAVAILABLE`; `fetchAndSettle` refuses - those orders (`getEnabledGiftCardProvider`), so the same in-flight caveat - applies to that provider's orders. + routed to it get `GIFT_CARD_PROVIDER_UNAVAILABLE` on catalog, quote and + purchase. Settlement resolves the adapter by registration + (`getRegisteredGiftCardProviderOrError`), so that provider's in-flight orders + keep reconciling exactly as above. - Open-loop only: `giftCards.allowOpenLoop: false`. -- Kill new purchases but keep settling: there is no such switch today; the - closest is `limits.mode: enforce` with `perLevel.*.perCardCents: 0`, which - refuses every purchase with `GIFT_CARD_LIMIT_EXCEEDED` while the worker - keeps running. +- Kill new purchases but keep settling: that is what `giftCards.enabled: + false` does. Not live-reloaded; budget for a pod roll. diff --git a/docs/gift-cards/TESTING.md b/docs/gift-cards/TESTING.md index 578a051d7..eb594c588 100644 --- a/docs/gift-cards/TESTING.md +++ b/docs/gift-cards/TESTING.md @@ -17,15 +17,16 @@ TEST=test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts yarn test:unit | Spec | Covers | | --- | --- | -| `test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts` | Happy path to FULFILLED on the first poll; PAID returned when the poll is pending or errors; `providerPaymentRef` captured on PAID / PAYMENT_PENDING / PAYMENT_FAILED and null on replay; idempotent replay, key reuse refusal, unique-index race; every refusal before a row exists (rate limit, wallet ownership, gate, routing, value, quantity, limits); vendor reject / 5xx / undecodable invoice / tolerance boundary; payment Failure and send errors; claim never in ops events or logs | +| `test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts` | Happy path to FULFILLED on the first poll; PAID returned when the poll is pending or errors; `providerPaymentRef` captured on PAID / PAYMENT_PENDING / PAYMENT_FAILED and null on replay; idempotent replay, key reuse refusal, unique-index race; replay of an unpaid INVOICE_ISSUED order resumes the pay step (and does not while expired, while the kill switch is off, or for CREATED / PAYMENT_PENDING); every refusal before a row exists (rate limit, wallet ownership, gate, routing, value, quantity, limits); vendor reject / 5xx / undecodable invoice / tolerance boundary; order expiry = min(TTL, decoded BOLT11, vendor-stated); payment Failure; send errors that prove refusal -> PAYMENT_FAILED (InsufficientIbexBalance, FailedIbexPayment, idempotency-key errors, send-guard rejection) vs. unknown outcome -> PAYMENT_PENDING (generic IbexError, UnconfirmedIbexPayment, CompletedInvoice, busy lock; ref kept when IBEX answered; concurrent attempt wins); claim never in ops events or logs | | `authorize-purchase.spec.ts` | Levels x amounts x modes table; account age; open-loop switch and level floor; velocity; daily cap including REFUND_REQUIRED and live Redis holds; vendor caps; reservations write/release; `limits-unavailable` in each mode; would-reject / rejected event shape | -| `settle-order.spec.ts` | PAID -> FULFILLED with encrypted claim, event and push; idempotent on FULFILLED; vendor-first payment (PAYMENT_PENDING / INVOICE_ISSUED -> PAID -> FULFILLED); terminal-unpaid + vendor fulfilled pages; encryption failure leaves PAID; losing the FULFILLED race; failed/refunded on PAID -> REFUND_REQUIRED, on INVOICE_ISSUED -> FAILED, on PAYMENT_PENDING left alone; `fetchAndSettle` guards | -| `reconcile-orders.spec.ts` | Poll schedule (`nextGiftCardPollAt`); lock acquire/release/foreign token; expiry with and without a settled or in-flight payment; PAYMENT_PENDING settled/failed/pending/unknown, no-ref, IBEX error, unconfirmed; PAID backoff and fresh-process behaviour; 24 h escalation; per-order isolation; job guard and error propagation | +| `settle-order.spec.ts` | PAID -> FULFILLED with encrypted claim, event and push; idempotent on FULFILLED; vendor-first payment (PAYMENT_PENDING / INVOICE_ISSUED -> PAID -> FULFILLED); terminal-unpaid + vendor fulfilled pages; encryption failure leaves PAID; losing the FULFILLED race; failed/refunded on PAID -> REFUND_REQUIRED, on INVOICE_ISSUED -> FAILED, on PAYMENT_PENDING left alone; `fetchAndSettle` resolves the provider by registration (not `enabled`) and guards | +| `reconcile-orders.spec.ts` | Poll schedule (`nextGiftCardPollAt`); lock acquire/release/foreign token; expiry with and without a settled or in-flight payment, vendor fallback for a no-ref INVOICE_ISSUED (fulfilled -> FULFILLED, nothing -> EXPIRED, IBEX FAILED -> no poll); PAYMENT_PENDING settled/failed/in-flight (no vendor poll), unknown and no-ref (vendor poll; fulfilled -> FULFILLED, vendor error -> stays pending), IBEX error, unconfirmed; PAID backoff and fresh-process behaviour; 24 h escalation only after a final vendor poll (fulfilled -> FULFILLED, vendor refund -> once, vendor error -> escalate and name it); per-order isolation; `hasOpenGiftCardOrders`; job and interval run while `giftCards.enabled` is false and skip the lock with nothing open; error propagation | | `sync-catalog.spec.ts` | Per-provider write and counts; vendor failure, adapter throw, cache write failure; multi-provider isolation; lock TTL and ownership; Redis-down and registry-throw safety; interval marker claim/skip/release | | `list-products.spec.ts` | Stock and open-loop filters; category/search; ordering; cursor paging without gaps or repeats; unknown cursor restart; `first` defaults and caps; routing and cache errors; stale-catalog event coalescing; `getGiftCardProduct` | | `gift-cards-master-gate.spec.ts` | Rail off, provider disabled, case-insensitive routing, country routed to a disabled provider, unknown-country sentinel; phone-country resolution (single, ambiguous, none, user load failure, never throws) | | `test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts` | Login/cache/refresh/401-retry/fallback-to-login/credentials-missing/lock serialisation/Redis-down; catalog paging (500 + short page), row skipping, GET retries and 4xx no-retry; purchase never retried, 4xx-with-message -> rejected, envelope-with-null-result; invoice-status retries; numeric-string coercion; timeout applied; log hygiene (`redactForLog`) | -| `bitcoin-company/mapping.spec.ts` | Unit conversion; product mapping (fixed/variable/VariableNoCents, dedupe, country, currency, logo, terms, categories); `stripBrand`; quote/purchase/claim mapping; full vendor status table, case-insensitivity, unknown status held as pending | +| `bitcoin-company/mapping.spec.ts` | Unit conversion; product mapping (fixed/variable/VariableNoCents, dedupe, country, currency, logo, terms, categories); `stripBrand`; quote/purchase/claim mapping (purchase `expiresAt` is null, never fabricated); full vendor status table, case-insensitivity, unknown status held as pending | +| `registry.spec.ts` | `getRegisteredGiftCardProviderOrError` returns the adapter while the master or provider switch is off and errors only when unregistered; `getEnabledGiftCardProvider` refuses on either switch; byCountry/default routing to a disabled provider is unavailable | | `bitcoin-company/contract.spec.ts` | Runs `runGiftCardProviderContract` (`test/flash/unit/services/gift-cards/provider-contract.ts`, the shared port contract every adapter must pass) plus TBC-specific assertions and registration idempotency | | `catalog-cache.spec.ts` | Per-country grouping, per-product keys, countries index written last, `staleAfterSeconds` retention, write failure short-circuits, fresh/stale boundary, normalisation, missing/unreadable/garbage records | | `claim-crypto.spec.ts` | Round trip, envelope layout, fresh iv per call, tamper detection on payload/tag/iv, version and length checks, key id, key rotation mismatch, hex/base64/whitespace/wrong-length key loading | diff --git a/src/app/gift-cards/get-order.ts b/src/app/gift-cards/get-order.ts index eb535681a..bc98821d2 100644 --- a/src/app/gift-cards/get-order.ts +++ b/src/app/gift-cards/get-order.ts @@ -1,12 +1,24 @@ -import { GiftCardOrderNotFoundError, GiftCardOrderStatus } from "@domain/gift-cards" +import { + GiftCardClaimCryptoError, + GiftCardOrderNotFoundError, + GiftCardOrderStatus, +} from "@domain/gift-cards" import { decryptGiftCardClaim } from "@services/gift-cards/claim-crypto" import { GiftCardOrdersRepository } from "@services/mongoose" import { addAttributesToCurrentSpan } from "@services/tracing" export type GiftCardOrderWithClaim = { order: GiftCardOrder - /** Decrypted bearer data. Present only when the order is FULFILLED. */ + /** Decrypted bearer data. Present only when the order is FULFILLED and the claim decrypted. */ claim: GiftCardClaim | null + /** + * Set when the order is FULFILLED but its stored claim could not be decrypted + * (key not configured, key rotated, corrupt ciphertext). The order still + * comes back — the card IS issued and the customer must be able to see that + * — and the caller decides how to surface the fault. `claim` is null + * whenever this is set. + */ + claimError: GiftCardClaimCryptoError | null } /** @@ -16,6 +28,12 @@ export type GiftCardOrderWithClaim = { * id, so the endpoint cannot be used to confirm that someone else's order * exists. The claim is decrypted here and nowhere else; it goes straight into * the authenticated response and is never logged or traced. + * + * Only "the order cannot be read at all" (missing, not the caller's, repository + * fault) is returned as an error. A decrypt failure is NOT: the order is real + * and paid for, so it rides back as data with `claimError` set. Returning the + * error instead would make a rotated key (or one pod with stale config) hide + * the order from its owner, who then cannot even see that it exists. */ export const getGiftCardOrderForAccount = async ({ accountId, @@ -38,14 +56,14 @@ export const getGiftCardOrderForAccount = async ({ !order.claimCiphertext || !order.claimKeyId ) { - return { order, claim: null } + return { order, claim: null, claimError: null } } const claim = decryptGiftCardClaim({ ciphertext: order.claimCiphertext, keyId: order.claimKeyId, }) - if (claim instanceof Error) return claim + if (claim instanceof Error) return { order, claim: null, claimError: claim } - return { order, claim } + return { order, claim, claimError: null } } diff --git a/src/app/gift-cards/purchase-gift-card.ts b/src/app/gift-cards/purchase-gift-card.ts index df5282388..a9a24e94e 100644 --- a/src/app/gift-cards/purchase-gift-card.ts +++ b/src/app/gift-cards/purchase-gift-card.ts @@ -8,13 +8,14 @@ import { import { payLnInvoiceViaIbex } from "@app/payments/pay-invoice-via-ibex" import { toSats } from "@domain/bitcoin" import { decodeInvoice, PaymentSendStatus } from "@domain/bitcoin/lightning" -import { InvalidIdempotencyKeyError } from "@domain/errors" +import { IdempotencyKeyReuseError, InvalidIdempotencyKeyError } from "@domain/errors" import { checkedGiftCardQuantity, checkedGiftCardValue, GIFT_CARD_QUOTE_TOLERANCE_BPS, GiftCardIdempotencyKeyReuseError, GiftCardOrderNotFoundError, + GiftCardOrderStateError, GiftCardOrderStatus, GiftCardProductNotAvailableInCountryError, GiftCardProductNotFoundError, @@ -28,6 +29,7 @@ import { RateLimitConfig } from "@domain/rate-limit" import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" import { ValidationError } from "@domain/shared" import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" +import { FailedIbexPayment, InsufficientIbexBalance } from "@services/ibex/errors" import { baseLogger } from "@services/logger" import { AccountsRepository, @@ -65,6 +67,14 @@ import { fetchAndSettle } from "./settle-order" * is unique on (walletId, idempotencyKey), so a replayed mutation finds the * existing order before it ever reaches the vendor. * + * PAYMENT_FAILED is reserved for outcomes that PROVE IBEX never accepted the + * send (see `isDefinitiveSendRejection`). The client reads that state as + * "nothing left the wallet, buy again with a new key", so an error that merely + * says "we don't know" — a socket reset or gateway 5xx after IBEX took the + * request, an unreadable 200, a busy idempotency lock — must never land there. + * Those go to PAYMENT_PENDING and the reconcile worker settles them against + * IBEX and, failing that, the vendor. + * * Claim data never passes through this module: fulfilment is `settle-order`'s * job and it stores ciphertext only. */ @@ -78,7 +88,7 @@ export type PurchaseGiftCardArgs = { } // How long a CREATED / INVOICE_ISSUED order stays payable before the reconcile -// worker expires it. Vendor invoices are usually shorter; we take the earlier. +// worker expires it. The vendor's BOLT11 is usually shorter; we take the earlier. const ORDER_TTL_MS = 15 * 60 * 1000 const MAX_IDEMPOTENCY_KEY_LENGTH = 256 @@ -100,48 +110,106 @@ const sameParameters = ( order.valueMinor === args.valueMinor && order.quantity === args.quantity +const isValidDate = (value: unknown): value is Date => + value instanceof Date && !Number.isNaN(value.getTime()) + +/** The earliest of `first` and every valid Date among `rest`. */ +const earliestDate = ( + first: Date, + ...rest: ReadonlyArray +): Date => rest.reduce((min, d) => (isValidDate(d) && d < min ? d : min), first) + /** * The ENG-573 send guard, built exactly as `lnInvoicePaymentSend` builds it: * decode is part of the guard (so log-only never blocks on it), a zero-amount * invoice is a gated rejection, and the decoded sats are what the cap is * judged on. Runs inside the idempotency lock, only on the path that pays. + * + * `onReject` fires when the guard refuses. The wrapper runs the guard + * immediately before `execute`, so a refusal here is proof that IBEX was never + * called — which is what lets the caller file it under PAYMENT_FAILED. */ const buildSendGuardHook = ({ senderAccount, senderWalletId, paymentRequest, + onReject, }: { senderAccount: Account senderWalletId: WalletId paymentRequest: string + onReject: () => void }): SendGuardHook => async () => { - if (getSendGuardMode() === "off") return true - - const gate = (error: ApplicationError) => - gateSend({ - error, - reason: SendRejectionReasons.undecodableInvoice, - senderAccount, - senderWalletId, - kind: "lightning", - }) - - const decoded = decodeInvoice(paymentRequest) - if (decoded instanceof Error) return gate(decoded) - if (decoded.paymentAmount === null) { - return gate(new LnPaymentRequestNonZeroAmountRequiredError()) - } + const verdict = await judgeSend({ senderAccount, senderWalletId, paymentRequest }) + if (verdict instanceof Error) onReject() + return verdict + } - return authorizeSend({ +const judgeSend = async ({ + senderAccount, + senderWalletId, + paymentRequest, +}: { + senderAccount: Account + senderWalletId: WalletId + paymentRequest: string +}): Promise => { + if (getSendGuardMode() === "off") return true + + const gate = (error: ApplicationError) => + gateSend({ + error, + reason: SendRejectionReasons.undecodableInvoice, senderAccount, senderWalletId, - amount: { currency: "BTC", sats: decoded.paymentAmount.amount }, kind: "lightning", }) + + const decoded = decodeInvoice(paymentRequest) + if (decoded instanceof Error) return gate(decoded) + if (decoded.paymentAmount === null) { + return gate(new LnPaymentRequestNonZeroAmountRequiredError()) } + return authorizeSend({ + senderAccount, + senderWalletId, + amount: { currency: "BTC", sats: decoded.paymentAmount.amount }, + kind: "lightning", + }) +} + +/** + * Does this error prove IBEX never accepted the send? Only then is + * PAYMENT_FAILED honest. + * + * - send guard refused: runs inside the idempotency lock immediately before + * `execute`, so `execute` (and the IBEX call) never ran + * - InvalidIdempotencyKeyError / IdempotencyKeyReuseError: the wrapper + * refused before `execute`, or replayed a cached result for a different + * fingerprint without executing + * - InsufficientIbexBalance: IBEX's 400 for "no funds"; nothing was sent + * - FailedIbexPayment: IBEX's 200 carrying a corroborated FAILED + * + * Everything else — the generic IbexError for a network fault, timeout, 5xx or + * auth failure; UnconfirmedIbexPayment; CompletedInvoice ("already prepared", + * which may be OUR earlier attempt); the busy LockServiceError while a + * concurrent same-key attempt is mid-flight — says nothing about whether money + * moved. That is the KNOWN GAP `withPaymentIdempotency` documents: IBEX may + * have debited and then errored to us. + */ +const isDefinitiveSendRejection = ( + error: ApplicationError, + guardRejected: boolean, +): boolean => + guardRejected || + error instanceof InvalidIdempotencyKeyError || + error instanceof IdempotencyKeyReuseError || + error instanceof InsufficientIbexBalance || + error instanceof FailedIbexPayment + /** Move an unpaid order to FAILED and report it. Transition errors are logged, not surfaced. */ const failUnpaidOrder = async ( order: GiftCardOrder, @@ -214,8 +282,9 @@ export const purchaseGiftCard = async ( const repo = GiftCardOrdersRepository() // Replay: the row is unique on (walletId, idempotencyKey). Same key, same - // parameters → the existing order, whatever state it reached. Same key, - // different parameters → refused, never silently swapped for the old order. + // parameters → the existing order (see `replayExistingOrder` for the one + // state that is resumed rather than returned). Same key, different + // parameters → refused, never silently swapped for the old order. const parsed = parseGiftCardProductId(productId) if (parsed instanceof Error) return parsed @@ -236,7 +305,7 @@ export const purchaseGiftCard = async ( "giftcard.orderId": existing.id, "giftcard.replay": true, }) - return existing + return replayExistingOrder({ existing, account, walletId }) } // Master gate with the account's routing country. @@ -360,11 +429,9 @@ export const purchaseGiftCard = async ( return error } - const vendorExpiresAt = - vendorOrder.expiresAt instanceof Date && - !Number.isNaN(vendorOrder.expiresAt.getTime()) - ? vendorOrder.expiresAt - : created.expiresAt + // The order is payable only as long as the invoice is. The BOLT11's own + // expiry is the source of truth; the order TTL caps it; a vendor that states + // an explicit order expiry (TBC does not) can only bring it forward. const issued = await repo.transition({ id: created.id, from: [GiftCardOrderStatus.Created], @@ -375,8 +442,11 @@ export const purchaseGiftCard = async ( paymentRequest: vendorOrder.paymentRequest, invoiceSats, paymentHash: decoded.paymentHash, - expiresAt: - vendorExpiresAt < created.expiresAt ? vendorExpiresAt : created.expiresAt, + expiresAt: earliestDate( + created.expiresAt, + decoded.expiresAt, + vendorOrder.expiresAt, + ), }, }) if (issued instanceof Error) { @@ -385,26 +455,105 @@ export const purchaseGiftCard = async ( return issued } - // IBEX-custodial rail (see @app/payments/pay-invoice-via-ibex): the same - // idempotency wrapper and send guard as lnInvoicePaymentSend. The - // fingerprint binds the cached result to THIS order as well as the invoice, - // so a different order can never replay a previous success. - // - // The IBEX transaction id is the only handle the reconcile worker has to - // re-query an in-flight send; it is captured from the raw 200 and written - // with whichever transition follows. A crash between the IBEX call and that - // transition leaves INVOICE_ISSUED with no ref — see reconcile-orders.ts. - const captured: { providerPaymentRef: string | null } = { providerPaymentRef: null } + return payIssuedOrder({ + account, + walletId, + order: issued, + paymentRequest: vendorOrder.paymentRequest, + invoiceSats, + }) +} + +/** + * Same key, same parameters. Every state is handed back as it is, with one + * exception: a still-payable INVOICE_ISSUED row. That is a first attempt that + * died between issuing the invoice and recording the payment's outcome, and + * handing it back as "keep polling" could only ever end in EXPIRED — writing + * the money off if IBEX had in fact paid. Re-entering the pay step is safe by + * construction: `withPaymentIdempotency` under `giftcard:` replays a + * cached outcome, refuses to run alongside an in-flight attempt, or makes the + * one send that never happened. + * + * CREATED is left alone (the vendor's createOrder is not idempotent; the worker + * expires it), as is an INVOICE_ISSUED row past its expiry (the worker re-reads + * IBEX and the vendor before expiring it). The kill switch stops the resume + * too — it is new money leaving — and the worker's vendor poll still settles + * an invoice the first attempt did pay. + */ +const replayExistingOrder = async ({ + existing, + account, + walletId, +}: { + existing: GiftCardOrder + account: Account + walletId: WalletId +}): Promise => { + if (existing.status !== GiftCardOrderStatus.InvoiceIssued) return existing + if (existing.expiresAt.getTime() <= Date.now()) return existing + if (!existing.paymentRequest) return existing + if (GiftCardsConfig.enabled !== true) return existing + + addAttributesToCurrentSpan({ "giftcard.replay.resumedPayment": true }) + baseLogger.info( + { orderId: existing.id }, + "Gift card purchase replayed on an unpaid INVOICE_ISSUED order; resuming the payment", + ) + return payIssuedOrder({ + account, + walletId, + order: existing, + paymentRequest: existing.paymentRequest, + invoiceSats: existing.invoiceSats ?? existing.quoteSats, + }) +} + +/** + * The pay step and the transitions that follow it, for an INVOICE_ISSUED + * order. Entered by the purchase flow once and by a same-key replay of an + * unpaid order; both are idempotent under `giftcard:`. + * + * IBEX-custodial rail (see @app/payments/pay-invoice-via-ibex): the same + * idempotency wrapper and send guard as lnInvoicePaymentSend. The fingerprint + * binds the cached result to THIS order as well as the invoice, so a different + * order can never replay a previous success. + * + * The IBEX transaction id is the only handle the reconcile worker has to + * re-query an in-flight send; it is captured from the raw 200 and written with + * whichever transition follows. A crash between the IBEX call and that + * transition leaves INVOICE_ISSUED with no ref — a same-key replay resumes it, + * and the worker polls the vendor before expiring it. + */ +const payIssuedOrder = async ({ + account, + walletId, + order, + paymentRequest, + invoiceSats, +}: { + account: Account + walletId: WalletId + order: GiftCardOrder + paymentRequest: string + invoiceSats: Satoshis +}): Promise => { + const captured: { providerPaymentRef: string | null; guardRejected: boolean } = { + providerPaymentRef: null, + guardRejected: false, + } const payment = await payLnInvoiceViaIbex({ senderWalletId: walletId, senderAccount: account, - paymentRequest: vendorOrder.paymentRequest, - idempotencyKey: `giftcard:${created.id}`, - requestFingerprint: `ln|${vendorOrder.paymentRequest}|giftcard|${created.id}`, + paymentRequest, + idempotencyKey: `giftcard:${order.id}`, + requestFingerprint: `ln|${paymentRequest}|giftcard|${order.id}`, authorize: buildSendGuardHook({ senderAccount: account, senderWalletId: walletId, - paymentRequest: vendorOrder.paymentRequest, + paymentRequest, + onReject: () => { + captured.guardRejected = true + }, }), onResponse: (response) => { const id = response?.transaction?.id @@ -415,13 +564,16 @@ export const purchaseGiftCard = async ( addAttributesToCurrentSpan({ "giftcard.providerPaymentRef": providerPaymentRef ?? "" }) if (payment instanceof Error) { - await markPaymentFailed( - issued, - `payment-error: ${payment.constructor.name}`, - payment, - providerPaymentRef, - ) - return payment + if (isDefinitiveSendRejection(payment, captured.guardRejected)) { + await markPaymentFailed( + order, + `payment-error: ${payment.constructor.name}`, + payment, + providerPaymentRef, + ) + return payment + } + return markPaymentUnconfirmed(order, payment, providerPaymentRef) } // Compare by value: a replayed status comes back from the idempotency cache @@ -429,35 +581,75 @@ export const purchaseGiftCard = async ( switch (payment.value) { case PaymentSendStatus.Success.value: case PaymentSendStatus.AlreadyPaid.value: - return markPaidAndSettle(issued, invoiceSats, providerPaymentRef) - case PaymentSendStatus.Pending.value: { - const pending = await repo.transition({ - id: issued.id, - from: [GiftCardOrderStatus.InvoiceIssued], - to: GiftCardOrderStatus.PaymentPending, - reason: "payment-pending", - patch: { providerPaymentRef }, - }) - if (pending instanceof Error) return pending - notifyGiftCardOpsEvent({ - phase: "payment-pending", - status: "pending", - order: pending, - }) - return pending - } + return markPaidAndSettle(order, invoiceSats, providerPaymentRef) + case PaymentSendStatus.Pending.value: + return markPaymentPending(order, { reason: "payment-pending", providerPaymentRef }) default: { const failed = await markPaymentFailed( - issued, + order, "payment-failed", null, providerPaymentRef, ) - return failed ?? issued + return failed ?? order } } } +/** + * The send errored without proving IBEX refused it: money may have left the + * wallet. PAYMENT_PENDING is the only honest state — the reconcile worker + * re-reads IBEX when it has a transaction id and asks the vendor when it does + * not. Returns the pending order, not the error: the client must poll, never + * retry with a fresh key. + */ +const markPaymentUnconfirmed = async ( + order: GiftCardOrder, + error: ApplicationError, + providerPaymentRef: string | null, +): Promise => { + const reason = `payment-unconfirmed: ${error.constructor.name}` + recordExceptionInCurrentSpan({ error }) + baseLogger.warn( + { orderId: order.id, providerPaymentRef, error: error.constructor.name }, + "Gift card payment outcome unknown after send error; holding as PAYMENT_PENDING", + ) + return markPaymentPending(order, { reason, providerPaymentRef, error }) +} + +const markPaymentPending = async ( + order: GiftCardOrder, + { + reason, + providerPaymentRef, + error, + }: { reason: string; providerPaymentRef: string | null; error?: ApplicationError }, +): Promise => { + const repo = GiftCardOrdersRepository() + const pending = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.PaymentPending, + reason, + patch: { providerPaymentRef }, + }) + if (pending instanceof GiftCardOrderStateError) { + // A concurrent same-key attempt already moved the order on — to its own + // PAYMENT_PENDING, or to the PAID / PAYMENT_FAILED its send resolved to. + // Whatever it wrote is the truth. + return repo.findById(order.id) + } + if (pending instanceof Error) return pending + notifyGiftCardOpsEvent({ + phase: "payment-pending", + status: "pending", + order: pending, + error: error?.constructor.name, + meta: { reason }, + }) + return pending +} + const markPaymentFailed = async ( order: GiftCardOrder, reason: string, diff --git a/src/app/gift-cards/quote-gift-card.ts b/src/app/gift-cards/quote-gift-card.ts index 2d6a9cedf..37636871f 100644 --- a/src/app/gift-cards/quote-gift-card.ts +++ b/src/app/gift-cards/quote-gift-card.ts @@ -4,8 +4,12 @@ import { GiftCardProductNotAvailableInCountryError, GiftCardProductNotFoundError, } from "@domain/gift-cards" +import { RateLimitConfig } from "@domain/rate-limit" +import { GiftCardQuoteRateLimiterExceededError } from "@domain/rate-limit/errors" import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" +import { baseLogger } from "@services/logger" import { AccountsRepository } from "@services/mongoose" +import { consumeLimiter } from "@services/rate-limit" import { addAttributesToCurrentSpan } from "@services/tracing" import { @@ -17,7 +21,7 @@ import { getGiftCardProduct } from "./list-products" /** * ENG-582 — a price for the storefront, BEFORE any order exists. * - * gate → product → validate → provider.quote + * attempt budget → gate → product → validate → provider.quote * * Deliberately the same first four steps as `purchaseGiftCard`, in the same * order with the same errors, so the quote can never say yes where the @@ -25,7 +29,13 @@ import { getGiftCardProduct } from "./list-products" * is not routed to, an out-of-stock row, an off-denomination value — every one * of those is refused here with the error the mutation would give. * - * Nothing is written and no limit is consumed: a quote is not a reservation. + * Nothing is written and no purchase limit is consumed: a quote is not a + * reservation. The only thing charged is the per-account ATTEMPT budget + * (`RateLimitConfig.giftCardQuote`), because every call past it is a live POST + * to the vendor through the one shared reseller login that every purchase also + * needs — a client looping on this field could get that login throttled or + * locked and fail every customer's purchase. + * * The number comes straight from the vendor and is good until `expiresAt`; the * purchase re-quotes and refuses to pay an invoice that drifted past tolerance * (`GIFT_CARD_QUOTE_TOLERANCE_BPS`), so a stale quote costs the customer a @@ -50,6 +60,22 @@ export const quoteGiftCard = async ({ "giftcard.quantity": quantity, }) + // Attempt budget, charged before any store or vendor round-trip so a client + // looping on a refused request bounds its own cost. A limiter STORE fault + // falls through (same posture as purchaseGiftCard): refusing every quote + // during a Redis blip would page nobody and block everyone. + const limitOk = await consumeLimiter({ + rateLimitConfig: RateLimitConfig.giftCardQuote, + keyToConsume: accountId, + }) + if (limitOk instanceof GiftCardQuoteRateLimiterExceededError) return limitOk + if (limitOk instanceof Error) { + baseLogger.warn( + { accountId, error: limitOk.constructor.name }, + "Gift card quote rate limiter unavailable; continuing", + ) + } + const account = await AccountsRepository().findById(accountId) if (account instanceof Error) return account diff --git a/src/app/gift-cards/reconcile-orders.ts b/src/app/gift-cards/reconcile-orders.ts index 2b5eac385..785dc0ae8 100644 --- a/src/app/gift-cards/reconcile-orders.ts +++ b/src/app/gift-cards/reconcile-orders.ts @@ -1,9 +1,11 @@ import { randomUUID } from "crypto" -import { GiftCardsConfig } from "@config" - import { PaymentSendStatus } from "@domain/bitcoin/lightning" -import { GiftCardOrderStatus, UnknownGiftCardError } from "@domain/gift-cards" +import { + GiftCardOrderStatus, + isGiftCardTerminalStatus, + UnknownGiftCardError, +} from "@domain/gift-cards" import Ibex from "@services/ibex/client" import { UnconfirmedIbexPayment } from "@services/ibex/errors" import { paymentSendStatusFromIbex } from "@services/ibex/payment-status" @@ -28,16 +30,30 @@ const logger = baseLogger.child({ module: "gift-cards.reconcile" }) * * Everything the purchase mutation does not wait for ends up here: * - * CREATED / INVOICE_ISSUED past `expiresAt` → EXPIRED (after one payment re-read) - * PAYMENT_PENDING → PAID | PAYMENT_FAILED (payment re-read) + * CREATED / INVOICE_ISSUED past `expiresAt` → EXPIRED (after one payment re-read + * and, for INVOICE_ISSUED, one vendor poll) + * PAYMENT_PENDING → PAID | PAYMENT_FAILED (payment re-read); + * IBEX unknown / no ref → vendor poll * PAID → poll vendor with backoff → FULFILLED - * | REFUND_REQUIRED; 24h → REFUND_REQUIRED + * | REFUND_REQUIRED; 24h → final vendor + * poll, then REFUND_REQUIRED + * + * The vendor is the arbiter of last resort: `settleOrderFromVendor` accepts a + * vendor `fulfilled` on an INVOICE_ISSUED or PAYMENT_PENDING order as proof of + * payment, so an order whose send IBEX cannot account for (no transaction id, + * IBEX unreachable, an error after IBEX accepted the request) is never written + * off while the vendor says a card shipped. * * Runs under a Redis lock so the cron pod and the trigger's 30s interval never * work the same batch at once. Never throws: a per-order failure is logged, * counted, and the loop moves on — one vendor 500 must not strand the other * orders in the batch. * + * NOT gated on `giftCards.enabled`. The kill switch stops new money leaving + * (quote / purchase); orders that already exist still need settling, and the + * 24h REFUND_REQUIRED alert must still fire. The job and interval entry points + * gate on "a non-terminal order exists" instead (`hasOpenGiftCardOrders`). + * * Backoff per PAID order: 5s, 15s, 60s, 5m, then every 15m, measured from the * PAID transition. Attempts are tracked in process memory (nothing on the * order changes on a poll, by design — `transition` is the only write), which @@ -62,10 +78,15 @@ const SECOND = 1000 const MINUTE = 60 * SECOND export const GIFT_CARD_PAID_TIMEOUT_MS = 24 * 60 * MINUTE // Pending payments have their own horizon: IBEX reports in-flight sends for a -// bounded time, and an order our own rail cannot tell us about after this long -// needs a human, not another poll. +// bounded time, and an order neither our rail nor the vendor can tell us about +// after this long needs a human, not another poll. const PAYMENT_PENDING_WARN_MS = 60 * MINUTE +/** Every status the worker still has work to do on. */ +export const GIFT_CARD_OPEN_STATUSES: readonly GiftCardOrderStatus[] = Object.values( + GiftCardOrderStatus, +).filter((status) => !isGiftCardTerminalStatus(status)) + /** * Gap to the next poll given how long the order had been PAID at the last * attempt. The schedule the ticket names, as a step function: 5s, 15s, 60s, @@ -112,16 +133,20 @@ type SentPaymentStatus = "settled" | "failed" | "pending" | "unknown" * (`payment.statusId`, `payment.status.id`, `payment.failureId`), so the one * reader, `paymentSendStatusFromIbex`, maps both. * - * No ref → "unknown". That is an order that crashed between the IBEX call and - * the transition that would have written the ref, or a 200 whose - * `transaction.id` was empty. THERE IS NO HASH-BASED FALLBACK: the - * `getAccountTransactions` (IBEX `G`) 200 items carry only id / createdAt / - * accountId / amount / networkFee / exchangeRateCurrencySats / currencyId / - * transactionTypeId — no `payment.hash` and no `bolt11` — and `invoiceFromHash` - * resolves only invoices WE issued. Such an order stays PAYMENT_PENDING and is - * warned on after PAYMENT_PENDING_WARN_MS; resolving it needs the IBEX - * dashboard. (The LND-rail `lnpayments` collection is not consulted: nothing - * writes it on this IBEX-custodial deployment.) + * No ref → "unknown". That is an order whose send errored before IBEX handed + * back an id (a socket reset, gateway 5xx or timeout after IBEX may have + * accepted the request), one that crashed between the IBEX call and the + * transition that would have written the ref, or a 200 whose `transaction.id` + * was empty. THERE IS NO HASH-BASED FALLBACK: the `getAccountTransactions` + * (IBEX `G`) 200 items carry only id / createdAt / accountId / amount / + * networkFee / exchangeRateCurrencySats / currencyId / transactionTypeId — no + * `payment.hash` and no `bolt11` — and `invoiceFromHash` resolves only + * invoices WE issued. The caller falls back to the vendor instead + * (`fetchAndSettle`): a vendor `fulfilled` is proof of payment. An order the + * vendor cannot vouch for either stays PAYMENT_PENDING and is warned on after + * PAYMENT_PENDING_WARN_MS; resolving it needs the IBEX dashboard. (The LND-rail + * `lnpayments` collection is not consulted: nothing writes it on this + * IBEX-custodial deployment.) * * An IbexError (network, 5xx, 404) is "unknown" too: it says nothing about * whether money moved, and the next run retries. An UnconfirmedIbexPayment — @@ -193,6 +218,62 @@ const releaseLock = async (token: string): Promise => { } } +/** + * Book what a vendor poll did to an order. `settleOrderFromVendor` performs + * and reports every transition itself; this only keeps the run summary and the + * in-memory poll schedule honest. + */ +const countVendorOutcome = ( + before: GiftCardOrder, + after: GiftCardOrder, + summary: GiftCardReconcileSummary, +): void => { + const wasPaid = before.status === GiftCardOrderStatus.Paid + switch (after.status) { + case GiftCardOrderStatus.Fulfilled: + if (!wasPaid) summary.paymentSettled += 1 + summary.fulfilled += 1 + lastAttemptAt.delete(after.id) + return + case GiftCardOrderStatus.Paid: + // Vendor vouched for the payment but the claim could not be stored yet; + // the PAID path picks it up on its schedule. + if (!wasPaid) summary.paymentSettled += 1 + lastAttemptAt.set(after.id, Date.now()) + return + case GiftCardOrderStatus.RefundRequired: + summary.refundRequired += 1 + lastAttemptAt.delete(after.id) + return + default: + return + } +} + +/** + * Ask the vendor about an order IBEX could not account for. Returns the order + * the vendor's answer produced when it moved the order, null when it did not + * (still awaiting payment, vendor unreachable, or the vendor's word is not + * enough for this state — `settleOrderFromVendor` decides). + */ +const settleFromVendorIfMoved = async ( + order: GiftCardOrder, + summary: GiftCardReconcileSummary, +): Promise => { + if (!order.providerOrderId) return null + const settled = await fetchAndSettle(order) + if (settled instanceof Error) { + logger.info( + { orderId: order.id, status: order.status, error: settled.constructor.name }, + "Vendor poll for an unaccounted payment did not settle", + ) + return null + } + if (settled.status === order.status) return null + countVendorOutcome(order, settled, summary) + return settled +} + const settleAsPaid = async ( order: GiftCardOrder, summary: GiftCardReconcileSummary, @@ -233,13 +314,13 @@ const processExpiry = async ( ): Promise => { if (order.expiresAt.getTime() >= now.getTime()) return - // An INVOICE_ISSUED order may have been paid by a call that crashed before - // recording it. One re-read before writing it off: settled → PAID, in - // flight → leave it for the PAYMENT_PENDING path next run. Only possible - // when the IBEX transaction id was persisted; a crash BETWEEN the IBEX call - // and the transition that writes it leaves no ref, the lookup answers - // "unknown", and the order is expired — the one window where a paid invoice - // can be written off, logged here so it is at least visible. + // An INVOICE_ISSUED order may have been paid by a call that crashed or + // errored before recording it. One IBEX re-read before writing it off: + // settled → PAID, in flight → leave it for the next run. When IBEX cannot + // answer (no transaction id was ever persisted, or the re-read failed) the + // vendor gets the last word: a card that shipped is proof of payment, and + // `settleOrderFromVendor` records it as such. Only an invoice neither IBEX + // nor the vendor knows as paid is expired. if (order.status === GiftCardOrderStatus.InvoiceIssued) { const payment = await lookupSentPaymentStatus(order) if (payment === "settled") { @@ -253,6 +334,10 @@ const processExpiry = async ( ) return } + if (payment === "unknown") { + const moved = await settleFromVendorIfMoved(order, summary) + if (moved) return + } } const expired = await GiftCardOrdersRepository().transition({ @@ -299,20 +384,30 @@ const processPendingPayment = async ( }) return } - default: { - const pendingSince = lastTransitionAt(order, GiftCardOrderStatus.PaymentPending) - if (now.getTime() - pendingSince.getTime() > PAYMENT_PENDING_WARN_MS) { - logger.warn( - { - orderId: order.id, - paymentHash: order.paymentHash, - providerPaymentRef: order.providerPaymentRef, - since: pendingSince, - }, - "Gift card payment has been pending for over an hour with no resolvable status", - ) - } + case "unknown": { + // IBEX cannot account for this send (no transaction id, or the re-read + // failed). The vendor can: `fulfilled` settles the order as paid and + // fulfilled in one step. Anything else leaves it pending for next run. + const moved = await settleFromVendorIfMoved(order, summary) + if (moved) return + break } + case "pending": + // IBEX still reports the send in flight; its word beats a vendor poll. + break + } + + const pendingSince = lastTransitionAt(order, GiftCardOrderStatus.PaymentPending) + if (now.getTime() - pendingSince.getTime() > PAYMENT_PENDING_WARN_MS) { + logger.warn( + { + orderId: order.id, + paymentHash: order.paymentHash, + providerPaymentRef: order.providerPaymentRef, + since: pendingSince, + }, + "Gift card payment has been pending for over an hour with no resolvable status", + ) } } @@ -325,6 +420,21 @@ const processPaid = async ( const nowMs = now.getTime() if (nowMs - paidAtMs >= GIFT_CARD_PAID_TIMEOUT_MS) { + // One final vendor poll before escalating. A worker gap longer than 24h + // (outage, kill switch, stuck lock) must not turn every order the vendor + // fulfilled in the meantime into a refund. + lastAttemptAt.set(order.id, nowMs) + const settled = await fetchAndSettle(order) + if (settled instanceof Error) { + logger.warn( + { orderId: order.id, error: settled.constructor.name }, + "Final vendor poll before fulfilment timeout failed; escalating", + ) + } else if (settled.status !== GiftCardOrderStatus.Paid) { + countVendorOutcome(order, settled, summary) + return + } + const refund = await GiftCardOrdersRepository().transition({ id: order.id, from: [GiftCardOrderStatus.Paid], @@ -340,7 +450,11 @@ const processPaid = async ( status: "failed", order: refund, error: "fulfillment-timeout", - meta: { reason: "fulfillment-timeout" }, + meta: { + reason: "fulfillment-timeout", + lastVendorPoll: + settled instanceof Error ? settled.constructor.name : "not-fulfilled", + }, }) return } @@ -458,12 +572,27 @@ export const reconcileGiftCardOrders = async ( } /** - * Cron entry (src/servers/cron.ts). Self-guards on the master switch; a - * returned error is thrown so the cron runner logs and counts the failure the - * way it does every other task. + * Is there anything for the worker to do? One indexed `limit: 1` read, so an + * idle deployment (feature off, nothing in flight) costs no lock and no batch + * listings per tick. A repository fault answers true: the run itself logs the + * listing failure, which is the signal an operator needs. + */ +export const hasOpenGiftCardOrders = async (): Promise => { + const open = await GiftCardOrdersRepository().listByStatus({ + statuses: [...GIFT_CARD_OPEN_STATUSES], + limit: 1, + }) + if (open instanceof Error) return true + return open.length > 0 +} + +/** + * Cron entry (src/servers/cron.ts). Gated on open orders, NOT on the master + * switch: see the module doc. A returned error is thrown so the cron runner + * logs and counts the failure the way it does every other task. */ export const reconcileGiftCardOrdersJob = async (): Promise => { - if (!GiftCardsConfig?.enabled) return + if (!(await hasOpenGiftCardOrders())) return const summary = await reconcileGiftCardOrders() if (summary instanceof Error) throw summary logger.info({ summary }, "gift card reconcile finished") @@ -474,17 +603,18 @@ export const GIFT_CARD_RECONCILE_INTERVAL_MS = 30 * SECOND /** * In-process schedule for the trigger server, so a PAID order reaches * FULFILLED in seconds rather than at the next 15-minute cron. Overlapping - * ticks are skipped locally; cross-pod overlap is the Redis lock's job. + * ticks are skipped locally; cross-pod overlap is the Redis lock's job. Runs + * regardless of `giftCards.enabled`; each tick first checks for open orders. */ export const startGiftCardReconcileInterval = ( intervalMs: number = GIFT_CARD_RECONCILE_INTERVAL_MS, -): NodeJS.Timeout | null => { - if (!GiftCardsConfig?.enabled) return null +): NodeJS.Timeout => { let running = false return setInterval(async () => { if (running) return running = true try { + if (!(await hasOpenGiftCardOrders())) return const summary = await reconcileGiftCardOrders() if (summary instanceof Error) { logger.warn({ error: summary }, "gift card reconcile tick failed") diff --git a/src/app/gift-cards/settle-order.ts b/src/app/gift-cards/settle-order.ts index 2017f61a9..5d54e534c 100644 --- a/src/app/gift-cards/settle-order.ts +++ b/src/app/gift-cards/settle-order.ts @@ -1,6 +1,6 @@ import { GiftCardOrderStateError, GiftCardOrderStatus } from "@domain/gift-cards" import { encryptGiftCardClaim } from "@services/gift-cards/claim-crypto" -import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" +import { getRegisteredGiftCardProviderOrError } from "@services/gift-cards/registry" import { baseLogger } from "@services/logger" import { GiftCardOrdersRepository } from "@services/mongoose" import { @@ -209,13 +209,18 @@ const vendorFailed = async ( /** * Ask the vendor where the order stands and settle on the answer. The - * reconcile worker's PAID path, and the purchase mutation's single - * fulfilled-already? poll. + * reconcile worker's PAID path, its fallback for payments IBEX cannot account + * for, and the purchase mutation's single fulfilled-already? poll. + * + * Resolves the order's provider by registration, not by `enabled`: the kill + * switch (`giftCards.enabled`, `providers..enabled`) stops NEW money + * leaving via quote/purchase. An order that already exists has already paid or + * may have, and refusing to look it up would strand the customer's code. */ export const fetchAndSettle = async ( order: GiftCardOrder, ): Promise => { - const provider = getEnabledGiftCardProvider(order.providerId) + const provider = getRegisteredGiftCardProviderOrError(order.providerId) if (provider instanceof Error) return provider if (!order.providerOrderId) { diff --git a/src/config/yaml.ts b/src/config/yaml.ts index cd0d63f40..7de50a164 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -324,6 +324,29 @@ export const getGiftCardPurchaseAttemptLimits = () => ({ blockDuration: toSeconds(300), // 5 minutes }) +/** + * The gift card QUOTE, per account. + * + * Every call is a live POST to the vendor's quote endpoint through the ONE + * shared reseller login — the login every purchase this provider serves goes + * through. Cheaper to abuse than the purchase next door: no idempotency key, no + * wallet, nothing written, so nothing short-circuits before the vendor + * round-trip. A vendor that throttles or locks that login turns one client's + * loop (a mobile re-render bug is enough) into GiftCardVendorUnavailableError + * on every customer's purchase — Critical, pages. + * + * Looser than the purchase because the honest client behaviour is different: a + * confirm screen renders this, and a customer adjusting a value or quantity can + * legitimately ask several times a minute. Buying that often is not. Same block + * as the purchase: a quote is good until `expiresAt`, so a client told to back + * off loses nothing by reusing the last one. + */ +export const getGiftCardQuoteAttemptLimits = () => ({ + points: 30, + duration: toSeconds(60), // 1 minute + blockDuration: toSeconds(300), // 5 minutes +}) + /** * The card top-up allowance READ, per account. * diff --git a/src/domain/gift-cards/index.types.d.ts b/src/domain/gift-cards/index.types.d.ts index 39f465935..637bfde5b 100644 --- a/src/domain/gift-cards/index.types.d.ts +++ b/src/domain/gift-cards/index.types.d.ts @@ -60,7 +60,12 @@ type GiftCardProviderOrder = { readonly providerOrderId: GiftCardProviderOrderId readonly paymentRequest: string // BOLT11 readonly amountSats: Satoshis - readonly expiresAt: Date + /** + * Vendor-stated order expiry, when the vendor reports one; null when it does + * not. Adapters never fabricate it: the BOLT11's own expiry is decoded by + * `purchaseGiftCard` and bounds how long the order stays payable. + */ + readonly expiresAt: Date | null } type GiftCardProviderOrderStatus = diff --git a/src/domain/rate-limit/errors.ts b/src/domain/rate-limit/errors.ts index 11056d7ae..7d9975d71 100644 --- a/src/domain/rate-limit/errors.ts +++ b/src/domain/rate-limit/errors.ts @@ -22,6 +22,7 @@ export class InviteCreateRateLimiterExceededError extends RateLimiterExceededErr export class InviteTargetRateLimiterExceededError extends RateLimiterExceededError {} export class FygaroCheckoutCreateRateLimiterExceededError extends RateLimiterExceededError {} export class GiftCardPurchaseRateLimiterExceededError extends RateLimiterExceededError {} +export class GiftCardQuoteRateLimiterExceededError extends RateLimiterExceededError {} export class FygaroTopupAllowanceRateLimiterExceededError extends RateLimiterExceededError {} // ENG-573: per-account budget on payment-send *attempts* (every call to a send // mutation, including ones the send guard rejects), so a probe that walks the diff --git a/src/domain/rate-limit/index.ts b/src/domain/rate-limit/index.ts index 0eee3b4e4..f9228eb54 100644 --- a/src/domain/rate-limit/index.ts +++ b/src/domain/rate-limit/index.ts @@ -4,6 +4,7 @@ import { getFailedLoginAttemptPerLoginIdentifierLimits, getFygaroCheckoutCreateAttemptLimits, getGiftCardPurchaseAttemptLimits, + getGiftCardQuoteAttemptLimits, getFygaroTopupAllowanceAttemptLimits, getInviteCreateAttemptLimits, getInviteTargetAttemptLimits, @@ -21,6 +22,7 @@ import { ConsentLogIpRateLimiterExceededError, FygaroCheckoutCreateRateLimiterExceededError, GiftCardPurchaseRateLimiterExceededError, + GiftCardQuoteRateLimiterExceededError, FygaroTopupAllowanceRateLimiterExceededError, InviteCreateRateLimiterExceededError, InviteTargetRateLimiterExceededError, @@ -48,6 +50,7 @@ export const RateLimitPrefix = { inviteTarget: "invite_target", fygaroCheckoutCreate: "fygaro_checkout_create", giftCardPurchase: "gift_card_purchase", + giftCardQuote: "gift_card_quote", consentLog: "consent_log_ip", fygaroTopupAllowance: "fygaro_topup_allowance", paymentSend: "payment_send", @@ -122,6 +125,16 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = { limits: getGiftCardPurchaseAttemptLimits(), error: GiftCardPurchaseRateLimiterExceededError, }, + // The read side of the same vendor dependency, and the CHEAPER one to abuse: + // no idempotency key, no wallet, nothing written, so every call is a live + // POST through the one shared reseller login that every purchase also needs. + // Its own key so a customer who has spent the purchase budget can still be + // told what a card costs. + giftCardQuote: { + key: RateLimitPrefix.giftCardQuote, + limits: getGiftCardQuoteAttemptLimits(), + error: GiftCardQuoteRateLimiterExceededError, + }, // The read side of the same ERPNext dependency, and the CHEAPER one to abuse: // no amount argument, so nothing can short-circuit before the trailing-24h // list query runs. Its own key so a customer who has spent the mutation's diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 86bfdb7cf..aa42ede01 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -1261,6 +1261,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "GiftCardQuoteRateLimiterExceededError": + message = error.message + return bridgeGqlError({ + code: "GIFT_CARD_QUOTE_RATE_LIMITED", + message, + }) + case "UnknownGiftCardError": message = "Something went wrong with your gift card request" return bridgeGqlError({ diff --git a/src/graphql/public/root/mutation/gift-card-purchase.ts b/src/graphql/public/root/mutation/gift-card-purchase.ts index d4b89eff3..0e57363da 100644 --- a/src/graphql/public/root/mutation/gift-card-purchase.ts +++ b/src/graphql/public/root/mutation/gift-card-purchase.ts @@ -95,7 +95,8 @@ const GiftCardPurchaseMutation = GT.Field< // owner-scoped read — so it is called rather than re-implemented. If the // claim cannot be read, the order is still returned (the card IS issued) and // the error rides alongside so the client does not show a FULFILLED order - // with a silently empty claim. + // with a silently empty claim. The read reports a decrypt failure as data + // (`claimError`) rather than as its own error, so both are mapped here. const withClaim = await getGiftCardOrderForAccount({ accountId: domainAccount.id, orderId: order.id, @@ -106,6 +107,12 @@ const GiftCardPurchaseMutation = GT.Field< order: toGiftCardOrderSource(order), } } + if (withClaim.claimError) { + return { + errors: [mapAndParseErrorForGqlResponse(withClaim.claimError)], + order: toGiftCardOrderSource(withClaim.order), + } + } return { errors: [], order: toGiftCardOrderSource(withClaim.order, withClaim.claim) } }, diff --git a/src/graphql/public/root/query/gift-card-order.ts b/src/graphql/public/root/query/gift-card-order.ts index 9c4194127..e554321c2 100644 --- a/src/graphql/public/root/query/gift-card-order.ts +++ b/src/graphql/public/root/query/gift-card-order.ts @@ -1,10 +1,13 @@ import { getGiftCardOrderForAccount } from "@app/gift-cards" import { GiftCardOrderNotFoundError } from "@domain/gift-cards" +import { ErrorLevel } from "@domain/shared" import { mapError } from "@graphql/error-map" import { GT } from "@graphql/index" import GiftCardOrder, { toGiftCardOrderSource, } from "@graphql/public/types/object/gift-card-order" +import { baseLogger } from "@services/logger" +import { recordExceptionInCurrentSpan } from "@services/tracing" type GiftCardOrderArgs = { id: string } @@ -14,7 +17,8 @@ const GiftCardOrderQuery = GT.Field({ + extensions: { complexity: 120 }, // one live vendor POST per resolve; see giftCardPurchase type: GT.NonNull(GiftCardQuote), description: "What a purchase would cost right now, in sats, for a product at a face value. " + diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 77506aeed..74024f72b 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1240,7 +1240,7 @@ enum GiftCardOrderStatus { PAID """ - Terminal. The Lightning payment failed and nothing left the wallet. Safe to try again with a new idempotencyKey. + Terminal. Only proven refusals land here (insufficient balance, a rejected send, a bad idempotency key): nothing left the wallet and it is safe to try again with a new idempotencyKey. An indeterminate send error goes to PAYMENT_PENDING instead, never here. """ PAYMENT_FAILED @@ -2414,7 +2414,7 @@ type Query { ): GiftCardProductConnection! """ - One of the calling account's gift card orders, by id — the endpoint to poll after giftCardPurchase, and the ONLY place the claim is returned. Null when the id is unknown or belongs to another account; the two are deliberately indistinguishable. + One of the calling account's gift card orders, by id — the endpoint to poll after giftCardPurchase, and the ONLY place the claim is returned. Null when the id is unknown or belongs to another account; the two are deliberately indistinguishable. A FULFILLED order whose claim cannot currently be read is still returned, with `claim` null. """ giftCardOrder( """A GiftCardOrder.id from giftCardPurchase or giftCardOrders.""" diff --git a/src/graphql/public/types/object/gift-card-order.ts b/src/graphql/public/types/object/gift-card-order.ts index a45a7ff72..4d69fa0e9 100644 --- a/src/graphql/public/types/object/gift-card-order.ts +++ b/src/graphql/public/types/object/gift-card-order.ts @@ -58,8 +58,10 @@ const STATUS_ENUM_VALUES: Record< PAYMENT_FAILED: { value: OrderStatus.PaymentFailed, description: - "Terminal. The Lightning payment failed and nothing left the wallet. Safe to " + - "try again with a new idempotencyKey.", + "Terminal. Only proven refusals land here (insufficient balance, a rejected " + + "send, a bad idempotency key): nothing left the wallet and it is safe to try " + + "again with a new idempotencyKey. An indeterminate send error goes to " + + "PAYMENT_PENDING instead, never here.", }, EXPIRED: { value: OrderStatus.Expired, diff --git a/src/servers/cron.ts b/src/servers/cron.ts index bab8012ef..92d242b71 100644 --- a/src/servers/cron.ts +++ b/src/servers/cron.ts @@ -100,8 +100,11 @@ const checkFygaroFloatJob = async () => { // Gift card fulfilment safety net (ENG-581): expires stale invoices, re-reads // pending payments, polls the vendor for PAID orders and escalates to // REFUND_REQUIRED after 24h. The trigger server runs the same function every -// 30s under the same Redis lock; this pass catches what that misses. Self-guards -// on GiftCardsConfig.enabled and never throws for a single bad order. +// 30s under the same Redis lock; this pass catches what that misses. +// Deliberately NOT gated on GiftCardsConfig.enabled: the kill switch stops new +// money leaving, but orders already paid still need their codes and the 24h +// alert must still fire. The job gates itself on "any non-terminal order +// exists" instead, and never throws for a single bad order. const reconcileGiftCardOrdersJob = async () => { await GiftCards.reconcileGiftCardOrdersJob() } diff --git a/src/servers/trigger.ts b/src/servers/trigger.ts index d81370435..48b2c8b24 100644 --- a/src/servers/trigger.ts +++ b/src/servers/trigger.ts @@ -508,8 +508,9 @@ const main = () => { publishCurrentPrice() // Gift card fulfilment worker (ENG-581): every 30s, under a Redis lock shared - // with the cron pass, so a PAID order reaches FULFILLED in seconds. No-op - // (returns null) while GiftCardsConfig.enabled is false. + // with the cron pass, so a PAID order reaches FULFILLED in seconds. Runs + // regardless of GiftCardsConfig.enabled (the kill switch only stops new + // purchases); each tick first checks that a non-terminal order exists. startGiftCardReconcileInterval() if (getSwapConfig().feeAccountingEnabled) listenerSwapMonitor() diff --git a/src/services/gift-cards/bitcoin-company/client.ts b/src/services/gift-cards/bitcoin-company/client.ts index 26b6fab9f..ff0234c1d 100644 --- a/src/services/gift-cards/bitcoin-company/client.ts +++ b/src/services/gift-cards/bitcoin-company/client.ts @@ -84,7 +84,8 @@ const RETRY_BASE_MS = 200 const RETRY_JITTER_MS = 100 export const CATALOG_PAGE_SIZE = 500 -const CATALOG_MAX_PAGES = 100 +/** Hard stop so a vendor that never returns an empty page cannot loop forever. */ +export const CATALOG_MAX_PAGES = 200 const LOG_BODY_PREVIEW_CHARS = 2000 @@ -292,13 +293,27 @@ export class BitcoinCompanyClient { /** * Full catalog, paginated. Public endpoint, no auth. A product row that * fails validation is skipped and counted, not fatal to the sync. + * + * The offset advances by the rows the vendor actually returned, never by the + * requested page size: a vendor that clamps `size` (100 is a common cap) or + * returns short pages for any other reason would otherwise silently truncate + * the catalog or skip products. The only end-of-catalog signal is an empty + * page, so every sync costs one extra request. Invalid rows still count + * toward the offset; they occupy a slot on the vendor's side regardless. + * + * Hitting the page cap without an empty page fails the sync rather than + * returning a partial catalog: a vendor that ignores `offset` would + * otherwise hand the sync a duplicate-heavy list that overwrites the last + * good catalog wholesale. Failing keeps the previous catalog serving. */ async listProducts(): Promise { const products: VendorProduct[] = [] let skipped = 0 + let offset = 0 + let pages = 0 + let reachedEnd = false - for (let page = 0; page < CATALOG_MAX_PAGES; page++) { - const offset = page * CATALOG_PAGE_SIZE + while (pages < CATALOG_MAX_PAGES) { const result = await this.call({ op: "listProducts", method: "GET", @@ -308,6 +323,12 @@ export class BitcoinCompanyClient { schema: catalogPageSchema, }) if (result instanceof Error) return result + pages++ + + if (result.svs.length === 0) { + reachedEnd = true + break + } for (const row of result.svs) { const parsed = vendorProductSchema.safeParse(row) @@ -317,8 +338,15 @@ export class BitcoinCompanyClient { skipped++ } } + offset += result.svs.length + } - if (result.svs.length < CATALOG_PAGE_SIZE) break + if (!reachedEnd) { + baseLogger.warn( + { provider: PROVIDER, op: "listProducts", pages, offset, kept: products.length }, + "Bitcoin Company catalog page cap reached before an empty page; failing the sync", + ) + return new GiftCardVendorUnavailableError() } if (skipped > 0) { diff --git a/src/services/gift-cards/bitcoin-company/index.ts b/src/services/gift-cards/bitcoin-company/index.ts index 31670b4a3..bfeaf5b7c 100644 --- a/src/services/gift-cards/bitcoin-company/index.ts +++ b/src/services/gift-cards/bitcoin-company/index.ts @@ -115,7 +115,7 @@ export const BitcoinCompanyProvider = ( label: reference, }) if (vendor instanceof Error) return vendor - return mapVendorPurchase(vendor, now()) + return mapVendorPurchase(vendor) } const getOrder = async ( diff --git a/src/services/gift-cards/bitcoin-company/mapping.ts b/src/services/gift-cards/bitcoin-company/mapping.ts index e4935e7ad..d405492e7 100644 --- a/src/services/gift-cards/bitcoin-company/mapping.ts +++ b/src/services/gift-cards/bitcoin-company/mapping.ts @@ -21,7 +21,6 @@ import { export const BITCOIN_COMPANY_PROVIDER_ID: GiftCardProviderId = "bitcoinCompany" export const QUOTE_TTL_MS = 60 * 1000 -export const ORDER_TTL_MS = 15 * 60 * 1000 /** Vendor money is in major units of the product currency; the domain is minor units. */ export const toMinorUnits = (major: number): number => Math.round(major * 100) @@ -131,14 +130,17 @@ export const mapVendorQuote = ({ expiresAt: new Date(now.getTime() + QUOTE_TTL_MS), }) -export const mapVendorPurchase = ( - vendor: VendorPurchase, - now: Date, -): GiftCardProviderOrder => ({ +/** + * The vendor's purchase response carries no order expiry; the only expiry is + * the one encoded in the BOLT11 itself, which `purchaseGiftCard` decodes. Not + * invented here: a fabricated "now + 15 min" would silently outlive a shorter + * invoice and keep an unpayable order open. + */ +export const mapVendorPurchase = (vendor: VendorPurchase): GiftCardProviderOrder => ({ providerOrderId: toGiftCardProviderOrderId(String(vendor.uuid)), paymentRequest: vendor.invoice, amountSats: toSats(Math.round(vendor.amount)), - expiresAt: new Date(now.getTime() + ORDER_TTL_MS), + expiresAt: null, }) /** Returns null when there is nothing a customer could redeem. */ diff --git a/src/services/gift-cards/registry.ts b/src/services/gift-cards/registry.ts index 90e8d7dc6..e9f29807c 100644 --- a/src/services/gift-cards/registry.ts +++ b/src/services/gift-cards/registry.ts @@ -23,6 +23,21 @@ export const getRegisteredGiftCardProvider = ( id: GiftCardProviderId, ): IGiftCardProvider | undefined => providers.get(id) +/** + * Registered provider by id, regardless of `enabled`. For orders that already + * exist: settlement and reconciliation must keep working after the kill switch + * flips, otherwise a customer who has already paid never receives their code + * and the 24h REFUND_REQUIRED alert goes quiet. New money (quote / purchase) + * goes through `getEnabledGiftCardProvider`, never this. + */ +export const getRegisteredGiftCardProviderOrError = ( + id: GiftCardProviderId, +): IGiftCardProvider | GiftCardProviderUnavailableError => { + const provider = providers.get(id) + if (!provider) return new GiftCardProviderUnavailableError() + return provider +} + export const isGiftCardProviderEnabled = (id: GiftCardProviderId): boolean => GiftCardsConfig.enabled === true && GiftCardsConfig.providers[id]?.enabled === true @@ -49,7 +64,11 @@ export const getGiftCardProviderForCountry = ( return provider } -/** Enabled provider by id, for orders that already know their provider. */ +/** + * Enabled provider by id. For quote and purchase only — the paths that create + * new orders and move new money. An order that already exists settles through + * `getRegisteredGiftCardProviderOrError`, so the kill switch never strands it. + */ export const getEnabledGiftCardProvider = ( id: GiftCardProviderId, ): IGiftCardProvider | GiftCardProviderUnavailableError => { diff --git a/test/flash/unit/app/authentication/ops-events-hooks.spec.ts b/test/flash/unit/app/authentication/ops-events-hooks.spec.ts index cc71f24ed..f285c0266 100644 --- a/test/flash/unit/app/authentication/ops-events-hooks.spec.ts +++ b/test/flash/unit/app/authentication/ops-events-hooks.spec.ts @@ -31,6 +31,8 @@ jest.mock("@config", () => { getFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(() => limits), getFygaroCheckoutCreateAttemptLimits: jest.fn(() => limits), getFygaroTopupAllowanceAttemptLimits: jest.fn(() => limits), + getGiftCardPurchaseAttemptLimits: jest.fn(() => limits), + getGiftCardQuoteAttemptLimits: jest.fn(() => limits), getInvoiceCreateAttemptLimits: jest.fn(() => limits), getInvoiceCreateForRecipientAttemptLimits: jest.fn(() => limits), getPaymentSendAttemptLimits: jest.fn(() => limits), diff --git a/test/flash/unit/app/authentication/request-code-destination.spec.ts b/test/flash/unit/app/authentication/request-code-destination.spec.ts index 95adf598c..ff25086d1 100644 --- a/test/flash/unit/app/authentication/request-code-destination.spec.ts +++ b/test/flash/unit/app/authentication/request-code-destination.spec.ts @@ -24,6 +24,8 @@ jest.mock("@config", () => { getInviteTargetAttemptLimits: jest.fn(() => limits), getFygaroCheckoutCreateAttemptLimits: jest.fn(() => limits), getFygaroTopupAllowanceAttemptLimits: jest.fn(() => limits), + getGiftCardPurchaseAttemptLimits: jest.fn(() => limits), + getGiftCardQuoteAttemptLimits: jest.fn(() => limits), getOnChainAddressCreateAttemptLimits: jest.fn(() => limits), getRequestCodePerIpLimits: jest.fn(() => limits), getRequestCodePerLoginIdentifierLimits: jest.fn(() => limits), diff --git a/test/flash/unit/app/gift-cards/get-order.spec.ts b/test/flash/unit/app/gift-cards/get-order.spec.ts new file mode 100644 index 000000000..d35ac2c9d --- /dev/null +++ b/test/flash/unit/app/gift-cards/get-order.spec.ts @@ -0,0 +1,173 @@ +import { UnknownRepositoryError } from "@domain/errors" +import { GiftCardClaimCryptoError, GiftCardOrderNotFoundError } from "@domain/gift-cards" + +const mockDecrypt = jest.fn() +const mockAddAttributes = jest.fn() + +let repo: FakeOrdersRepo + +jest.mock("@services/mongoose", () => ({ + GiftCardOrdersRepository: () => repo, +})) +jest.mock("@services/gift-cards/claim-crypto", () => ({ + decryptGiftCardClaim: (...a: unknown[]) => mockDecrypt(...a), +})) +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: (...a: unknown[]) => mockAddAttributes(...a), +})) + +import { + getGiftCardOrderForAccount, + type GiftCardOrderWithClaim, +} from "@app/gift-cards/get-order" + +import { + ACCOUNT_ID, + allMockCallText, + makeFakeOrdersRepo, + makeOrder, + NOW_MS, + type FakeOrdersRepo, +} from "./fixtures" + +const OTHER_ACCOUNT_ID = "6a8203ce490716aa69381499" as AccountId +const ORDER_ID = "order-1" as GiftCardOrderId +const CIPHERTEXT = "enc:CIPHERTEXT-MUST-NEVER-LEAK-4c1d" +const KEY_ID = "0123456789abcdef" +const CLAIM_CODE = "CLAIM-CODE-SECRET-9f8e7d" +const CLAIM: GiftCardClaim = { + codes: [{ label: "Code", value: CLAIM_CODE }], + claimLink: null, + barcode: null, +} + +const fulfilledOrder = (overrides: Partial = {}) => + repo.seed( + makeOrder({ + status: "FULFILLED", + claimCiphertext: CIPHERTEXT, + claimKeyId: KEY_ID, + fulfilledAt: new Date(NOW_MS), + paidSats: 40_100 as Satoshis, + ...overrides, + }), + ) + +const read = (accountId: AccountId = ACCOUNT_ID, orderId: GiftCardOrderId = ORDER_ID) => + getGiftCardOrderForAccount({ accountId, orderId }) + +/** Narrow a result to the data shape, failing loudly if it came back as an error. */ +const ok = ( + result: GiftCardOrderWithClaim | ApplicationError, +): GiftCardOrderWithClaim => { + if (result instanceof Error) + throw new Error(`expected data, got ${result.constructor.name}`) + return result +} + +beforeEach(() => { + jest.clearAllMocks() + repo = makeFakeOrdersRepo() + mockDecrypt.mockReturnValue(CLAIM) +}) + +describe("getGiftCardOrderForAccount", () => { + describe("errors — only when the order itself cannot be read", () => { + it("returns NotFound for an unknown id", async () => { + const result = await read(ACCOUNT_ID, "no-such-order" as GiftCardOrderId) + + expect(result).toBeInstanceOf(GiftCardOrderNotFoundError) + expect(mockDecrypt).not.toHaveBeenCalled() + }) + + it("returns NotFound for another account's order — indistinguishable from unknown — and never decrypts", async () => { + fulfilledOrder() + + const result = await read(OTHER_ACCOUNT_ID) + + expect(result).toBeInstanceOf(GiftCardOrderNotFoundError) + expect(mockDecrypt).not.toHaveBeenCalled() + // Nothing about the order goes on the span for a caller who may not see it. + expect(mockAddAttributes).not.toHaveBeenCalled() + }) + + it("passes a repository fault through as the error", async () => { + const fault = new UnknownRepositoryError("mongo down") + repo.findById.mockResolvedValueOnce(fault) + + expect(await read()).toBe(fault) + expect(mockDecrypt).not.toHaveBeenCalled() + }) + }) + + describe("before FULFILLED", () => { + it("returns the order with claim and claimError both null, without decrypting", async () => { + repo.seed(makeOrder({ status: "PAID", paidSats: 40_100 as Satoshis })) + + const result = ok(await read()) + + expect(result.order.id).toBe(ORDER_ID) + expect(result.order.status).toBe("PAID") + expect(result.claim).toBeNull() + expect(result.claimError).toBeNull() + expect(mockDecrypt).not.toHaveBeenCalled() + }) + + it("does not decrypt a FULFILLED order that carries no ciphertext", async () => { + fulfilledOrder({ claimCiphertext: null, claimKeyId: null }) + + const result = ok(await read()) + + expect(result.claim).toBeNull() + expect(result.claimError).toBeNull() + expect(mockDecrypt).not.toHaveBeenCalled() + }) + }) + + describe("FULFILLED", () => { + it("decrypts the stored claim under the stored key id and returns it", async () => { + fulfilledOrder() + + const result = ok(await read()) + + expect(mockDecrypt).toHaveBeenCalledTimes(1) + expect(mockDecrypt).toHaveBeenCalledWith({ ciphertext: CIPHERTEXT, keyId: KEY_ID }) + expect(result.order.status).toBe("FULFILLED") + expect(result.claim).toEqual(CLAIM) + expect(result.claimError).toBeNull() + }) + + it("returns the order with claim null and the crypto error as DATA when the claim will not decrypt", async () => { + // A rotated key (or one pod on stale config) must not hide the order from + // its owner: the card IS issued and paid for. Only "cannot read the order" + // is an error; "cannot read its claim" rides back alongside the order so + // each caller decides how to surface it. + fulfilledOrder() + const cryptoError = new GiftCardClaimCryptoError("key rotated") + mockDecrypt.mockReturnValue(cryptoError) + + const result = await read() + + expect(result).not.toBeInstanceOf(Error) + const data = ok(result) + expect(data.order.id).toBe(ORDER_ID) + expect(data.order.status).toBe("FULFILLED") + expect(data.claim).toBeNull() + expect(data.claimError).toBe(cryptoError) + }) + + it("puts the order id and status on the span, never the ciphertext or the claim", async () => { + fulfilledOrder() + + await read() + + expect(mockAddAttributes).toHaveBeenCalledWith({ + "giftcard.orderId": ORDER_ID, + "giftcard.status": "FULFILLED", + }) + const traced = allMockCallText(mockAddAttributes) + expect(traced).not.toContain(CIPHERTEXT) + expect(traced).not.toContain(CLAIM_CODE) + }) + }) +}) diff --git a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts index 1832b9c4d..91d33ce03 100644 --- a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts +++ b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts @@ -1,4 +1,8 @@ -import { CouldNotFindWalletFromIdError, InvalidIdempotencyKeyError } from "@domain/errors" +import { + CouldNotFindWalletFromIdError, + IdempotencyKeyReuseError, + InvalidIdempotencyKeyError, +} from "@domain/errors" import { GiftCardIdempotencyKeyReuseError, GiftCardInvalidValueError, @@ -9,8 +13,16 @@ import { GiftCardVendorRejectedOrderError, GiftCardVendorUnavailableError, } from "@domain/gift-cards" +import { ResourceAttemptsLockServiceError } from "@domain/lock" import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" import { ValidationError } from "@domain/shared" +import { + CompletedInvoice, + FailedIbexPayment, + IbexError, + InsufficientIbexBalance, + UnconfirmedIbexPayment, +} from "@services/ibex/errors" const mockFindAccountById = jest.fn() const mockFindWalletById = jest.fn() @@ -63,6 +75,7 @@ jest.mock("@app/gift-cards/authorize-purchase", () => ({ })) jest.mock("@services/gift-cards/registry", () => ({ getEnabledGiftCardProvider: (...a: unknown[]) => mockGetProvider(...a), + getRegisteredGiftCardProviderOrError: (...a: unknown[]) => mockGetProvider(...a), })) jest.mock("@services/gift-cards/claim-crypto", () => ({ encryptGiftCardClaim: (...a: unknown[]) => mockEncrypt(...a), @@ -133,11 +146,12 @@ const CLAIM: GiftCardClaim = { barcode: null, } +// What the TBC adapter returns: no vendor-stated expiry, the BOLT11 carries it. const VENDOR_ORDER = { providerOrderId: "tbc-123" as GiftCardProviderOrderId, paymentRequest: PAYMENT_REQUEST, amountSats: 40_100 as Satoshis, - expiresAt: new Date(Date.now() + 10 * 60 * 1000), + expiresAt: null, } const decoded = (sats: number) => ({ @@ -418,6 +432,110 @@ describe("purchaseGiftCard", () => { expect(res).toBeInstanceOf(InvalidIdempotencyKeyError) expect(mockFindAccountById).not.toHaveBeenCalled() }) + + describe("replay of an unpaid INVOICE_ISSUED order", () => { + // A first attempt that died after issuing the invoice but before recording + // the payment's outcome. The row must not be handed back as "keep polling". + const issued = (overrides: Partial = {}) => + repo.seed( + makeOrder({ + status: "INVOICE_ISSUED", + idempotencyKey: "idem-1", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + paymentRequest: PAYMENT_REQUEST, + paymentHash: PAYMENT_HASH, + invoiceSats: 40_100 as Satoshis, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + statusHistory: [ + { status: "CREATED", at: new Date(Date.now() - 2000), reason: null }, + { status: "INVOICE_ISSUED", at: new Date(Date.now() - 1000), reason: null }, + ], + ...overrides, + }), + ) + + it("resumes from the pay step instead of returning the row", async () => { + const existing = issued() + const res = await purchase() + if (res instanceof Error) throw res + expect(res.id).toBe(existing.id) + expect(res.status).toBe("FULFILLED") + expect(res.paidSats).toBe(40_100) + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "CREATED", + "INVOICE_ISSUED", + "PAID", + "FULFILLED", + ]) + // Same key and fingerprint as the first attempt: a cached outcome is + // replayed, an in-flight attempt is refused, a send that never happened + // is made exactly once. + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + expect(mockPayInvoice.mock.calls[0][0]).toMatchObject({ + paymentRequest: PAYMENT_REQUEST, + senderWalletId: WALLET_ID, + idempotencyKey: `giftcard:${existing.id}`, + requestFingerprint: `ln|${PAYMENT_REQUEST}|giftcard|${existing.id}`, + }) + // Nothing upstream of the pay step runs again. + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockQuote).not.toHaveBeenCalled() + expect(mockAuthorize).not.toHaveBeenCalled() + expect(repo.create).not.toHaveBeenCalled() + expect(mockCreateOrder).not.toHaveBeenCalled() + }) + + it("a resumed send the first attempt already made replays as AlreadyPaid -> PAID", async () => { + issued() + mockPayInvoice.mockResolvedValue({ value: "already_paid" }) + mockGetOrder.mockResolvedValue({ kind: "paidPendingFulfillment" }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + }) + + it("a resumed send with an unknown outcome -> PAYMENT_PENDING", async () => { + issued() + mockPayInvoice.mockResolvedValue(new IbexError(new Error("timeout"))) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + }) + + it("an INVOICE_ISSUED order past its expiry is returned as-is", async () => { + const existing = issued({ expiresAt: new Date(Date.now() - 1) }) + const res = await purchase() + expect(res).toBe(existing) + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("does not resume while the kill switch is off: that is new money leaving", async () => { + mockConfig = makeGiftCardsConfig({ enabled: false }) + const existing = issued() + const res = await purchase() + expect(res).toBe(existing) + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("a CREATED order is returned as-is: vendor createOrder is not idempotent", async () => { + const existing = repo.seed( + makeOrder({ status: "CREATED", idempotencyKey: "idem-1" }), + ) + const res = await purchase() + expect(res).toBe(existing) + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("a PAYMENT_PENDING order is returned as-is for the worker to settle", async () => { + const existing = repo.seed( + makeOrder({ status: "PAYMENT_PENDING", idempotencyKey: "idem-1" }), + ) + const res = await purchase() + expect(res).toBe(existing) + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + }) }) describe("refusals before any order exists", () => { @@ -557,13 +675,39 @@ describe("purchaseGiftCard", () => { expect(res).toBeInstanceOf(GiftCardQuoteMismatchError) }) - it("order expiry is capped at the vendor invoice expiry", async () => { + it("order expiry is capped at the decoded BOLT11 expiry", async () => { const soon = new Date(Date.now() + 2 * 60 * 1000) - mockCreateOrder.mockResolvedValue({ ...VENDOR_ORDER, expiresAt: soon }) + mockDecodeInvoice.mockReturnValue({ ...decoded(40_100), expiresAt: soon }) const res = await purchase() if (res instanceof Error) throw res expect(res.expiresAt).toEqual(soon) }) + + it("falls back to the order TTL when the invoice carries no usable expiry", async () => { + mockDecodeInvoice.mockReturnValue({ ...decoded(40_100), expiresAt: new Date(NaN) }) + const res = await purchase() + if (res instanceof Error) throw res + const created = repo.create.mock.calls[0][0] as { expiresAt: Date } + expect(res.expiresAt).toEqual(created.expiresAt) + expect(res.expiresAt.getTime() - Date.now()).toBeGreaterThan(14 * 60 * 1000) + }) + + it("the order TTL caps an invoice that outlives it", async () => { + const late = new Date(Date.now() + 60 * 60 * 1000) + mockDecodeInvoice.mockReturnValue({ ...decoded(40_100), expiresAt: late }) + const res = await purchase() + if (res instanceof Error) throw res + const created = repo.create.mock.calls[0][0] as { expiresAt: Date } + expect(res.expiresAt).toEqual(created.expiresAt) + }) + + it("a vendor-stated expiry earlier than the invoice's is honoured", async () => { + const vendorSoon = new Date(Date.now() + 60 * 1000) + mockCreateOrder.mockResolvedValue({ ...VENDOR_ORDER, expiresAt: vendorSoon }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.expiresAt).toEqual(vendorSoon) + }) }) describe("payment outcomes", () => { @@ -577,15 +721,135 @@ describe("purchaseGiftCard", () => { expect(mockNotifyOpsEvent.mock.calls[1][0].meta.reason).toBe("payment-failed") }) - it("an error from the send -> PAYMENT_FAILED, the error is returned", async () => { - const sendError = new ValidationError("insufficient balance") - mockPayInvoice.mockResolvedValue(sendError) - const res = await purchase() - expect(res).toBe(sendError) - const order = [...repo.store.values()][0] - expect(order.status).toBe("PAYMENT_FAILED") - expect(order.failureReason).toContain("ValidationError") - expect(mockGetOrder).not.toHaveBeenCalled() + describe("errors that PROVE IBEX never accepted the send -> PAYMENT_FAILED", () => { + // Only these may tell the client "nothing left the wallet, buy again". + it.each([ + [ + "InsufficientIbexBalance (IBEX 400)", + () => + new InsufficientIbexBalance( + new Error("400"), + undefined, + "insufficient balance, account: abc", + ), + ], + [ + "FailedIbexPayment (corroborated FAILED on the 200)", + () => new FailedIbexPayment("no route"), + ], + [ + "InvalidIdempotencyKeyError (wrapper refused)", + () => new InvalidIdempotencyKeyError("k"), + ], + [ + "IdempotencyKeyReuseError (wrapper refused)", + () => new IdempotencyKeyReuseError(), + ], + ])("%s", async (_label, make) => { + const sendError = make() + mockPayInvoice.mockResolvedValue(sendError) + const res = await purchase() + expect(res).toBe(sendError) + const order = [...repo.store.values()][0] + expect(order.status).toBe("PAYMENT_FAILED") + expect(order.failureReason).toContain(sendError.constructor.name) + expect(mockGetOrder).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created", "order-failed"]) + }) + + it("a send-guard rejection (the guard runs before the IBEX call)", async () => { + mockSendGuardMode = "enforce" + const rejection = new ValidationError("over daily limit") + mockAuthorizeSend.mockResolvedValue(rejection) + // Mirror the wrapper: authorize immediately before execute, and a + // rejection short-circuits without calling IBEX. + mockPayInvoice.mockImplementation( + async (args: { authorize: () => Promise }) => { + const verdict = await args.authorize() + return verdict instanceof Error ? verdict : PaymentSendStatus.Success + }, + ) + const res = await purchase() + expect(res).toBe(rejection) + const order = [...repo.store.values()][0] + expect(order.status).toBe("PAYMENT_FAILED") + expect(order.failureReason).toContain("ValidationError") + }) + }) + + describe("errors that leave the outcome unknown -> PAYMENT_PENDING, never PAYMENT_FAILED", () => { + // A socket reset / gateway 5xx / timeout after IBEX accepted the request + // (the KNOWN GAP in @app/payments/idempotency) may have moved money. The + // pending order is returned so the client polls instead of buying again. + it.each([ + ["a generic IbexError", () => new IbexError(new Error("socket hang up"))], + ["UnconfirmedIbexPayment", () => new UnconfirmedIbexPayment("unreadable 200")], + [ + "CompletedInvoice (already prepared — possibly by our first attempt)", + () => new CompletedInvoice(new Error("payment already prepared")), + ], + [ + "a busy idempotency lock (concurrent same-key attempt in flight)", + () => new ResourceAttemptsLockServiceError(), + ], + ])("%s", async (_label, make) => { + const sendError = make() + mockPayInvoice.mockResolvedValue(sendError) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(res.providerPaymentRef).toBeNull() + expect(res.failureReason).toBeNull() + expect(res.statusHistory[res.statusHistory.length - 1].reason).toBe( + `payment-unconfirmed: ${sendError.constructor.name}`, + ) + expect(mockGetOrder).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created", "payment-pending"]) + expect(mockNotifyOpsEvent.mock.calls[1][0]).toMatchObject({ + status: "pending", + error: sendError.constructor.name, + meta: expect.objectContaining({ + reason: `payment-unconfirmed: ${sendError.constructor.name}`, + }), + }) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orderId: res.id, error: sendError.constructor.name }), + expect.stringContaining("PAYMENT_PENDING"), + ) + }) + + it("keeps the IBEX transaction id when the error came after IBEX answered", async () => { + mockPayInvoice.mockImplementation( + async (args: { onResponse?: (r: unknown) => void }) => { + args.onResponse?.({ transaction: { id: "ibex-tx-1" } }) + return new IbexError(new Error("connection reset while reading body")) + }, + ) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) + + it("a concurrent attempt that already moved the order on wins", async () => { + // The replay's send hits the busy lock while the first attempt, still + // running, records PAID. The replay must not clobber that. + mockPayInvoice.mockImplementation(async () => { + const [order] = [...repo.store.values()] + await repo.transition({ + id: order.id, + from: ["INVOICE_ISSUED"], + to: "PAID", + reason: "payment-settled", + patch: { paidSats: 40_100 as Satoshis, providerPaymentRef: "ibex-tx-1" }, + }) + return new ResourceAttemptsLockServiceError() + }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) }) it("Pending -> PAYMENT_PENDING, no vendor poll", async () => { diff --git a/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts index 19a8c3bb2..28d1a721f 100644 --- a/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts +++ b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts @@ -7,6 +7,7 @@ import { GiftCardsDisabledError, GiftCardVendorUnavailableError, } from "@domain/gift-cards" +import { GiftCardQuoteRateLimiterExceededError } from "@domain/rate-limit/errors" import { quoteGiftCard } from "@app/gift-cards/quote-gift-card" @@ -18,6 +19,8 @@ const mockResolveCountry = jest.fn() const mockGetGiftCardProduct = jest.fn() const mockGetProvider = jest.fn() const mockQuote = jest.fn() +const mockConsumeLimiter = jest.fn() +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } jest.mock("@services/mongoose", () => ({ AccountsRepository: () => ({ @@ -38,6 +41,19 @@ jest.mock("@services/tracing", () => ({ addAttributesToCurrentSpan: jest.fn(), recordExceptionInCurrentSpan: jest.fn(), })) +jest.mock("@services/rate-limit", () => ({ + consumeLimiter: (...a: unknown[]) => mockConsumeLimiter(...a), +})) +jest.mock("@domain/rate-limit", () => ({ + RateLimitConfig: { giftCardQuote: { key: "gift_card_quote" } }, +})) +jest.mock("@services/logger", () => ({ + baseLogger: { + info: (...a: unknown[]) => mockLogger.info(...a), + warn: (...a: unknown[]) => mockLogger.warn(...a), + error: (...a: unknown[]) => mockLogger.error(...a), + }, +})) const PRODUCT = makeProduct() // variable, 500..50_000 minor, bitcoinCompany const QUOTE: GiftCardQuote = { @@ -63,6 +79,7 @@ const quote = (overrides: Record = {}) => beforeEach(() => { jest.clearAllMocks() + mockConsumeLimiter.mockResolvedValue(true) mockFindAccountById.mockResolvedValue(makeAccount()) mockResolveCountry.mockResolvedValue("US") mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) @@ -72,6 +89,61 @@ beforeEach(() => { }) describe("quoteGiftCard", () => { + // Every call past the budget is a live POST to the vendor through the ONE + // shared reseller login every purchase also needs. The budget is charged + // first, on the ACCOUNT, and counts attempts — refused ones included — so a + // client looping on this field bounds its own cost, not every customer's. + describe("attempt budget", () => { + it("charges the per-account quote budget before any store or vendor call", async () => { + await quote() + + expect(mockConsumeLimiter).toHaveBeenCalledTimes(1) + expect(mockConsumeLimiter).toHaveBeenCalledWith({ + rateLimitConfig: { key: "gift_card_quote" }, + keyToConsume: ACCOUNT_ID, + }) + const limiterOrder = mockConsumeLimiter.mock.invocationCallOrder[0] + expect(limiterOrder).toBeLessThan(mockFindAccountById.mock.invocationCallOrder[0]) + expect(limiterOrder).toBeLessThan(mockQuote.mock.invocationCallOrder[0]) + }) + + it("returns the limiter's refusal without reading the account or calling the vendor", async () => { + mockConsumeLimiter.mockResolvedValue(new GiftCardQuoteRateLimiterExceededError()) + + const result = await quote() + + expect(result).toBeInstanceOf(GiftCardQuoteRateLimiterExceededError) + expect(mockFindAccountById).not.toHaveBeenCalled() + expect(mockGetGiftCardProduct).not.toHaveBeenCalled() + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("falls through on a limiter STORE fault, warning rather than refusing", async () => { + // `consumeLimiter` hands back UnknownRateLimitServiceError (not the + // exceeded error) when Redis itself fails. Refusing every quote during a + // store blip would page nobody and block everyone; the same posture as + // purchaseGiftCard. + mockConsumeLimiter.mockResolvedValue(new Error("redis down")) + + const result = await quote() + + expect(result).toBe(QUOTE) + expect(mockQuote).toHaveBeenCalledTimes(1) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ accountId: ACCOUNT_ID, error: "Error" }), + expect.stringContaining("rate limiter unavailable"), + ) + }) + + it("counts a refused attempt against the budget too", async () => { + mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + + await quote() + + expect(mockConsumeLimiter).toHaveBeenCalledTimes(1) + }) + }) + it("returns the vendor's quote for the checked value and quantity", async () => { const result = await quote({ valueMinor: 2500, quantity: 2 }) diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index 6620c19bd..b869b9dcf 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -3,11 +3,14 @@ import { IbexError } from "@services/ibex/errors" import { __resetGiftCardReconcileStateForTest, + GIFT_CARD_OPEN_STATUSES, GIFT_CARD_PAID_TIMEOUT_MS, GIFT_CARD_RECONCILE_LOCK_KEY, + hasOpenGiftCardOrders, nextGiftCardPollAt, reconcileGiftCardOrders, reconcileGiftCardOrdersJob, + startGiftCardReconcileInterval, } from "@app/gift-cards/reconcile-orders" import { @@ -119,6 +122,31 @@ const ibexTransaction = (statusId: number) => ({ }, }) +/** The vendor reports the card shipped: `settleOrderFromVendor` lands on FULFILLED. */ +const vendorFulfils = () => + mockFetchAndSettle.mockImplementation(async (o: GiftCardOrder) => { + const fulfilled: GiftCardOrder = { + ...o, + status: "FULFILLED", + paidSats: o.paidSats ?? o.invoiceSats, + statusHistory: [ + ...o.statusHistory, + ...(o.status === "PAID" + ? [] + : [ + { + status: "PAID" as const, + at: new Date(clock), + reason: "vendor-reported-fulfilled", + }, + ]), + { status: "FULFILLED" as const, at: new Date(clock), reason: "vendor-fulfilled" }, + ], + } + repo.store.set(o.id, fulfilled) + return fulfilled + }) + beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers({ now: NOW_MS }) @@ -259,6 +287,68 @@ describe("reconcileGiftCardOrders", () => { expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) }) + it("INVOICE_ISSUED past expiresAt with no ref asks the vendor; fulfilled -> FULFILLED, not EXPIRED", async () => { + // The crash-between-IBEX-call-and-transition window: IBEX cannot be + // asked, the vendor can. A shipped card is proof of payment. + vendorFulfils() + const order = repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + providerOrderId: "tbc-i" as GiftCardProviderOrderId, + paymentRequest: "lnbc1...", + paymentHash: PAYMENT_HASH, + providerPaymentRef: null, + invoiceSats: 40_100 as Satoshis, + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockGetTransactionDetails).not.toHaveBeenCalled() + expect(mockFetchAndSettle).toHaveBeenCalledWith(order) + expect(res.expired).toBe(0) + expect(res.paymentSettled).toBe(1) + expect(res.fulfilled).toBe(1) + expect(repo.store.get("i")?.status).toBe("FULFILLED") + }) + + it("INVOICE_ISSUED past expiresAt with no ref and nothing at the vendor -> EXPIRED", async () => { + repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + providerOrderId: "tbc-i" as GiftCardProviderOrderId, + paymentHash: PAYMENT_HASH, + providerPaymentRef: null, + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(res.expired).toBe(1) + expect(repo.store.get("i")?.status).toBe("EXPIRED") + }) + + it("INVOICE_ISSUED past expiresAt whose payment IBEX reports FAILED is expired without a vendor poll", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(3)) + repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + providerOrderId: "tbc-i" as GiftCardProviderOrderId, + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockFetchAndSettle).not.toHaveBeenCalled() + expect(res.expired).toBe(1) + }) + it("INVOICE_ISSUED past expiresAt with an in-flight payment is not expired", async () => { mockGetTransactionDetails.mockResolvedValue(ibexTransaction(1)) repo.seed( @@ -322,36 +412,76 @@ describe("reconcileGiftCardOrders", () => { expect(mockFetchAndSettle).not.toHaveBeenCalled() }) - it("pending / unknown stays PAYMENT_PENDING", async () => { + it("in flight at IBEX stays PAYMENT_PENDING and the vendor is not asked", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(1)) pending() const res = await runAt(0) if (res instanceof Error) throw res expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") expect(repo.transition).not.toHaveBeenCalled() + expect(mockFetchAndSettle).not.toHaveBeenCalled() }) - it("an order with no IBEX transaction id is left alone and IBEX is not asked", async () => { - repo.seed( - makeOrder({ - id: "noref" as GiftCardOrderId, - status: "PAYMENT_PENDING", - paymentHash: PAYMENT_HASH, - providerPaymentRef: null, - }), - ) + it("unknown at IBEX asks the vendor and stays PAYMENT_PENDING when it has nothing new", async () => { + pending() const res = await runAt(0) if (res instanceof Error) throw res - expect(mockGetTransactionDetails).not.toHaveBeenCalled() - expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() }) - it("an IBEX lookup error leaves the order pending for the next run", async () => { + describe("no IBEX transaction id (send errored before IBEX answered, or crashed before the ref was written)", () => { + const noRef = () => + repo.seed( + makeOrder({ + id: "noref" as GiftCardOrderId, + status: "PAYMENT_PENDING", + providerOrderId: "tbc-noref" as GiftCardProviderOrderId, + paymentRequest: "lnbc1...", + paymentHash: PAYMENT_HASH, + providerPaymentRef: null, + invoiceSats: 40_100 as Satoshis, + }), + ) + + it("asks the vendor instead of IBEX", async () => { + const order = noRef() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockGetTransactionDetails).not.toHaveBeenCalled() + expect(mockFetchAndSettle).toHaveBeenCalledWith(order) + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + }) + + it("vendor says fulfilled -> PAID -> FULFILLED, counted as settled and fulfilled", async () => { + vendorFulfils() + noRef() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.paymentSettled).toBe(1) + expect(res.fulfilled).toBe(1) + expect(repo.store.get("noref")?.status).toBe("FULFILLED") + }) + + it("a vendor error leaves the order pending for the next run", async () => { + mockFetchAndSettle.mockResolvedValue(new GiftCardVendorUnavailableError()) + noRef() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + expect(mockLogger.error).not.toHaveBeenCalled() + }) + }) + + it("an IBEX lookup error falls back to the vendor and leaves the order pending", async () => { mockGetTransactionDetails.mockResolvedValue(new IbexError(new Error("502"))) pending() const res = await runAt(0) if (res instanceof Error) throw res expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") expect(repo.transition).not.toHaveBeenCalled() + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ orderId: "p", providerPaymentRef: "ibex-tx" }), expect.stringContaining("Could not re-read"), @@ -365,6 +495,7 @@ describe("reconcileGiftCardOrders", () => { if (res instanceof Error) throw res expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") expect(repo.transition).not.toHaveBeenCalled() + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) }) it("warns once pending for over an hour", async () => { @@ -436,22 +567,77 @@ describe("reconcileGiftCardOrders", () => { }) describe("24h escalation", () => { - it("PAID for 24h -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { - paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + it("PAID for 24h with nothing new at the vendor -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { + const order = paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) const res = await runAt(0) if (res instanceof Error) throw res expect(res.refundRequired).toBe(1) - const order = repo.store.get("old") - expect(order?.status).toBe("REFUND_REQUIRED") - expect(order?.failureReason).toBe("fulfillment-timeout") - expect(mockFetchAndSettle).not.toHaveBeenCalled() + const after = repo.store.get("old") + expect(after?.status).toBe("REFUND_REQUIRED") + expect(after?.failureReason).toBe("fulfillment-timeout") + // One final vendor poll precedes the escalation. + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(mockFetchAndSettle).toHaveBeenCalledWith(order) expect(mockNotifyOpsEvent).toHaveBeenCalledWith( expect.objectContaining({ flow: "giftcard", phase: "refund-required", status: "failed", error: "fulfillment-timeout", - meta: expect.objectContaining({ orderId: "old" }), + meta: expect.objectContaining({ + orderId: "old", + lastVendorPoll: "not-fulfilled", + }), + }), + ) + }) + + it("PAID for 25h with the vendor returning fulfilled ends FULFILLED, not REFUND_REQUIRED", async () => { + // A worker gap over 24h (outage, stuck lock) must not refund orders the + // vendor fulfilled in the meantime. + vendorFulfils() + paidOrder("old", -(GIFT_CARD_PAID_TIMEOUT_MS + HOUR)) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(0) + expect(res.fulfilled).toBe(1) + expect(repo.store.get("old")?.status).toBe("FULFILLED") + expect(mockNotifyOpsEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ phase: "refund-required" }), + ) + }) + + it("PAID for 25h with the vendor reporting a refund is REFUND_REQUIRED via the vendor, once", async () => { + mockFetchAndSettle.mockImplementation(async (o: GiftCardOrder) => { + const refund = { + ...o, + status: "REFUND_REQUIRED" as const, + failureReason: "vendor-refunded: Refunded", + } + repo.store.set(o.id, refund) + return refund + }) + paidOrder("old", -(GIFT_CARD_PAID_TIMEOUT_MS + HOUR)) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(1) + expect(repo.store.get("old")?.failureReason).toBe("vendor-refunded: Refunded") + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("a vendor error on the final poll still escalates, naming the error", async () => { + mockFetchAndSettle.mockResolvedValue(new GiftCardVendorUnavailableError()) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(1) + expect(repo.store.get("old")?.status).toBe("REFUND_REQUIRED") + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "refund-required", + meta: expect.objectContaining({ + lastVendorPoll: "GiftCardVendorUnavailableError", + }), }), ) }) @@ -514,14 +700,65 @@ describe("reconcileGiftCardOrders", () => { }) }) +describe("hasOpenGiftCardOrders", () => { + it("asks for one non-terminal order", async () => { + await hasOpenGiftCardOrders() + expect(repo.listByStatus).toHaveBeenCalledWith({ + statuses: ["CREATED", "INVOICE_ISSUED", "PAYMENT_PENDING", "PAID"], + limit: 1, + }) + expect(GIFT_CARD_OPEN_STATUSES).toEqual([ + "CREATED", + "INVOICE_ISSUED", + "PAYMENT_PENDING", + "PAID", + ]) + }) + + it("is false with only terminal orders, true with any open one", async () => { + repo.seed(makeOrder({ id: "done" as GiftCardOrderId, status: "FULFILLED" })) + repo.seed(makeOrder({ id: "refund" as GiftCardOrderId, status: "REFUND_REQUIRED" })) + expect(await hasOpenGiftCardOrders()).toBe(false) + paidOrder("a", -10 * SECOND) + expect(await hasOpenGiftCardOrders()).toBe(true) + }) + + it("fails open on a repository fault so the run itself logs it", async () => { + repo.listByStatus.mockResolvedValueOnce( + new (jest.requireActual("@domain/errors").RepositoryError)("mongo"), + ) + expect(await hasOpenGiftCardOrders()).toBe(true) + }) +}) + describe("reconcileGiftCardOrdersJob", () => { - it("is a no-op while gift cards are disabled", async () => { + it("keeps running while gift cards are disabled: the kill switch stops new money, not settlement", async () => { mockConfig = makeGiftCardsConfig({ enabled: false }) + vendorFulfils() + paidOrder("a", -10 * SECOND) await reconcileGiftCardOrdersJob() + expect(mockRedisSet).toHaveBeenCalledTimes(1) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(repo.store.get("a")?.status).toBe("FULFILLED") + }) + + it("still escalates the 24h timeout while gift cards are disabled", async () => { + mockConfig = makeGiftCardsConfig({ enabled: false }) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + await reconcileGiftCardOrdersJob() + expect(repo.store.get("old")?.status).toBe("REFUND_REQUIRED") + }) + + it("does not take the lock when no non-terminal order exists", async () => { + repo.seed(makeOrder({ id: "done" as GiftCardOrderId, status: "FULFILLED" })) + await reconcileGiftCardOrdersJob() + expect(repo.listByStatus).toHaveBeenCalledTimes(1) expect(mockRedisSet).not.toHaveBeenCalled() + expect(mockLogger.info).not.toHaveBeenCalled() }) it("throws the reconcile error so the cron runner counts the failure", async () => { + paidOrder("a", -10 * SECOND) mockRedisSet.mockRejectedValue(new Error("ECONNREFUSED")) await expect(reconcileGiftCardOrdersJob()).rejects.toBeInstanceOf( UnknownGiftCardError, @@ -529,10 +766,40 @@ describe("reconcileGiftCardOrdersJob", () => { }) it("logs the summary on success", async () => { + paidOrder("a", -1 * SECOND) // open, but not yet due for a poll await reconcileGiftCardOrdersJob() expect(mockLogger.info).toHaveBeenCalledWith( - { summary: expect.objectContaining({ scanned: 0 }) }, + { summary: expect.objectContaining({ scanned: 1, fulfilled: 0 }) }, "gift card reconcile finished", ) }) }) + +describe("startGiftCardReconcileInterval", () => { + it("ticks while gift cards are disabled when an open order exists", async () => { + mockConfig = makeGiftCardsConfig({ enabled: false }) + vendorFulfils() + paidOrder("a", -10 * SECOND) + const timer = startGiftCardReconcileInterval(1000) + try { + await jest.advanceTimersByTimeAsync(1000) + expect(mockRedisSet).toHaveBeenCalledTimes(1) + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(repo.store.get("a")?.status).toBe("FULFILLED") + } finally { + clearInterval(timer) + } + }) + + it("skips the lock on a tick with nothing open", async () => { + repo.seed(makeOrder({ id: "done" as GiftCardOrderId, status: "FULFILLED" })) + const timer = startGiftCardReconcileInterval(1000) + try { + await jest.advanceTimersByTimeAsync(2000) + expect(repo.listByStatus).toHaveBeenCalledTimes(2) + expect(mockRedisSet).not.toHaveBeenCalled() + } finally { + clearInterval(timer) + } + }) +}) diff --git a/test/flash/unit/app/gift-cards/settle-order.spec.ts b/test/flash/unit/app/gift-cards/settle-order.spec.ts index f1c43e4c5..324bd8f00 100644 --- a/test/flash/unit/app/gift-cards/settle-order.spec.ts +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -21,7 +21,7 @@ jest.mock("@services/gift-cards/claim-crypto", () => ({ encryptGiftCardClaim: (...a: unknown[]) => mockEncrypt(...a), })) jest.mock("@services/gift-cards/registry", () => ({ - getEnabledGiftCardProvider: (...a: unknown[]) => mockGetProvider(...a), + getRegisteredGiftCardProviderOrError: (...a: unknown[]) => mockGetProvider(...a), })) jest.mock("@app/gift-cards/send-fulfilled-notification", () => ({ sendGiftCardFulfilledNotificationBestEffort: (...a: unknown[]) => @@ -313,7 +313,16 @@ describe("fetchAndSettle", () => { expect(repo.transition).not.toHaveBeenCalled() }) - it("returns an error when the provider is disabled", async () => { + it("resolves the provider by registration, not by the enabled switch", async () => { + // The kill switch must not strand an order that already exists: settlement + // asks the registry for the registered adapter, never the enabled one. + const res = await fetchAndSettle(paidOrder()) + if (res instanceof Error) throw res + expect(mockGetProvider).toHaveBeenCalledWith("bitcoinCompany") + expect(res.status).toBe("FULFILLED") + }) + + it("returns an error when the provider is not registered", async () => { mockGetProvider.mockReturnValue(new GiftCardProviderUnavailableError()) const res = await fetchAndSettle(paidOrder()) expect(res).toBeInstanceOf(GiftCardProviderUnavailableError) diff --git a/test/flash/unit/config/rate-limits.spec.ts b/test/flash/unit/config/rate-limits.spec.ts index ebb81abc4..bab8d9afe 100644 --- a/test/flash/unit/config/rate-limits.spec.ts +++ b/test/flash/unit/config/rate-limits.spec.ts @@ -1,4 +1,14 @@ -import { getRequestCodeBlockedCountryPerIpLimits } from "@config" +import { + getGiftCardPurchaseAttemptLimits, + getGiftCardQuoteAttemptLimits, + getRequestCodeBlockedCountryPerIpLimits, +} from "@config" + +import { RateLimitConfig, RateLimitPrefix } from "@domain/rate-limit" +import { + GiftCardPurchaseRateLimiterExceededError, + GiftCardQuoteRateLimiterExceededError, +} from "@domain/rate-limit/errors" describe("getRequestCodeBlockedCountryPerIpLimits", () => { // Both numbers are load-bearing and neither comes from the yaml, so nothing @@ -33,3 +43,44 @@ describe("getRequestCodeBlockedCountryPerIpLimits", () => { expect(getRequestCodeBlockedCountryPerIpLimits().points).toBeGreaterThanOrEqual(5) }) }) + +describe("getGiftCardQuoteAttemptLimits", () => { + // Hardcoded like the purchase budget beside it — not in the yaml — so nothing + // else in the suite pins these; the app spec mocks the whole registry. + // + // points: 30/min — a confirm screen renders this and a customer adjusting a + // value or quantity can honestly ask several times a minute. + // Each call is a live vendor POST through the ONE shared + // reseller login, so the bound is what keeps a looping client + // from getting that login throttled for every customer. + // blockDuration: 5 min, same as the purchase — a quote is good until its + // `expiresAt`, so a client told to back off loses nothing by + // reusing the last one. + it("bounds quotes at 30/min with a 5 minute block", () => { + expect(getGiftCardQuoteAttemptLimits()).toEqual({ + points: 30, + duration: 60, + blockDuration: 300, + }) + }) + + it("is looser than the purchase budget: pricing is cheaper than buying", () => { + expect(getGiftCardQuoteAttemptLimits().points).toBeGreaterThan( + getGiftCardPurchaseAttemptLimits().points, + ) + }) + + it("is wired into RateLimitConfig under its own prefix and error, apart from the purchase", () => { + expect(RateLimitConfig.giftCardQuote).toEqual({ + key: RateLimitPrefix.giftCardQuote, + limits: getGiftCardQuoteAttemptLimits(), + error: GiftCardQuoteRateLimiterExceededError, + }) + expect(RateLimitPrefix.giftCardQuote).toBe("gift_card_quote") + // Its own bucket: spending the purchase budget must not silence the price. + expect(RateLimitPrefix.giftCardQuote).not.toBe(RateLimitPrefix.giftCardPurchase) + expect(RateLimitConfig.giftCardQuote.error).not.toBe( + GiftCardPurchaseRateLimiterExceededError, + ) + }) +}) diff --git a/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts index d3c7f43b4..247a22110 100644 --- a/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts @@ -28,6 +28,7 @@ import { GiftCardInvalidValueError, GiftCardLevelNotEligibleError, GiftCardLimitExceededError, + GiftCardOrderNotFoundError, GiftCardProductNotAvailableInCountryError, GiftCardProductNotFoundError, GiftCardProviderUnavailableError, @@ -97,6 +98,7 @@ beforeEach(() => { mockGetGiftCardOrderForAccount.mockResolvedValue({ order: makeOrder({ status: "FULFILLED" }), claim: CLAIM, + claimError: null, }) mockConsumeLimiter.mockResolvedValue(true) }) @@ -383,9 +385,19 @@ describe("giftCardPurchase resolver", () => { it("still returns a FULFILLED order when its claim cannot be read — with the error alongside", async () => { // The card IS issued; hiding the order would tell the customer nothing // happened. But a FULFILLED order with a silently empty claim is worse, so - // the read failure rides along in `errors`. - mockPurchaseGiftCard.mockResolvedValue(makeOrder({ status: "FULFILLED" })) - mockGetGiftCardOrderForAccount.mockResolvedValue(new GiftCardClaimCryptoError()) + // the read failure rides along in `errors`. The owner-scoped read hands + // the decrypt failure back as data (`claimError`), not as its own error. + const order = makeOrder({ + status: "FULFILLED", + claimCiphertext: "enc:...", + claimKeyId: "k1", + }) + mockPurchaseGiftCard.mockResolvedValue(order) + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order, + claim: null, + claimError: new GiftCardClaimCryptoError("key rotated"), + }) const result = await resolve() @@ -395,6 +407,23 @@ describe("giftCardPurchase resolver", () => { expect(result.order?.claim).toBeNull() }) + it("still returns a FULFILLED order when the owner-scoped read itself fails — with the error alongside", async () => { + // Distinct from a decrypt failure: here the read could not produce the + // order at all (store fault, row gone between write and read). The order + // the purchase already returned is still handed back. + const order = makeOrder({ status: "FULFILLED" }) + mockPurchaseGiftCard.mockResolvedValue(order) + mockGetGiftCardOrderForAccount.mockResolvedValue(new GiftCardOrderNotFoundError()) + + const result = await resolve() + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe("GIFT_CARD_ORDER_NOT_FOUND") + expect(result.order?.id).toBe(order.id) + expect(result.order?.status).toBe("FULFILLED") + expect(result.order?.claim).toBeNull() + }) + it("never carries the ciphertext or key id onto the payload", async () => { mockPurchaseGiftCard.mockResolvedValue( makeOrder({ diff --git a/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts b/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts index 93af669ea..4e3a9ee01 100644 --- a/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts +++ b/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts @@ -3,6 +3,7 @@ const mockMasterGate = jest.fn() const mockResolveCountry = jest.fn() const mockGetGiftCardOrderForAccount = jest.fn() +const mockRecordException = jest.fn() jest.mock("@app/gift-cards", () => ({ giftCardsMasterGate: (...a: unknown[]) => mockMasterGate(...a), @@ -11,21 +12,35 @@ jest.mock("@app/gift-cards", () => ({ })) jest.mock("@services/logger", () => ({ - baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + // The jest.fn()s live inside the factory: the resolver's import graph may log + // while modules load, before a top-level `const` would be initialised. + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), fatal: jest.fn() }, })) +// Never the real module: it registers OpenTelemetry instrumentation on import. +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: (...a: unknown[]) => mockRecordException(...a), +})) + +import { UnknownRepositoryError } from "@domain/errors" import { GiftCardClaimCryptoError, GiftCardOrderNotFoundError, GiftCardsDisabledError, } from "@domain/gift-cards" +import { ErrorLevel } from "@domain/shared" import GiftCardOrderQuery from "@graphql/public/root/query/gift-card-order" import type { GiftCardOrderSource } from "@graphql/public/types/object/gift-card-order" +import { baseLogger } from "@services/logger" -import { makeOrder } from "test/flash/unit/app/gift-cards/fixtures" +import { allMockCallText, makeOrder } from "test/flash/unit/app/gift-cards/fixtures" + +const mockLoggerError = baseLogger.error as unknown as jest.Mock const ACCOUNT_ID = "account-001" as AccountId const ORDER_ID = "order-1" +const CIPHERTEXT = "enc:CIPHERTEXT-MUST-NEVER-LEAK-4c1d" const ctx = { domainAccount: { id: ACCOUNT_ID, kratosUserId: "kratos-1", level: 1 }, @@ -56,6 +71,7 @@ beforeEach(() => { mockGetGiftCardOrderForAccount.mockResolvedValue({ order: makeOrder({ status: "PAID", paidSats: 40_100 as Satoshis }), claim: null, + claimError: null, }) }) @@ -90,13 +106,76 @@ describe("giftCardOrder resolver", () => { expect(await resolve("someone-elses-order")).toBeNull() }) - it("throws the mapped error when the order exists but its claim cannot be read", async () => { - // Not null: null means "not yours / does not exist", and this order is - // both the caller's and real. A fault reading it is reported as a fault. - mockGetGiftCardOrderForAccount.mockResolvedValue(new GiftCardClaimCryptoError()) + it("throws the mapped error when the order itself cannot be read (repository fault)", async () => { + // Not null: null means "not yours / does not exist", and a store fault says + // nothing about either. A fault reading the ORDER is reported as a fault. + mockGetGiftCardOrderForAccount.mockResolvedValue(new UnknownRepositoryError("down")) + + await expect(resolve()).rejects.toMatchObject({ extensions: { code: "DB_ERROR" } }) + }) + + describe("a FULFILLED order whose claim cannot be read", () => { + const claimError = new GiftCardClaimCryptoError("key rotated") + const fulfilledAt = new Date("2026-09-09T10:00:00Z") + + beforeEach(() => { + mockGetGiftCardOrderForAccount.mockResolvedValue({ + order: makeOrder({ + status: "FULFILLED", + paidSats: 40_100 as Satoshis, + fulfilledAt, + claimCiphertext: CIPHERTEXT, + claimKeyId: "0123456789abcdef", + }), + claim: null, + claimError, + }) + }) + + it("is still returned, with claim null, instead of throwing", async () => { + // The order is real, paid for, and the caller's. A rotated key (or one pod + // on stale config) must not make the whole read fail — the customer could + // not even see that the order exists. + const result = await resolve() + + expect(result).toMatchObject({ + id: ORDER_ID, + status: "FULFILLED", + claim: null, + fulfilledAt, + paidSats: 40_100, + }) + }) + + it("records the claim error at Critical on the span and in the log", async () => { + await resolve() + + expect(mockRecordException).toHaveBeenCalledTimes(1) + expect(mockRecordException).toHaveBeenCalledWith( + expect.objectContaining({ + error: claimError, + level: ErrorLevel.Critical, + attributes: expect.objectContaining({ "giftcard.orderId": ORDER_ID }), + }), + ) + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: ORDER_ID, + error: "GiftCardClaimCryptoError", + }), + expect.stringContaining("claim could not be read"), + ) + }) + + it("never puts the ciphertext on the span, in the log, or on the wire", async () => { + const result = await resolve() - await expect(resolve()).rejects.toMatchObject({ - extensions: { code: "GIFT_CARD_CLAIM_UNAVAILABLE" }, + expect(allMockCallText(mockRecordException, mockLoggerError)).not.toContain( + CIPHERTEXT, + ) + expect(JSON.stringify(result)).not.toContain(CIPHERTEXT) + expect(result).not.toHaveProperty("claimCiphertext") }) }) @@ -114,12 +193,13 @@ describe("giftCardOrder resolver", () => { mockGetGiftCardOrderForAccount.mockResolvedValue({ order: makeOrder({ status: "PAID" }), claim: CLAIM, + claimError: null, }) expect((await resolve())?.claim).toBeNull() }) - it("is returned, intact, for a FULFILLED order", async () => { + it("is returned, intact, for a FULFILLED order — and nothing is recorded", async () => { const fulfilledAt = new Date("2026-09-09T10:00:00Z") mockGetGiftCardOrderForAccount.mockResolvedValue({ order: makeOrder({ @@ -130,6 +210,7 @@ describe("giftCardOrder resolver", () => { claimKeyId: "k1", }), claim: CLAIM, + claimError: null, }) const result = await resolve() @@ -138,6 +219,8 @@ describe("giftCardOrder resolver", () => { expect(result?.claim).toEqual(CLAIM) expect(result?.fulfilledAt).toBe(fulfilledAt) expect(result?.paidSats).toBe(40_100) + expect(mockRecordException).not.toHaveBeenCalled() + expect(mockLoggerError).not.toHaveBeenCalled() }) }) @@ -149,6 +232,7 @@ describe("giftCardOrder resolver", () => { claimKeyId: "k1", }), claim: CLAIM, + claimError: null, }) const result = await resolve() @@ -170,6 +254,7 @@ describe("giftCardOrder resolver", () => { failureReason: "vendor-create-failed: GiftCardVendorRejectedOrderError", }), claim: null, + claimError: null, }) const result = await resolve() diff --git a/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts b/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts index a981f0e72..ed2bb6180 100644 --- a/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts +++ b/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts @@ -22,6 +22,7 @@ import { import { InputValidationError } from "@graphql/error" import GiftCardQuoteQuery from "@graphql/public/root/query/gift-card-quote" import type { GiftCardQuoteSource } from "@graphql/public/types/object/gift-card-quote" +import { MAXIMUM_QUERY_COMPLEXITY } from "@servers/plugins/complexity" const ACCOUNT_ID = "account-001" as AccountId const PRODUCT_ID = "bitcoinCompany:amazon-us" @@ -167,3 +168,30 @@ describe("giftCardQuote resolver", () => { }) }) }) + +// Every resolve of this field is a live POST to the vendor's quote endpoint +// through the ONE shared reseller login every purchase also needs, and ROOT +// QUERY FIELDS RESOLVE IN PARALLEL. Undeclared, `simpleEstimator({ +// defaultComplexity: 1 })` scores it at 1 against the server's ceiling, so one +// authenticated document aliasing it 25 times fires 25 concurrent vendor +// POSTs. The 30/min per-account limiter is not an answer to that: it consumes +// once per RESOLVE, so it refuses the NEXT request, after the burst has landed. +describe("giftCardQuote is budgeted for the vendor call it performs", () => { + const declared = () => { + const complexity = GiftCardQuoteQuery.extensions?.complexity + expect(typeof complexity).toBe("number") + return complexity as number + } + + it("declares a complexity rather than taking the default of 1", () => { + expect(declared()).toBeGreaterThan(1) + }) + + it("costs enough that a second copy cannot fit in the same document", () => { + // Against the ceiling the server actually enforces — IMPORTED, not + // hand-copied — so raising the ceiling fails this rather than leaving it + // green with the property gone. Once must fit; twice must not. + expect(declared()).toBeLessThanOrEqual(MAXIMUM_QUERY_COMPLEXITY) + expect(declared() * 2).toBeGreaterThan(MAXIMUM_QUERY_COMPLEXITY) + }) +}) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts index cf9a6b248..2096c73e5 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts @@ -52,6 +52,7 @@ import { BITCOIN_COMPANY_AUTH_LOCK_KEY, BitcoinCompanyClient, BitcoinCompanyClientDeps, + CATALOG_MAX_PAGES, CATALOG_PAGE_SIZE, RETRY_MAX, redactForLog, @@ -76,12 +77,14 @@ import { bearerOf, bitcoinCompanyConfigFixture, callsTo, + catalogOffsets, catalogPage, failThen, httpError, httpOk, makeCatalog, networkError, + pagedCatalog, routeGet, routePost, sequence, @@ -345,16 +348,9 @@ describe("BitcoinCompanyClient auth", () => { }) describe("BitcoinCompanyClient catalog", () => { - it("pages through the catalog until a short page (500 + 355 = 855)", async () => { + it("pages through the catalog until an empty page (500 + 355 = 855)", async () => { mockedAxios.get.mockImplementation( - routeGet({ - "/giftcards": (ctx) => { - const offset = Number(ctx.query.get("offset")) - return catalogPage( - offset === 0 ? makeCatalog(CATALOG_PAGE_SIZE, 0) : makeCatalog(355, offset), - ) - }, - }), + routeGet({ "/giftcards": pagedCatalog(makeCatalog(855)) }), ) const result = await makeClient().listProducts() @@ -363,19 +359,79 @@ describe("BitcoinCompanyClient catalog", () => { expect(result).toHaveLength(855) expect(new Set(result.map((p) => p.id)).size).toBe(855) + // A short page is not treated as the end; only the empty page at 855 is. const pages = callsTo(mockedAxios.get, "/giftcards") - expect(pages).toHaveLength(2) - expect(pages.map(([url]) => new URL(url).searchParams.get("offset"))).toEqual([ - "0", - "500", - ]) + expect(pages).toHaveLength(3) + expect(catalogOffsets(mockedAxios.get)).toEqual(["0", "500", "855"]) expect(pages.map(([url]) => new URL(url).searchParams.get("size"))).toEqual([ "500", "500", + "500", ]) // Public endpoint: no auth round-trip. expect(bearerOf(pages[0][1])).toBeNull() expect(mockedAxios.post).not.toHaveBeenCalled() + expect(mockedLogger.warn).not.toHaveBeenCalled() + }) + + it("collects every row when the vendor clamps the page size to 100 (100 + 100 + 55 = 255)", async () => { + mockedAxios.get.mockImplementation( + routeGet({ "/giftcards": pagedCatalog(makeCatalog(255), { maxPageSize: 100 }) }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + expect(result).toHaveLength(255) + expect(new Set(result.map((p) => p.id)).size).toBe(255) + + // Offsets advance by rows received, not by the 500 we asked for; the + // trailing empty page at 255 is the stop signal. + expect(catalogOffsets(mockedAxios.get)).toEqual(["0", "100", "200", "255"]) + const pages = callsTo(mockedAxios.get, "/giftcards") + expect(pages.every(([url]) => new URL(url).searchParams.get("size") === "500")).toBe( + true, + ) + expect(mockedLogger.warn).not.toHaveBeenCalled() + }) + + it("returns an empty catalog after a single request when the first page is empty", async () => { + mockedAxios.get.mockImplementation(routeGet({ "/giftcards": pagedCatalog([]) })) + + const result = await makeClient().listProducts() + + expect(result).toEqual([]) + expect(catalogOffsets(mockedAxios.get)).toEqual(["0"]) + expect(mockedLogger.warn).not.toHaveBeenCalled() + }) + + it("fails the sync with a warning at the page cap when the vendor never returns an empty page", async () => { + // One fresh row per page, forever: a vendor that never signals the end. + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => + catalogPage(makeCatalog(1, Number(ctx.query.get("offset")))), + }), + ) + + const result = await makeClient().listProducts() + + // The partial catalog is never returned: a vendor that ignores `offset` + // would otherwise overwrite the last good catalog with duplicates. + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(CATALOG_MAX_PAGES) + expect(catalogOffsets(mockedAxios.get)).toEqual( + Array.from({ length: CATALOG_MAX_PAGES }, (_, i) => String(i)), + ) + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + op: "listProducts", + pages: CATALOG_MAX_PAGES, + offset: CATALOG_MAX_PAGES, + kept: CATALOG_MAX_PAGES, + }), + expect.stringContaining("page cap"), + ) }) it("stops after a full page followed by an empty one", async () => { @@ -398,15 +454,14 @@ describe("BitcoinCompanyClient catalog", () => { it("skips catalog rows that fail validation instead of failing the sync", async () => { mockedAxios.get.mockImplementation( routeGet({ - "/giftcards": () => - catalogPage([ - vendorProductFixture(), - { id: 42, name: "broken" }, - vendorProductFixture({ - id: "prod-2", - isOpenLoop: "yes" as unknown as boolean, - }), - ]), + "/giftcards": pagedCatalog([ + vendorProductFixture(), + { id: 42, name: "broken" }, + vendorProductFixture({ + id: "prod-2", + isOpenLoop: "yes" as unknown as boolean, + }), + ]), }), ) @@ -414,6 +469,8 @@ describe("BitcoinCompanyClient catalog", () => { if (result instanceof Error) throw result expect(result.map((p) => p.id)).toEqual(["prod-amazon-us"]) + // Skipped rows still advance the offset: the next page starts at 3, not 1. + expect(catalogOffsets(mockedAxios.get)).toEqual(["0", "3"]) expect(mockedLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ op: "listProducts", skipped: 2, kept: 1 }), expect.any(String), @@ -421,12 +478,14 @@ describe("BitcoinCompanyClient catalog", () => { }) it("retries a GET after a network error and then succeeds", async () => { + const firstPage = failThen( + [networkError("ECONNRESET")], + catalogPage([vendorProductFixture()]), + ) mockedAxios.get.mockImplementation( routeGet({ - "/giftcards": failThen( - [networkError("ECONNRESET")], - catalogPage([vendorProductFixture()]), - ), + "/giftcards": (ctx) => + Number(ctx.query.get("offset")) === 0 ? firstPage(ctx) : catalogPage([]), }), ) @@ -434,7 +493,8 @@ describe("BitcoinCompanyClient catalog", () => { if (result instanceof Error) throw result expect(result).toHaveLength(1) - expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(2) + // Failed attempt, successful retry, then the terminating empty page. + expect(catalogOffsets(mockedAxios.get)).toEqual(["0", "0", "1"]) expect(mockedLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ op: "listProducts", attempt: 1 }), expect.any(String), diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts index f1c358673..29d66759b 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts @@ -199,7 +199,8 @@ describe("bitcoinCompany adapter specifics", () => { providerOrderId: PURCHASE_RESULT.uuid, paymentRequest: PURCHASE_RESULT.invoice, amountSats: 39000, - expiresAt: new Date(NOW + 15 * 60_000), + // TBC reports no order expiry; the BOLT11's own expiry governs. + expiresAt: null, }) }) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts index 955ad5f89..fddbaa457 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts @@ -271,3 +271,25 @@ export const failThen = (failures: Error[], then: AxiosLikeResponse): RouteHandl return then } } + +// ============ Catalog paging helpers ============ + +/** + * A `/giftcards` handler that serves `rows` by `offset`/`size` the way a real + * offset-paginated endpoint does, and an empty page past the end. + * `maxPageSize` emulates a vendor that clamps `size` regardless of the request. + */ +export const pagedCatalog = + (rows: unknown[], opts: { maxPageSize?: number } = {}): RouteHandler => + (ctx) => { + const offset = Number(ctx.query.get("offset") ?? 0) + const requested = Number(ctx.query.get("size") ?? rows.length) + const size = opts.maxPageSize ? Math.min(requested, opts.maxPageSize) : requested + return catalogPage(rows.slice(offset, offset + size)) + } + +/** The `offset` query param of every `/giftcards` request, oldest first. */ +export const catalogOffsets = (mock: jest.Mock): string[] => + callsTo(mock, "/giftcards").map( + ([url]) => new URL(url as string).searchParams.get("offset") ?? "", + ) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts index 87136257f..3979efb53 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts @@ -1,6 +1,5 @@ import { KNOWN_VENDOR_STATUSES, - ORDER_TTL_MS, QUOTE_TTL_MS, isProductMappingSkip, mapDenominationType, @@ -275,17 +274,20 @@ describe("mapVendorQuote", () => { describe("mapVendorPurchase", () => { it("maps invoice, amount, and uuid into the provider order", () => { - expect(mapVendorPurchase(PURCHASE_RESULT, now)).toEqual({ + expect(mapVendorPurchase(PURCHASE_RESULT)).toEqual({ providerOrderId: PURCHASE_RESULT.uuid, paymentRequest: PURCHASE_RESULT.invoice, amountSats: 39000, - expiresAt: new Date(NOW + ORDER_TTL_MS), + expiresAt: null, }) - expect(ORDER_TTL_MS).toBe(15 * 60_000) + }) + + it("never fabricates an order expiry: the vendor reports none, the BOLT11 carries it", () => { + expect(mapVendorPurchase(PURCHASE_RESULT).expiresAt).toBeNull() }) it("stringifies a numeric uuid and rounds the amount", () => { - const order = mapVendorPurchase({ ...PURCHASE_RESULT, uuid: 42, amount: 100.6 }, now) + const order = mapVendorPurchase({ ...PURCHASE_RESULT, uuid: 42, amount: 100.6 }) expect(order.providerOrderId).toBe("42") expect(order.amountSats).toBe(101) }) diff --git a/test/flash/unit/services/gift-cards/provider-contract.ts b/test/flash/unit/services/gift-cards/provider-contract.ts index 03e694314..948f8423f 100644 --- a/test/flash/unit/services/gift-cards/provider-contract.ts +++ b/test/flash/unit/services/gift-cards/provider-contract.ts @@ -160,7 +160,11 @@ export const runGiftCardProviderContract = ( expect(order.providerOrderId).not.toBe("") expect(order.paymentRequest).not.toBe("") expect(Number.isSafeInteger(order.amountSats) && order.amountSats > 0).toBe(true) - expect(order.expiresAt.getTime()).toBeGreaterThan(now().getTime()) + // Null when the vendor states no order expiry (the BOLT11 carries its own); + // when stated, it must be in the future. + expect( + order.expiresAt === null || order.expiresAt.getTime() > now().getTime(), + ).toBe(true) const status = unwrap( await provider.getOrder({ diff --git a/test/flash/unit/services/gift-cards/registry.spec.ts b/test/flash/unit/services/gift-cards/registry.spec.ts new file mode 100644 index 000000000..f5f3ad151 --- /dev/null +++ b/test/flash/unit/services/gift-cards/registry.spec.ts @@ -0,0 +1,108 @@ +import { GiftCardProviderUnavailableError } from "@domain/gift-cards" + +let mockConfig = makeGiftCardsConfig() + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockConfig + }, +})) + +import { + __resetGiftCardProvidersForTest, + enabledGiftCardProviders, + getEnabledGiftCardProvider, + getGiftCardProviderForCountry, + getRegisteredGiftCardProviderOrError, + registerGiftCardProvider, + resolveGiftCardProviderIdForCountry, +} from "@services/gift-cards/registry" + +import { makeGiftCardsConfig } from "../../app/gift-cards/fixtures" + +const fakeProvider = (id: GiftCardProviderId): IGiftCardProvider => ({ + id, + listProducts: jest.fn(), + quote: jest.fn(), + createOrder: jest.fn(), + getOrder: jest.fn(), +}) + +const tbc = fakeProvider("bitcoinCompany") + +beforeEach(() => { + __resetGiftCardProvidersForTest() + registerGiftCardProvider(tbc) + mockConfig = makeGiftCardsConfig() +}) + +describe("getRegisteredGiftCardProviderOrError (orders that already exist)", () => { + it("returns the registered provider while it is enabled", () => { + expect(getRegisteredGiftCardProviderOrError("bitcoinCompany")).toBe(tbc) + }) + + it("returns it while the master switch is off: settlement must outlive the kill switch", () => { + mockConfig = makeGiftCardsConfig({ enabled: false }) + expect(getRegisteredGiftCardProviderOrError("bitcoinCompany")).toBe(tbc) + }) + + it("returns it while the provider switch is off", () => { + mockConfig = makeGiftCardsConfig() + mockConfig.providers.bitcoinCompany.enabled = false + expect(getRegisteredGiftCardProviderOrError("bitcoinCompany")).toBe(tbc) + }) + + it("errors only when no adapter is registered under the id", () => { + expect(getRegisteredGiftCardProviderOrError("bitrefill")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + __resetGiftCardProvidersForTest() + expect(getRegisteredGiftCardProviderOrError("bitcoinCompany")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + }) +}) + +describe("getEnabledGiftCardProvider (quote / purchase: new money)", () => { + it("returns the provider when both switches are on", () => { + expect(getEnabledGiftCardProvider("bitcoinCompany")).toBe(tbc) + }) + + it("refuses while the master switch is off", () => { + mockConfig = makeGiftCardsConfig({ enabled: false }) + expect(getEnabledGiftCardProvider("bitcoinCompany")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + }) + + it("refuses while the provider switch is off", () => { + mockConfig = makeGiftCardsConfig() + mockConfig.providers.bitcoinCompany.enabled = false + expect(getEnabledGiftCardProvider("bitcoinCompany")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + expect(enabledGiftCardProviders()).toEqual([]) + }) + + it("refuses an enabled id with no registered adapter", () => { + mockConfig = makeGiftCardsConfig() + mockConfig.providers.bitrefill.enabled = true + expect(getEnabledGiftCardProvider("bitrefill")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + }) +}) + +describe("routing", () => { + it("routes byCountry, else default, and requires the result to be enabled", () => { + mockConfig = makeGiftCardsConfig({ + routing: { default: "bitcoinCompany", byCountry: { JM: "bitrefill" } }, + }) + expect(resolveGiftCardProviderIdForCountry("us")).toBe("bitcoinCompany") + expect(getGiftCardProviderForCountry("US")).toBe(tbc) + // Routed to a disabled provider: unavailable, never silently re-routed. + expect(resolveGiftCardProviderIdForCountry("JM")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + }) +}) From 7fbdf3a2d67943e8126aaecb38cb47a6def73c58 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 11 Sep 2026 21:49:04 -0700 Subject: [PATCH 06/14] =?UTF-8?q?fix(gift-cards):=20address=20review=20rou?= =?UTF-8?q?nd=202=20=E2=80=94=20replay/lock=20regression,=2024h=20escalati?= =?UTF-8?q?on,=20post-pay=20bookkeeping,=20vendor=20hardening=20(#508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01W5e4H8MF7oX2LPUxBRW3Pf --- dev/apollo-federation/supergraph.graphql | 12 +- docs/gift-cards/ALERTING.md | 48 +- docs/gift-cards/API.md | 138 +++-- docs/gift-cards/ARCHITECTURE.md | 137 +++-- docs/gift-cards/CONFIG.md | 41 +- docs/gift-cards/FLOWS.md | 139 +++-- docs/gift-cards/RUNBOOK.md | 330 +++++----- docs/gift-cards/TESTING.md | 45 +- src/app/gift-cards/get-order.ts | 3 + src/app/gift-cards/gift-cards-master-gate.ts | 22 +- src/app/gift-cards/ops.ts | 2 + src/app/gift-cards/purchase-gift-card.ts | 318 ++++++++-- src/app/gift-cards/quote-gift-card.ts | 13 +- src/app/gift-cards/reconcile-orders.ts | 185 ++++-- src/app/gift-cards/settle-order.ts | 65 +- src/app/gift-cards/sync-catalog.ts | 16 +- src/domain/gift-cards/index.ts | 18 +- src/domain/gift-cards/index.types.d.ts | 16 + src/domain/gift-cards/primitives.ts | 36 +- .../root/mutation/gift-card-purchase.ts | 14 +- src/graphql/public/schema.graphql | 12 +- .../public/types/object/gift-card-order.ts | 5 +- .../public/types/object/gift-card-product.ts | 12 + .../gift-cards/bitcoin-company/client.ts | 61 +- .../gift-cards/bitcoin-company/index.ts | 92 ++- .../gift-cards/bitcoin-company/mapping.ts | 94 ++- .../gift-cards/bitcoin-company/schemas.ts | 24 +- src/services/gift-cards/catalog-cache.ts | 177 +++++- src/services/gift-cards/claim-crypto.ts | 49 +- src/services/gift-cards/registry.ts | 22 +- src/services/mongoose/gift-card-orders.ts | 23 + test/flash/unit/app/gift-cards/fixtures.ts | 79 ++- .../unit/app/gift-cards/get-order.spec.ts | 30 +- .../gift-cards/gift-cards-master-gate.spec.ts | 59 +- .../unit/app/gift-cards/list-orders.spec.ts | 199 +++++++ .../unit/app/gift-cards/list-products.spec.ts | 2 + .../app/gift-cards/purchase-gift-card.spec.ts | 562 ++++++++++++++++-- .../app/gift-cards/quote-gift-card.spec.ts | 49 +- .../app/gift-cards/reconcile-orders.spec.ts | 327 ++++++++-- .../unit/app/gift-cards/settle-order.spec.ts | 182 +++++- .../unit/app/gift-cards/sync-catalog.spec.ts | 34 ++ .../app/payments/pay-invoice-via-ibex.spec.ts | 36 +- test/flash/unit/config/rate-limits.spec.ts | 36 +- .../unit/domain/gift-cards/primitives.spec.ts | 180 ++++++ .../root/mutation/gift-card-purchase.spec.ts | 103 +++- .../root/query/globals-gift-cards.spec.ts | 33 +- .../types/object/gift-card-order.spec.ts | 2 + .../gift-cards/bitcoin-company/client.spec.ts | 255 +++++++- .../bitcoin-company/contract.spec.ts | 228 ++++++- .../gift-cards/bitcoin-company/fixtures.ts | 39 ++ .../bitcoin-company/mapping.spec.ts | 175 +++++- .../services/gift-cards/catalog-cache.spec.ts | 219 ++++++- .../services/gift-cards/claim-crypto.spec.ts | 205 +++++-- .../services/gift-cards/provider-contract.ts | 2 + .../unit/services/gift-cards/registry.spec.ts | 58 ++ .../mongoose/gift-card-orders.mapping.spec.ts | 31 +- .../mongoose/gift-card-orders.spec.ts | 40 ++ 57 files changed, 4397 insertions(+), 937 deletions(-) create mode 100644 test/flash/unit/app/gift-cards/list-orders.spec.ts create mode 100644 test/flash/unit/domain/gift-cards/primitives.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 8cc999535..a94a1b010 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1422,7 +1422,7 @@ type GiftCardOrder id: ID! """ - What actually left the wallet, in sats. Null until the payment settles (and forever null for an order that never paid). + The vendor invoice amount in sats — what was paid for the card, excluding the Lightning routing fee. Null until the payment settles (and forever null for an order that never paid). """ paidSats: SatAmount @@ -1551,6 +1551,11 @@ type GiftCardProduct """Brand artwork, when the vendor supplies one.""" logoUrl: String + """ + Largest quantity a single order may carry for this product. Vendor-specific; 1 while a vendor's multi-card fulfilment response has not been verified. + """ + maxQuantity: Int! + """Largest face value for a VARIABLE card, minor units. Null for FIXED.""" maxValue: CentAmount @@ -1567,6 +1572,11 @@ type GiftCardProduct """The card's terms and conditions, when the vendor supplies a link.""" termsUrl: String + + """ + True when the vendor only accepts whole currency units for a variable-value card (no cents). Values that are not a multiple of 100 minor units are refused. + """ + wholeUnitsOnly: Boolean! } """A connection to a list of items.""" diff --git a/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md index 03d2536ee..5bda0b470 100644 --- a/docs/gift-cards/ALERTING.md +++ b/docs/gift-cards/ALERTING.md @@ -23,17 +23,18 @@ event by construction: the helper takes an order, not a claim. | Phase | Status | Emitted by | Meaning | Action | | --- | --- | --- | --- | --- | | `order-created` | pending | `purchaseGiftCard` | Row written, vendor not yet called | none | -| `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight (`meta.reason` `payment-pending`), or the send errored without a verdict — socket reset, gateway 5xx, timeout, unreadable 200, busy idempotency lock (`meta.reason` `payment-unconfirmed: `, `error` naming the class) | none; worker resolves against IBEX, then the vendor. Warn if the same order is still pending after 60 min (see log line below). A burst of `payment-unconfirmed` is an IBEX connectivity problem | -| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card | none | +| `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight, **or** returned a 200 naming no recognised status (`paymentSendStatusOrPending` converts it to Pending) — both `meta.reason` `payment-pending`; or the send errored without a verdict — socket reset, gateway 5xx, timeout after the request was accepted (`meta.reason` `payment-unconfirmed: `, `error` naming the class) | none; worker resolves against IBEX, then the vendor. Warn if the same order is still pending after 60 min (see log line below). A burst of `payment-unconfirmed` is an IBEX connectivity problem | +| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card. `statusHistory` reason is `payment-settled`, `vendor-reported-fulfilled`, `payment-settled-on-reconcile` or `payment-settled-after-expiry` (a late Success on an EXPIRED row) | none | | `order-fulfilled` | success | `settleOrderFromVendor` | Claim stored; push sent | none | -| `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment (`meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: ` — only for a send IBEX provably refused, `payment-failed`, `expired`, `vendor-failed: ...`) | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | -| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` (24 h in PAID and a final vendor poll did not report fulfilled; `meta.lastVendorPoll` is `not-fulfilled` or the poll error's class) | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | +| `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment. `meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: ` (only for a send IBEX provably refused), `payment-failed`, `expired`, `payment-unresolved-expired` (pending with no IBEX ref, vendor says unpaid, 24 h past `expiresAt`). For a vendor cancel before payment the event's `error` is `vendor-failed` / `vendor-refunded` and `meta.reason` the raw vendor status; `vendor-failed: ` is the `statusHistory` reason | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | +| `paid-not-recorded` | failed | `purchaseGiftCard` | IBEX reported Success but the `PAID` write failed (lost race or store fault). The order is still returned to the client; until the worker or an operator records it, the row shows unpaid for money that has left the wallet. `meta.providerPaymentRef` is the IBEX transaction id | **Page.** RUNBOOK (i) | +| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` (24 h in PAID and the final vendor poll **positively** reported not fulfilled; `meta.lastVendorPoll` is `not-fulfilled`; a vendor error does not escalate) | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | | `claim-encrypt-failed` | failed | `settleOrderFromVendor` | Card issued at the vendor; Flash could not encrypt the claim (key missing/invalid). Order stays `PAID` | **Page.** Fix `claimDataEncryptionKey`; the next poll retries | | `vendor-fulfilled-unexpected` | failed | `settleOrderFromVendor` | Vendor says fulfilled but our order is terminal-unpaid (`meta.orderStatus`). Someone paid; our records say not us | **Page.** Reconcile by hand | | `would-reject` | pending | `authorizeGiftCardPurchase` (log-only) | A limits check would have refused; `meta.reason`, `meta.level`, `meta.productId`, `error` | Review weekly before flipping to enforce (RUNBOOK f). `reason: limits-unavailable` must be zero | | `rejected` | failed | `authorizeGiftCardPurchase` (enforce) | A limits check refused | Warn on rate; investigate `limits-unavailable` immediately | | `catalog-synced` | success | `syncGiftCardCatalogForProvider` | `meta.products`, `meta.countries`, `meta.durationMs` | none. Absence for > `syncIntervalSeconds` x 2 is the signal | -| `catalog-sync-failed` | failed | `syncGiftCardCatalogForProvider` | Vendor pull or Redis write failed for one provider (`error`, `meta.providerId`) | Warn. Two consecutive failures: RUNBOOK (g) | +| `catalog-sync-failed` | failed | `syncGiftCardCatalogForProvider` | Vendor pull or Redis write failed for one provider, **or the pull received zero rows / kept none after mapping** (a failure, not an empty catalog: the previous one keeps serving) (`error`, `meta.providerId`) | Warn. Two consecutive failures: RUNBOOK (g) | | `catalog-stale` | pending | `listGiftCardProducts` | A request was served a catalog older than `catalog.ttlSeconds`. Coalesced to one per provider per 10 min | Warn. The catalog will vanish at `staleAfterSeconds` | `would-reject` and `rejected` are not coalesced; they are bounded by the @@ -45,20 +46,21 @@ caller's attempt budget. `catalog-stale` is coalesced | Page (someone acts within the hour) | Warn (review same day) | | --- | --- | | `refund-required` (any) | `catalog-stale`, `catalog-sync-failed` | -| `claim-encrypt-failed`, `vendor-fulfilled-unexpected` | `order-failed` rate above baseline | -| TBC auth failure: span exception `BitcoinCompanyAuthError` (Critical) | vendor 5xx / network: span exceptions `BitcoinCompanyApiError` (>= 500) and `BitcoinCompanyNetworkError` (Warn) | -| `BitcoinCompanyResponseShapeError` (Critical): the vendor changed its API | An order in `PAID` longer than 15 min (RUNBOOK b) | -| `limits-unavailable` in enforce mode (`UnknownGiftCardError`, Critical) | An order in `PAYMENT_PENDING` longer than 60 min (log line below; RUNBOOK c) | -| `GiftCardOrderStateError` recorded from `purchaseGiftCard` after payment ("payment settled but PAID transition failed") | `would-reject` volume while in log-only | +| `paid-not-recorded` (any) | `order-failed` rate above baseline | +| `claim-encrypt-failed`, `vendor-fulfilled-unexpected` | vendor 5xx / network: span exceptions `BitcoinCompanyApiError` (>= 500) and `BitcoinCompanyNetworkError` (Warn) | +| TBC auth failure: span exception `BitcoinCompanyAuthError` (Critical) | An order in `PAID` longer than 15 min (RUNBOOK b) | +| `BitcoinCompanyResponseShapeError` (Critical): the vendor changed its API | An order in `PAYMENT_PENDING` longer than 60 min (log line below; RUNBOOK c) | +| `limits-unavailable` in enforce mode (`UnknownGiftCardError`, Critical) | `would-reject` volume while in log-only | +| `GiftCardClaimCryptoError` at Critical from `giftCardOrder` (a FULFILLED order nobody can read; `giftcard.claimKeyId` on the span) | | Suggested thresholds, to tune once there is traffic: -- `refund-required`: >= 1 in 5 min pages. +- `refund-required` or `paid-not-recorded`: >= 1 in 5 min pages. - `BitcoinCompanyAuthError`: >= 1 pages (every purchase and poll fails until fixed). - Vendor 5xx/network (`giftcard.provider = bitcoinCompany`, exception level Warn): > 5 in 5 min warns, > 20 in 5 min pages. - `order-failed` with `meta.reason` starting `quote-mismatch`: > 3 in 15 min warns (tolerance too tight or vendor volatility). - `catalog-synced` absent for 2 x `syncIntervalSeconds` (12 h at defaults) warns; `catalog-stale` firing at all warns. -- Reconcile summary `refundRequired > 0` or `scanned` at the 200 batch cap warns. +- Reconcile summary `refundRequired > 0` warns. `scanned` sums three listings of up to 200 each (open, pending, paid); a run at 600 means every batch cap was hit and orders are being left for the next tick. ## Log lines worth a rule @@ -66,14 +68,19 @@ All from `baseLogger` with `module: "gift-cards.reconcile"` unless noted: | Level | Message | Meaning | | --- | --- | --- | -| warn | `Gift card payment has been pending for over an hour with no resolvable status` | PAYMENT_PENDING with no `providerPaymentRef` or an IBEX lookup that never resolves, AND the vendor does not report fulfilled; carries `orderId`, `paymentHash`, `providerPaymentRef` | +| warn | `Gift card payment has been pending for over an hour with no resolvable status` | PAYMENT_PENDING with no `providerPaymentRef` or an IBEX lookup that never resolves, AND the vendor does not report fulfilled; carries `orderId`, `paymentHash`, `providerPaymentRef`. Self-terminates as `payment-unresolved-expired` 24 h past `expiresAt` when the vendor reports unpaid | | warn | `Gift card payment outcome unknown after send error; holding as PAYMENT_PENDING` (purchase) | The send errored without proving IBEX refused it; carries `orderId`, `providerPaymentRef`, `error`. The worker settles it | -| warn | `Final vendor poll before fulfilment timeout failed; escalating` | 24 h in PAID and the vendor could not be reached; the order is now REFUND_REQUIRED and the page names the poll error | -| warn | `Expired gift card invoice still has an in-flight payment; not expiring` | IBEX says pending on an expired invoice; watch it | +| warn | final vendor poll at the 24 h horizon failed (reconcile) | The vendor could not be reached at the fulfilment timeout; the order stays `PAID` and is polled again next run. It does **not** escalate | | error | `Gift card reconcile failed for order` | One order threw; the batch continued | -| error | `Gift card payment settled but PAID transition failed` (purchase) | Money moved, bookkeeping lost a race; the worker will re-read IBEX | +| error | `Gift card payment settled but PAID transition failed` (purchase) | Money moved, bookkeeping lost a race or the store faulted; pairs with the `paid-not-recorded` event (RUNBOOK i) | +| error | `Gift card claim could not be read for a FULFILLED order` (`giftCardOrder` resolver) | Key missing/rotated or ciphertext bound to another order; carries `orderId`, `claimKeyId`, never the ciphertext | | warn | `Bitcoin Company reported "" without claim data; holding as pending` (adapter) | Vendor said done with nothing to redeem; polling continues | | warn | `Bitcoin Company returned unknown order status ""; holding as pending` | New vendor status; add it to `VENDOR_STATUS_TABLE` | +| warn | `Bitcoin Company reported ""; holding as pending until the vendor settles it or the timeout decides` | A `disputed` order: held, never refunded on the vendor's word alone; the 24 h timeout or TBC decides | + +The trigger-pod tick logs **nothing** on a successful or lock-held run; only +`gift card reconcile tick failed` / `gift card reconcile tick threw`. The cron +run logs `gift card reconcile finished` with the summary. ## Span attributes @@ -86,14 +93,16 @@ and the jobs run under `cron.`. | Attribute | Set by | Values | | --- | --- | --- | | `giftcard.provider` | adapter, client, purchase, quote, settle | `bitcoinCompany` | -| `giftcard.op` | adapter, client | `listProducts`, `quote`, `createOrder`, `getOrder`, `login`, `refreshToken` | +| `giftcard.op` | client (`call` pipeline only) | `listProducts`, `quote`, `createOrder`, `getOrder`. `login` and `refreshToken` bypass `call` and do **not** set it | | `giftcard.productId`, `giftcard.valueMinor` (number), `giftcard.quantity` (number) | purchase, quote | request args | -| `giftcard.orderId` | purchase, settle, get-order | our UUID | +| `giftcard.orderId` | purchase, settle, get-order, `giftCardOrder` resolver | our UUID | | `giftcard.replay` | purchase | `true` when an existing order was returned for the key | +| `giftcard.replay.resumedPayment` | purchase | `true` when the replay re-entered the pay step on an unpaid INVOICE_ISSUED order | | `giftcard.quoteSats`, `giftcard.invoiceSats` (numbers) | purchase | the tolerance comparison inputs | | `giftcard.providerPaymentRef` | purchase | IBEX `transaction.id`, or `""` | | `giftcard.status` | settle, get-order | order status before the settle / at read | | `giftcard.vendorStatus` | settle | `awaitingPayment`, `paidPendingFulfillment`, `fulfilled`, `failed`, `refunded` | +| `giftcard.claimKeyId` | `giftCardOrder` resolver, on a claim read failure | the key id stored on the order (a fingerprint, never key bytes) | | `giftcard.limits.mode`, `giftcard.limits.reason`, `giftcard.limits.error` | authorize (on any would-reject/reject) | mode; `level-not-eligible`, `account-too-new`, `per-card-cap`, `daily-cap`, `vendor-daily-cap`, `vendor-card-cap`, `velocity`, `open-loop-not-allowed`, `limits-unavailable`; error class name | | `giftcard.country.source`, `giftcard.country.candidates` (number) | master gate | phone-country resolution | | `giftcard.reconcile.skipped` | reconcile | `lock-held` | @@ -107,6 +116,7 @@ Recorded exceptions (`recordExceptionInCurrentSpan`) and their levels: `BitcoinCompanyUnauthorizedError` Warn, `BitcoinCompanyResponseShapeError` Critical, `BitcoinCompanyAuthError` Critical, unexpected client throw Critical; settle-order records `GiftCardOrderStateError` (vendor fulfilled on a -terminal order) and `GiftCardClaimCryptoError` (Critical); purchase records the +terminal order) and `GiftCardClaimCryptoError` (Critical); the `giftCardOrder` +resolver records `GiftCardClaimCryptoError` at Critical; purchase records the INVOICE_ISSUED and PAID transition failures; reconcile records every per-order throw. diff --git a/docs/gift-cards/API.md b/docs/gift-cards/API.md index 135a37384..136b839f3 100644 --- a/docs/gift-cards/API.md +++ b/docs/gift-cards/API.md @@ -1,16 +1,18 @@ # Gift Cards GraphQL API Reference All fields require an authenticated session (`GraphQLPublicContextAuth`). -`giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` open with -`gateGiftCardsForAccount` (`src/graphql/public/root/gift-card-gate.ts`): -queries throw the mapped error, the mutation returns it in `errors`. -`giftCardOrder` and `giftCardOrders` are **not** gated: they are owner-scoped -reads of orders the customer already paid for, and switching the rail off must -never hide codes they own. Ownership is enforced in the app layer +`giftCardCatalog` and `giftCardQuote` open with `gateGiftCardsForAccount` +(`src/graphql/public/root/gift-card-gate.ts`) and throw the mapped error. +`giftCardPurchase` does **not** gate at the resolver: `purchaseGiftCard` gates +after its same-key replay lookup, so a retry of a timed-out purchase still +finds its order once the kill switch is off, and a fresh purchase while the +rail is off gets `GIFT_CARDS_DISABLED` in `errors` from the app layer. +`giftCardOrder` and `giftCardOrders` are **not** gated at all: they are +owner-scoped reads of orders the customer already paid for, and switching the +rail off must never hide codes they own. Ownership is enforced in the app layer (`getGiftCardOrderForAccount` answers not-found for a non-owner). Money on -products, quotes and orders is in -**minor units** of the product currency (`CentAmount`); what the customer pays -is in sats (`SatAmount`). +products, quotes and orders is in **minor units** of the product currency +(`CentAmount`); what the customer pays is in sats (`SatAmount`). Resolvers on disk at the time of writing: `src/graphql/public/root/query/gift-card-catalog.ts`, `gift-card-quote.ts`, @@ -22,10 +24,11 @@ Resolvers on disk at the time of writing: `Boolean!` on `Globals` (`src/graphql/public/types/object/globals.ts`, resolved in `root/query/globals.ts`). True only when `giftCards.enabled` is on -**and** at least one provider is enabled. When false, hide every gift card -entry point: `giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` refuse. -`giftCardOrder` and `giftCardOrders` keep resolving so a customer can always -reach a card they already bought. +**and** at least one provider is both enabled and registered by an adapter +(`isGiftCardProviderEnabled`). When false, hide every gift card +entry point: `giftCardCatalog`, `giftCardQuote` and a fresh `giftCardPurchase` +refuse. `giftCardOrder` and `giftCardOrders` keep resolving so a customer can +always reach a card they already bought. ## Queries @@ -39,8 +42,8 @@ query GiftCardCatalog($countryCode: CountryCode, $first: Int, $after: String) { node { id name brand countryCode currency denominationType # FIXED | VARIABLE - denominations minValue maxValue - isOpenLoop categories logoUrl termsUrl rewardBps + denominations minValue maxValue wholeUnitsOnly + maxQuantity isOpenLoop categories logoUrl termsUrl rewardBps } } pageInfo { hasNextPage endCursor } @@ -54,8 +57,15 @@ query GiftCardCatalog($countryCode: CountryCode, $first: Int, $after: String) { - In stock only; open-loop cards only when `giftCards.allowOpenLoop` is on. Ordered by brand, name, id. `first` 1..200, default 50. An unknown `after` restarts from the top (repeat possible, never a gap). +- `maxQuantity` (1 for TBC) is the most cards one order may hold; + `wholeUnitsOnly` is true when a VARIABLE card takes whole currency units + only (no cents). Both are enforced at quote and purchase + (`GIFT_CARD_INVALID_VALUE`). - Served from Redis. A stale catalog is still served; a missing one throws - `GIFT_CARD_CATALOG_UNAVAILABLE`. + `GIFT_CARD_CATALOG_UNAVAILABLE`. Each sync deletes the keys of cards the + vendor de-listed, so a removed card stops being quotable at once; rows cached + before `maxQuantity` / `wholeUnitsOnly` existed read as 1 / false until the + next sync rewrites them. ### `giftCardQuote` @@ -71,10 +81,11 @@ query GiftCardQuote($productId: ID!, $value: CentAmount!, $quantity: Int) { } ``` -Refuses exactly what the purchase would refuse (same first four steps, same +Refuses exactly what the purchase would refuse (same first steps, same errors), so the client can surface problems before the confirm screen. Not a -reservation: nothing is held, no limit is consumed. Quotes are good for ~60 s -(`QUOTE_TTL_MS`); the purchase re-quotes regardless. +reservation: nothing is held, no purchase limit is consumed; only the 30/min +quote attempt budget is charged. Quotes are good for ~60 s (`QUOTE_TTL_MS`); +the purchase re-quotes regardless. ### `giftCardOrder` @@ -92,24 +103,26 @@ The endpoint to poll after a purchase and the **only** place `claim` is returned. Returns `null` for an unknown id **and** for another account's order; the two are deliberately indistinguishable (`getGiftCardOrderForAccount`). Not behind the master gate (see the top of -this page). +this page). `paidSats` is the vendor invoice amount, excluding the Lightning +routing fee. `failureReason` is a short machine-oriented string for support and diagnostics (`vendor-create-failed: GiftCardVendorRejectedOrderError`, `quote-mismatch: quoted 41234 invoiced 42000`, `expired`, -`fulfillment-timeout`, ...). It is **not** customer copy; choose the wording -from `status`. +`fulfillment-timeout`, `payment-unresolved-expired`, ...). It is **not** +customer copy; choose the wording from `status`. **Claim rule.** `claim` is non-null only when all of the following hold: the caller owns the order, `status` is `FULFILLED`, and the ciphertext decrypts -under the currently configured key. This is enforced twice: the app layer -decrypts only for the owner once fulfilled, and `toGiftCardOrderSource` drops -any claim unless `status === FULFILLED`. A `FULFILLED` order whose claim cannot -be decrypted (key missing, rotated, or one pod on stale config) is **still -returned**, with `claim: null`, so the customer can always see the order they -paid for; the fault is recorded server-side at Critical (span + log, never the -ciphertext) rather than thrown to the client. Only `giftCardPurchase` surfaces -it, as `GIFT_CARD_CLAIM_UNAVAILABLE` alongside the order. A client polling +under the currently configured key for this order. This is enforced twice: the +app layer decrypts only for the owner once fulfilled, and +`toGiftCardOrderSource` drops any claim unless `status === FULFILLED`. A +`FULFILLED` order whose claim cannot be decrypted (key missing, rotated, or one +pod on stale config) is **still returned**, with `claim: null`, so the +customer can always see the order they paid for; the fault is recorded +server-side at Critical (span + log, never the ciphertext) rather than thrown +to the client. Only `giftCardPurchase` surfaces it, as +`GIFT_CARD_CLAIM_UNAVAILABLE` alongside the order. A client polling `giftCardOrder` that sees `FULFILLED` with `claim: null` should show "contact support" and keep polling; the claim appears once the key is restored. @@ -145,9 +158,9 @@ mutation GiftCardPurchase($input: GiftCardPurchaseInput!) { ``` `GiftCardPurchaseInput`: `productId: ID!`, `value: CentAmount!` (one card), -`quantity: Int` (1..10, default 1), `walletId: WalletId!` (must belong to the -caller), `idempotencyKey: String!` (8 to 64 chars, no whitespace; a UUID is -ideal). +`quantity: Int` (1..10 and at most the product's `maxQuantity`, default 1), +`walletId: WalletId!` (must belong to the caller), `idempotencyKey: String!` +(8 to 64 chars, no whitespace; a UUID is ideal). Behaviour: @@ -155,33 +168,38 @@ Behaviour: `quoteToleranceBps` from the quote. No money moves on any refusal. - Money moves at most once per `idempotencyKey`. Retry a timed-out call with the **same** key: same key + same parameters returns the existing order in - whatever state it reached; same key + different parameters is refused. One - exception: an `INVOICE_ISSUED` order still inside its expiry is **resumed**, - not returned. The server re-enters the payment step under its own - server-side key (`giftcard:`), so a first call that died - mid-payment is finished (cached outcome replayed, or the one send that never - happened made) rather than left to expire. `CREATED` is returned as-is. -- `order` is returned in whatever state the purchase reached: often already + whatever state it reached, without spending the attempt budget; same key + + different parameters is refused. One exception: an `INVOICE_ISSUED` order + still inside its expiry is **resumed**, not returned. The server re-enters + the payment step under its own server-side key (`giftcard:`), so a + first call that died mid-payment is finished (cached outcome replayed, or + the one send that never happened made) rather than left to expire. If that + key's lock is busy (the first call is still in flight) the current row is + returned unchanged. `CREATED` is returned as-is. +- Once IBEX has answered, `order` is **always** returned: often already `FULFILLED` with `claim` attached, otherwise `PAID` or `PAYMENT_PENDING` - (poll `giftCardOrder`), or `FAILED` / `PAYMENT_FAILED` (see - `failureReason`). A send whose outcome IBEX could not confirm (network + (poll `giftCardOrder`). A send whose outcome IBEX could not confirm (network fault, gateway error or timeout after the request was accepted) comes back - as a `PAYMENT_PENDING` order, **not** as an error: poll, do not buy again. - `order` is absent when refused before a row existed. It can be present - **alongside** an error when the card was issued but the claim could not be - read. + as a `PAYMENT_PENDING` order, **not** as an error: poll, do not buy again. A + bookkeeping failure after a successful send (the PAID write lost) raises a + Critical `paid-not-recorded` ops event on the Flash side and still returns + the order. `FAILED` / `PAYMENT_FAILED` orders are returned with + `failureReason`. `order` is absent only when refused before a row existed. + It can be present **alongside** an error when the card was issued but the + claim could not be read. - Attempt budget: 10 per minute per account, 5-minute block - (`RateLimitConfig.giftCardPurchase`), charged before any vendor call. + (`RateLimitConfig.giftCardPurchase`), charged before any vendor call on a + fresh attempt; a same-key replay does not consume it. ### `GiftCardOrderStatus` | Value | Meaning for the client | | --- | --- | -| `CREATED`, `INVOICE_ISSUED`, `PAYMENT_PENDING`, `PAID` | Transient. Keep polling. Never purchase again. `PAYMENT_PENDING` can take minutes: the worker re-reads IBEX and, when IBEX cannot account for the send, asks the vendor | +| `CREATED`, `INVOICE_ISSUED`, `PAYMENT_PENDING`, `PAID` | Transient. Keep polling. Never purchase again. `PAYMENT_PENDING` can take minutes: the worker re-reads IBEX and, when IBEX cannot account for the send, asks the vendor. A vendor `disputed` is held as pending, not refunded: the order stays `PAID` until TBC resolves it or the 24 h timeout | | `FULFILLED` | Terminal. `claim` and `fulfilledAt` are set | | `FAILED` | Terminal, nothing paid: vendor rejected, price moved past tolerance, or unreadable invoice. Safe to retry with a new key | -| `PAYMENT_FAILED` | Terminal, nothing paid: IBEX **provably** refused or failed the Lightning payment (insufficient balance, a corroborated payment failure, a send-guard rejection). Only proven refusals land here; a send whose outcome is unknown is `PAYMENT_PENDING`. Safe to retry with a new key | -| `EXPIRED` | Terminal, nothing paid: invoice not paid in time | +| `PAYMENT_FAILED` | Terminal, nothing paid: IBEX **provably** refused or failed the Lightning payment (insufficient balance, a corroborated payment failure, a send-guard rejection), or a pending send with no IBEX ref that the vendor reports unpaid 24 h past expiry (`payment-unresolved-expired`). Safe to retry with a new key | +| `EXPIRED` | Terminal, nothing paid: invoice not paid in time. (A late IBEX Success can still move it to `PAID`; keep showing the latest `status`) | | `REFUND_REQUIRED` | Terminal. The wallet paid, no card arrived; Flash has been paged. Do **not** tell the customer to buy again | ## Error codes @@ -191,22 +209,22 @@ From `src/graphql/error-map.ts`. On queries they arrive as a GraphQL error with | Code | Fires when | Client should | | --- | --- | --- | -| `GIFT_CARDS_DISABLED` | `giftCards.enabled` is off, or an open-loop card while `allowOpenLoop` is off | Hide the feature; `globals.giftCardsEnabled` should already be false | -| `GIFT_CARD_PROVIDER_UNAVAILABLE` | No enabled provider routed for the account's country | "Not available in your region"; deterministic until config changes | -| `GIFT_CARD_PRODUCT_NOT_FOUND` | Malformed id, unknown provider prefix, cache miss, out of stock, or an open-loop card hidden by config | Refresh the catalog | -| `GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY` | Product's provider is not the one routed for the account | Show the account-country catalog | -| `GIFT_CARD_INVALID_VALUE` | Value not a listed denomination / outside min..max, or quantity not 1..10 | Fix the input from the product's denomination fields | +| `GIFT_CARDS_DISABLED` | `giftCards.enabled` is off (catalog, quote, a fresh purchase) | Hide the feature; `globals.giftCardsEnabled` should already be false | +| `GIFT_CARD_PROVIDER_UNAVAILABLE` | No registered and enabled provider routed for the account's country | "Not available in your region"; deterministic until config changes | +| `GIFT_CARD_PRODUCT_NOT_FOUND` | Malformed id, unknown provider prefix, cache miss, out of stock, or an open-loop card hidden by `allowOpenLoop` (catalog, quote and purchase all answer this) | Refresh the catalog | +| `GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY` | Product's provider is not the one routed for the account, or the product's `countryCode` differs from the account's known country (skipped when the country is unknown, `"XX"`). Enforced at quote and purchase | Show the account-country catalog | +| `GIFT_CARD_INVALID_VALUE` | Value not a listed denomination / outside min..max / not whole units when `wholeUnitsOnly`, quantity not 1..10 or above `maxQuantity`, or the vendor's quote endpoint returned a 4xx for the value | Fix the input from the product's denomination fields | | `GIFT_CARD_LIMIT_EXCEEDED` | Limits `enforce` mode: account too new, per-card, daily, vendor cap, or velocity | Show the message; it names the cap | | `GIFT_CARD_LEVEL_NOT_ELIGIBLE` | Level 0, below `minAccountLevel`, or level < 2 for open-loop (enforce mode) | Route to the account upgrade flow, not a retry | | `GIFT_CARD_QUOTE_MISMATCH` | Vendor invoice exceeded the quote by more than tolerance; order is `FAILED`, nothing paid | Re-quote and retry with a new key | | `GIFT_CARD_ORDER_NOT_FOUND` | Not thrown by `giftCardOrder` (returns null); can surface from internal reads | Treat as not found | | `GIFT_CARD_ORDER_STATE` | An illegal transition was attempted (e.g. INVOICE_ISSUED write lost a race) | Poll `giftCardOrder`; do not retry the purchase | -| `GIFT_CARD_VENDOR_REJECTED` | Vendor returned a definitive 4xx on order creation, or an unreadable invoice; `FAILED`, nothing paid | Show message; retry later with a new key | +| `GIFT_CARD_VENDOR_REJECTED` | Vendor returned a definitive 4xx on order creation, or an unreadable invoice; `FAILED`, nothing paid. The message is a fixed customer string; the vendor's text stays in logs | Show message; retry later with a new key | | `GIFT_CARD_VENDOR_UNAVAILABLE` | Vendor unreachable, 5xx, auth failure, or unexpected response shape | "Temporarily unavailable"; retry later | | `GIFT_CARD_CATALOG_UNAVAILABLE` | Catalog key missing from Redis or Redis unreadable | Retry later; the sync job repopulates | -| `GIFT_CARD_CLAIM_UNAVAILABLE` | Claim ciphertext could not be decrypted (key missing, rotated, corrupt). Returned only by `giftCardPurchase`, alongside the order; `giftCardOrder` returns the order with `claim: null` instead | Show "contact support"; the order is still `FULFILLED` | +| `GIFT_CARD_CLAIM_UNAVAILABLE` | Claim ciphertext could not be decrypted (key missing, rotated, corrupt, or bound to another order). Returned only by `giftCardPurchase`, alongside the order; `giftCardOrder` returns the order with `claim: null` instead | Show "contact support"; the order is still `FULFILLED` | | `GIFT_CARD_IDEMPOTENCY_KEY_REUSE` | Same key, different product/value/quantity | Generate a new key for a new purchase | -| `GIFT_CARD_PURCHASE_RATE_LIMITED` | More than 10 purchase attempts in a minute | Back off 5 minutes | +| `GIFT_CARD_PURCHASE_RATE_LIMITED` | More than 10 fresh purchase attempts in a minute (replays do not count) | Back off 5 minutes | | `GIFT_CARD_QUOTE_RATE_LIMITED` | More than 30 `giftCardQuote` calls in a minute | Back off 5 minutes; reuse the last quote until its `expiresAt` | | `GIFT_CARD_UNKNOWN` | Limits store unreadable (`limits-unavailable` in enforce mode) or an adapter threw | Retry later; this pages on the Flash side | @@ -215,4 +233,6 @@ scalar validation (`CentAmount`, `WalletId`) and a bad `idempotencyKey` length return a plain `errors[].message` with no code; a wallet that does not belong to the caller is a generic validation error; and the payment step passes through the send guard's and IBEX's own errors (e.g. rate limit or limit -errors from `authorizeSend`, `IbexError` subclasses) with their existing codes. +errors from `authorizeSend`, `IbexError` subclasses) with their existing codes +**only when they prove IBEX never accepted the send**; anything after that is +an order, not an error. diff --git a/docs/gift-cards/ARCHITECTURE.md b/docs/gift-cards/ARCHITECTURE.md index 096199262..0b9b87fcd 100644 --- a/docs/gift-cards/ARCHITECTURE.md +++ b/docs/gift-cards/ARCHITECTURE.md @@ -22,18 +22,17 @@ Flash holds no gift card inventory and no balances of its own: user funds sit at IBEX, so a purchase is a Lightning payment from the user's IBEX account to -the vendor's invoice. Flash's job is ordering, paying exactly once, and -delivering the claim. +the vendor's invoice. Flash orders, pays exactly once, and delivers the claim. ## Layers and files | Layer | Files | Responsibility | | --- | --- | --- | | Domain | `src/domain/gift-cards/index.ts`, `index.types.d.ts`, `primitives.ts`, `errors.ts` | Status enum + transition table, product/quote/order/claim types, the `IGiftCardProvider` port, product-id codec, value/quantity validation, every `GiftCard*` error | -| Registry | `src/services/gift-cards/registry.ts`, `index.ts` | Adapters self-register at import; config-driven routing (`routing.byCountry[cc] ?? routing.default`, gated by `providers..enabled`) for new money; `getRegisteredGiftCardProviderOrError` (registration only, no `enabled` gate) for orders that already exist | +| Registry | `src/services/gift-cards/registry.ts`, `index.ts` | Adapters self-register at import; config-driven routing (`routing.byCountry[cc] ?? routing.default`, keys and lookup both case-normalised) requires the result to be **registered and enabled** for new money; `getRegisteredGiftCardProviderOrError` (registration only, no `enabled` gate) for orders that already exist | | Adapter (TBC) | `src/services/gift-cards/bitcoin-company/{index,client,mapping,schemas,errors}.ts` | HTTP transport, auth token lifecycle in Redis, retries (idempotent reads only), zod validation, vendor-to-domain mapping. Nothing vendor-shaped leaves this directory | -| Catalog cache | `src/services/gift-cards/catalog-cache.ts` | Redis read model of the catalog; sync job writes, every request reads | -| Claim crypto | `src/services/gift-cards/claim-crypto.ts` | AES-256-GCM at rest for claim data; `claimKeyId` derivation | +| Catalog cache | `src/services/gift-cards/catalog-cache.ts` | Redis read model of the catalog; sync job writes (and deletes the keys of de-listed cards), every request reads; rows cached before `maxQuantity` / `wholeUnitsOnly` existed read as 1 / false | +| Claim crypto | `src/services/gift-cards/claim-crypto.ts` | AES-256-GCM at rest for claim data; `claimKeyId` derivation; AAD binds each ciphertext to its key id and order | | Use cases | `src/app/gift-cards/` | `purchase-gift-card.ts`, `quote-gift-card.ts`, `authorize-purchase.ts` (limits), `reservation-store.ts` (daily-cap holds), `settle-order.ts` (the single settlement path), `reconcile-orders.ts` (worker), `sync-catalog.ts`, `list-products.ts`, `get-order.ts`, `list-orders.ts`, `gift-cards-master-gate.ts`, `ops.ts`, `send-fulfilled-notification.ts` | | Payment rail | `src/app/payments/pay-invoice-via-ibex.ts` (+ `idempotency.ts`, `authorize-send.ts`) | Pays a bolt11 from the wallet's IBEX account under `withPaymentIdempotency` with the ENG-573 send guard | | Persistence | `src/services/mongoose/gift-card-orders.ts`, `gift-card-orders.mapping.ts`, `schema.ts` (`GiftCardOrderSchema`), `src/migrations/20260909120000-gift-card-orders-indexes.ts` | `giftcardorders` collection; every status move is one conditional `findOneAndUpdate` | @@ -43,41 +42,50 @@ delivering the claim. Error names all carry the `GiftCard` prefix because `error-map.ts` switches on the constructor name across every error module; a collision would be a silent -mis-mapping (`src/domain/gift-cards/errors.ts`). +mis-mapping. ## The provider port `IGiftCardProvider` (`src/domain/gift-cards/index.types.d.ts`) is pure vendor -I/O. Flash policy (fees, limits, open-loop gating, tolerance) never lives in an -adapter. +I/O; Flash policy (fees, limits, open-loop gating, tolerance) never lives in an adapter. | Method | Called by | Notes | | --- | --- | --- | | `listProducts()` | `sync-catalog.ts` only | Never on a request path | -| `quote({ product, valueMinor, quantity })` | `quoteGiftCard`, `purchaseGiftCard` | Vendor price in sats, `expiresAt` | -| `createOrder({ product, valueMinor, quantity, reference })` | `purchaseGiftCard` | Returns `providerOrderId`, `paymentRequest` (bolt11), `amountSats`, `expiresAt`. `reference` is our order id, sent as the vendor label. Never retried | +| `quote({ product, valueMinor, quantity })` | `quoteGiftCard`, `purchaseGiftCard` | Vendor price in sats, `expiresAt`. A vendor 4xx here is `GiftCardInvalidValueError` (the value is not sellable), not a vendor fault | +| `createOrder({ product, valueMinor, quantity, reference })` | `purchaseGiftCard` | Returns `providerOrderId`, `paymentRequest` (bolt11), `amountSats`, `expiresAt` (null when the vendor states none). `reference` is our order id, sent as the vendor label. Never retried | | `getOrder({ providerOrderId, paymentRequest })` | `fetchAndSettle` | Returns `awaitingPayment`, `paidPendingFulfillment`, `fulfilled` (+claim), `failed`, `refunded` | Provider ids are `bitcoinCompany` and `bitrefill` (`GIFT_CARD_PROVIDER_IDS`). -**Only `bitcoinCompany` has an adapter.** Routing a country to `bitrefill` -yields `GiftCardProviderUnavailableError` because nothing is registered under -that id, whatever `providers.bitrefill.enabled` says. +**Only `bitcoinCompany` has an adapter**; routing a country to `bitrefill` is +`GiftCardProviderUnavailableError` whatever `providers.bitrefill.enabled` says. TBC specifics (`bitcoin-company/mapping.ts`): vendor money is in major units, the domain is minor units; a `fulfilled` status without claim data is held as `paidPendingFulfillment` with a warning, never reported fulfilled; unknown -vendor statuses are also held as pending; order status is looked up by +vendor statuses are also held as pending, and so is `disputed` (TBC may still +fulfil or refund it; neither is assumed); order status is looked up by **invoice**, not order id, so `getOrder` refuses a ref with no `paymentRequest`. -Quote TTL (60s) and order TTL (15m) are assumed by the adapter, not returned by -the vendor. +At sync, physical and non-Lightning rows are skipped (`PRODUCT_MAPPING_SKIP_REASONS`); +`resellingEnabled: false` rows are kept and counted as `flagged.notResellable` +(every row reads false until KYB; ENG-586). +The quote TTL (60 s, `QUOTE_TTL_MS`) is assumed by the adapter because the +vendor returns none. The adapter does **not** assume an order TTL: +`createOrder` reports `expiresAt: null`, and `purchaseGiftCard` sets the order's +expiry to the earliest of its own 15 min order TTL, the decoded BOLT11 expiry, +and any vendor-stated expiry. Products carry `maxQuantity` (1 for TBC) and +`wholeUnitsOnly`, enforced at quote and purchase (API.md). ## Routing and the master gate `giftCardsMasterGate(countryCode)` (`src/app/gift-cards/gift-cards-master-gate.ts`) checks `giftCards.enabled`, then resolves a provider for the country and -requires it to be enabled. `giftCardCatalog`, `giftCardQuote`, -`giftCardPurchase` and the quote/purchase use cases open with it, so the -catalog can never show what the purchase would refuse. The order reads +requires it to be registered and enabled. `giftCardCatalog` and `giftCardQuote` +open with it (`gateGiftCardsForAccount`); the quote and purchase use cases run +it themselves, so the catalog never shows what the purchase would refuse. The +purchase **resolver** does not run it: `purchaseGiftCard` gates after its +same-key replay lookup, so a retry of a timed-out purchase still finds its +order once the kill switch is off (FLOWS 7). The order reads (`giftCardOrder`, `giftCardOrders`) are deliberately **not** gated: they are owner-scoped (`getGiftCardOrderForAccount` answers not-found for a non-owner) and a customer must be able to reach a card they already paid for while the @@ -85,8 +93,10 @@ rail is off. The account's country comes from the phone number only (`resolvePhoneCountries` in `src/app/bridge/kyc-gate.ts`); Bridge KYC stores no country. An ambiguous -calling code (NANP) resolves to the sentinel `"XX"`, which can never match a -`routing.byCountry` entry and so always takes `routing.default`. +calling code (NANP) resolves to the sentinel `"XX"`, which never matches a +`routing.byCountry` entry and so takes `routing.default`. With a known country, +a product whose `countryCode` differs is refused at quote and purchase +(`GiftCardProductNotAvailableInCountryError`); skipped for `"XX"`. ## Order state machine @@ -97,10 +107,10 @@ CREATED -> INVOICE_ISSUED | FAILED | EXPIRED INVOICE_ISSUED -> PAYMENT_PENDING | PAID | PAYMENT_FAILED | EXPIRED | FAILED PAYMENT_PENDING -> PAID | PAYMENT_FAILED PAID -> FULFILLED | REFUND_REQUIRED +EXPIRED -> PAID (late settlement only; see below) FULFILLED -> (terminal) FAILED -> (terminal) PAYMENT_FAILED -> (terminal) -EXPIRED -> (terminal) REFUND_REQUIRED -> (terminal) ``` @@ -108,7 +118,11 @@ REFUND_REQUIRED -> (terminal) arrived". `FAILED`, `PAYMENT_FAILED` and `EXPIRED` all mean nothing was paid: `PAYMENT_FAILED` is written only for a send IBEX provably refused, a send whose outcome is unknown goes to `PAYMENT_PENDING`, and an expiring invoice is -checked against IBEX and the vendor first. +checked against the vendor first. `EXPIRED` stays in +`GIFT_CARD_TERMINAL_STATUSES` — the worker never polls it — but has one exit: +a late IBEX Success for an invoice the worker expired while the send was still +in flight moves it to `PAID` (reason `payment-settled-after-expiry`) rather +than writing the money off. The repository refuses an illegal move before touching Mongo, and the write is `findOneAndUpdate({ id, status: { $in: from } })`, so of two racing settlers exactly one wins and the loser gets `GiftCardOrderStateError`. @@ -120,11 +134,12 @@ Who moves what: | `CREATED -> INVOICE_ISSUED` | `purchaseGiftCard`, after `provider.createOrder` and the tolerance check | | `CREATED/INVOICE_ISSUED -> FAILED` | `purchaseGiftCard` (`failUnpaidOrder`), `settleOrderFromVendor` on vendor `failed`/`refunded` before payment | | `INVOICE_ISSUED -> PAID / PAYMENT_PENDING / PAYMENT_FAILED` | `purchaseGiftCard` from the `PaymentSendStatus`, or from the send error's class: a proven refusal is `PAYMENT_FAILED`, anything else `PAYMENT_PENDING` (FLOWS 4a). Also entered by a same-key replay of an unpaid `INVOICE_ISSUED` order | -| `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX | +| `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX; also `PAYMENT_FAILED` (`payment-unresolved-expired`) when there is no IBEX ref, the vendor positively reports not paid, and now is past `expiresAt` + 24 h | | `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment — including the reconcile worker's vendor fallback when IBEX cannot account for the send | -| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`, after one payment re-read and, when IBEX cannot answer, one vendor poll | +| `EXPIRED -> PAID` | `purchaseGiftCard` (`markPaidAndSettle`) on a late IBEX Success for an already-expired row, reason `payment-settled-after-expiry` | +| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`; an INVOICE_ISSUED row gets one vendor poll first (it never has an IBEX ref, so there is nothing to re-read at IBEX) | | `PAID -> FULFILLED` | `settleOrderFromVendor` (`fulfil`) with the encrypted claim | -| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24h in PAID when a final vendor poll does not report fulfilled (`fulfillment-timeout`) | +| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24 h in PAID **only** when the final vendor poll positively reports not fulfilled (`fulfillment-timeout`) — a vendor error keeps PAID and retries | ## Decisive facts @@ -135,22 +150,26 @@ Who moves what: this deployment. On this rail the IBEX account id is the Flash wallet id. **Order first, vendor second, payment third.** `purchaseGiftCard` writes the -`CREATED` row before calling `provider.createOrder`, and calls the vendor -before paying. A crash at any point leaves a row the reconcile worker can -reason about; a vendor order with no Flash row cannot exist. +`CREATED` row before `provider.createOrder`, and calls the vendor before +paying: a crash anywhere leaves a row the worker can reason about, and a vendor +order with no Flash row cannot exist. **Money moves at most once per order.** Two layers: - The order row is unique on `(walletId, idempotencyKey)`; a replay with the same key and parameters returns the existing order without reaching the - vendor, and the same key with different parameters is - `GiftCardIdempotencyKeyReuseError`. + vendor and without spending the purchase attempt budget; the same key with + different parameters is `GiftCardIdempotencyKeyReuseError`. - The Lightning send runs under `withPaymentIdempotency` with key `giftcard:` and fingerprint `ln||giftcard|`, so a different order can never replay a previous success. The cached result lives at `payment-idempotency::giftcard:` (`src/app/payments/idempotency.ts`). +**Once IBEX has answered, the mutation returns the order.** A lost PAID write +after a Success raises a Critical `paid-not-recorded` ops event and still hands +back the order; an error there would read as "nothing happened" for money gone. + **Quote tolerance.** The invoice Flash is about to pay is decoded and `max(decodedSats, vendorStatedSats)` must be `<= quoteSats * (1 + toleranceBps/10000)`. `GiftCardsConfig.quoteToleranceBps` wins over the domain constant @@ -159,20 +178,26 @@ nothing paid. **Claim data is ciphertext at rest.** `encryptGiftCardClaim` produces AES-256-GCM, base64 of `version(0x01) | iv(12) | tag(16) | data`, with a fresh -iv per call. `claimKeyId` (first 16 hex chars of sha256 of the raw key bytes) -is stored next to each ciphertext; a mismatch on read is reported as -`GiftCardClaimCryptoError`, never guessed around. Plaintext exists only inside +iv per call. The GCM additional data binds the ciphertext to the envelope +version, the `claimKeyId` and the order id, so a ciphertext transplanted onto +another order fails to decrypt. `claimKeyId` (first 16 hex chars of sha256 of +the raw key bytes) is stored next to each ciphertext; a mismatch on read is +reported as `GiftCardClaimCryptoError`, never guessed around. The key must load +before any money moves (`payIssuedOrder` checks `claimCryptoReady()` right before +the pay, on the first attempt and on replay-resume; failure marks the order +`FAILED` with `claim-key-not-configured`, after the vendor order exists but before +IBEX is called). Plaintext exists only inside `settle-order.ts` (in) and `get-order.ts` (out); it is never logged, traced, or put in an ops event, `toJSON` on the schema drops `claimCiphertext`, and the GraphQL source object (`toGiftCardOrderSource`) has no ciphertext field at all. -**Catalog is served from Redis only.** No request path calls -`listProducts`. Between `catalog.ttlSeconds` and `catalog.staleAfterSeconds` -the catalog is served with `stale: true`; past `staleAfterSeconds` Redis drops -the key and reads become `GiftCardCatalogUnavailableError`. +**Catalog is served from Redis only.** No request path calls `listProducts`. +Between `catalog.ttlSeconds` and `catalog.staleAfterSeconds` the catalog is +served `stale: true`; past `staleAfterSeconds` the key is gone and reads become +`GiftCardCatalogUnavailableError`. -**`feeBps` is not applied anywhere.** The config key exists and defaults to 0; -no code path reads it. Revenue today is the vendor reward only. +**`feeBps` is not applied anywhere.** The key exists, defaults to 0, and no code +path reads it; revenue today is the vendor reward only. ## Redis key inventory @@ -180,7 +205,8 @@ no code path reads it. Revenue today is the vendor reward only. | --- | --- | --- | --- | | `giftcards:catalog::` | `{ syncedAt, products[] }` | `catalog.staleAfterSeconds` | `GiftCardCatalogCache.write` | | `giftcards:catalog::countries` | `string[]` (written last) | `catalog.staleAfterSeconds` | same | -| `giftcards:product:` | `GiftCardProduct` | `catalog.staleAfterSeconds` | same | +| `giftcards:product:` | `GiftCardProduct` | `catalog.staleAfterSeconds` | same; deleted on the next sync once the vendor de-lists the card | +| `giftcards:products:` | `string[]` of product ids from the last sync | `catalog.staleAfterSeconds` | same (the diff that finds de-listed cards) | | `giftcards:catalog-sync:lock` | token | 10 min, `NX` | `syncGiftCardCatalogs` | | `giftcards:catalog-sync:last-run` | ISO timestamp | `catalog.syncIntervalSeconds`, `NX` | same (throttle marker) | | `giftcards:reconcile:lock` | token | 5 min, `NX` | `reconcileGiftCardOrders` | @@ -189,16 +215,16 @@ no code path reads it. Revenue today is the vendor reward only. | `giftcards:reservation::` | amount (minor) | 24 h | `reservation-store.ts` | | `giftcards:reservations:` | zset, score = expiry ms, member `:` | pruned by score on read | same | | `payment-idempotency::giftcard:` | cached send result | see `idempotency.ts` | `withPaymentIdempotency` | -| rate-limiter bucket, prefix `gift_card_purchase` | per `accountId` | 60 s window, 300 s block | `consumeLimiter` (`RateLimitConfig.giftCardPurchase`) | +| rate-limiter bucket, prefix `gift_card_purchase` | per `accountId` | 60 s window, 300 s block | `consumeLimiter` (`RateLimitConfig.giftCardPurchase`); not charged on a same-key replay | +| rate-limiter bucket, prefix `gift_card_quote` | per `accountId` | 60 s window, 300 s block | `consumeLimiter` (`RateLimitConfig.giftCardQuote`) | -Every module that touches Redis imports `@services/redis` lazily so unit tests -never open a connection. +Every Redis user imports `@services/redis` lazily so unit tests never connect. ## Mongo: `giftcardorders` -One document per purchase attempt; fields as in `GiftCardOrder` -(`index.types.d.ts`). Indexes, identical in `GiftCardOrderSchema` and the -migration so `syncIndexes()` on boot has nothing to rebuild: +One document per purchase attempt; fields as in `GiftCardOrder`. Indexes are +identical in `GiftCardOrderSchema` and the migration so boot-time `syncIndexes()` +has nothing to rebuild: | Name | Keys | Options | Purpose | | --- | --- | --- | --- | @@ -208,20 +234,21 @@ migration so `syncIndexes()` on boot has nothing to rebuild: | `status_1_updatedAt_1` | `{ status: 1, updatedAt: 1 }` | | Reconciler sweep, stalest first | | `walletId_1_idempotencyKey_1` | `{ walletId: 1, idempotencyKey: 1 }` | unique | Client retry / concurrent same-key purchase yields one order | -The E11000 on `walletId_1_idempotencyKey_1` is mapped to -`GiftCardOrderDuplicateKeyError`; any other duplicate stays a generic -`DuplicateKeyForPersistError` because it is a bug. +The E11000 on `walletId_1_idempotencyKey_1` maps to `GiftCardOrderDuplicateKeyError`; +any other duplicate stays a generic `DuplicateKeyForPersistError` (a bug). ## Jobs | Job | Where | Cadence | Guard | | --- | --- | --- | --- | -| Catalog sync | `cron.ts` `syncGiftCardCatalogsJob` | every cron run, throttled to `catalog.syncIntervalSeconds` by the Redis marker | `giftCards.enabled`; distributed lock | +| Catalog sync | `cron.ts` `syncGiftCardCatalogsJob` | every cron run, throttled to `catalog.syncIntervalSeconds` by the Redis marker | `giftCards.enabled`; distributed lock. A pull that receives zero rows or keeps none is a failure (`catalog-sync-failed`); the previous catalog keeps serving | | Reconcile | `trigger.ts` `startGiftCardReconcileInterval` (30 s) and `cron.ts` `reconcileGiftCardOrdersJob` | 30 s in the trigger pod; cron as safety net | any non-terminal order exists (`hasOpenGiftCardOrders`, one `limit: 1` read) — **not** `giftCards.enabled`, so the kill switch never strands a paid order; shared Redis lock | Reconcile PAID backoff: 5s, 15s, 60s, 5m, then every 15m from the PAID -transition. Attempt timing is process memory, so the cron (a fresh process) +transition; attempt timing is process memory, so the cron (a fresh process) polls each PAID order once per run and the trigger interval carries the real -schedule. Stuck-order horizons: PAID for 24h becomes `REFUND_REQUIRED`; -PAYMENT_PENDING with no resolvable IBEX status logs a warning after 60 min and -stays put. +schedule. Horizons: PAID for 24 h becomes `REFUND_REQUIRED` only when the final +vendor poll positively reports not fulfilled; PAYMENT_PENDING with no +resolvable IBEX status warns after 60 min and, with no ref, a vendor not-paid +and `expiresAt` + 24 h passed, becomes `PAYMENT_FAILED` +(`payment-unresolved-expired`). diff --git a/docs/gift-cards/CONFIG.md b/docs/gift-cards/CONFIG.md index eaf7adcb6..e6e377578 100644 --- a/docs/gift-cards/CONFIG.md +++ b/docs/gift-cards/CONFIG.md @@ -14,14 +14,14 @@ Local overrides go in `$CONFIG_PATH/dev-overrides.yaml` | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `enabled` | boolean | `false` | Master switch for **new money only**. Off: `giftCardCatalog`, `giftCardQuote` and `giftCardPurchase` return `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, catalog sync is a no-op, a same-key replay of an unpaid `INVOICE_ISSUED` order is not resumed. The reconcile worker keeps running while any non-terminal order exists (settlement and the 24 h `REFUND_REQUIRED` alert outlive the switch; RUNBOOK d). `giftCardOrder` / `giftCardOrders` still resolve (owner-scoped reads of paid orders) | -| `allowOpenLoop` | boolean | `false` | Show and sell open-loop (Visa/Mastercard-style) cards. Off: hidden from the catalog, `GIFT_CARD_PRODUCT_NOT_FOUND` by id, `GIFT_CARDS_DISABLED` on purchase. On: still requires account level >= 2 | +| `enabled` | boolean | `false` | Master switch for **new money only**. Off: `giftCardCatalog` and `giftCardQuote` return `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, catalog sync is a no-op. A **fresh** `giftCardPurchase` returns `GIFT_CARDS_DISABLED`; a same-key replay still returns the order it already created (the gate runs after the replay lookup), except that an unpaid `INVOICE_ISSUED` row is returned as-is rather than resumed. The reconcile worker keeps running while any non-terminal order exists (settlement and the 24 h `REFUND_REQUIRED` alert outlive the switch; RUNBOOK d). `giftCardOrder` / `giftCardOrders` still resolve (owner-scoped reads of paid orders) | +| `allowOpenLoop` | boolean | `false` | Show and sell open-loop (Visa/Mastercard-style) cards. Off: hidden from the catalog, and `GIFT_CARD_PRODUCT_NOT_FOUND` by id, on quote and on purchase (the product lookup hides them before any other check). On: still requires account level >= 2 | | `feeBps` | integer | `0` | Flash markup on face value, basis points. **Not read by any code path today** (reserved) | -| `claimDataEncryptionKey` | string | `""` | 32-byte AES-256-GCM key: 64 hex chars, or base64 of exactly 32 bytes. Empty key: fulfilment leaves orders `PAID` and pages (`claim-encrypt-failed`); reads throw `GIFT_CARD_CLAIM_UNAVAILABLE` | +| `claimDataEncryptionKey` | string | `""` | 32-byte AES-256-GCM key: 64 hex chars, or base64 of exactly 32 bytes. Empty or malformed: `giftCardPurchase` fails the order immediately before the pay step, after the vendor order exists but before any money moves (order `FAILED`, `failureReason` `claim-key-not-configured`, client sees `GIFT_CARD_CLAIM_UNAVAILABLE`); fulfilment of an order that already exists leaves it `PAID` and pages (`claim-encrypt-failed`); `giftCardOrder` returns a FULFILLED order with `claim: null` and logs Critical; only the `giftCardPurchase` payload carries `GIFT_CARD_CLAIM_UNAVAILABLE` | | `quoteToleranceBps` | integer | `100` | Max the vendor invoice may exceed the quote (1% default) before the purchase is refused with `GIFT_CARD_QUOTE_MISMATCH`. Overrides the domain constant `GIFT_CARD_QUOTE_TOLERANCE_BPS` | | `routing.default` | `bitcoinCompany` \| `bitrefill` | `bitcoinCompany` | Provider for any country not listed in `byCountry`, including unknown (`"XX"`) | -| `routing.byCountry` | map CC -> provider id | `{}` | ISO 3166-1 alpha-2 (upper case; lookups are normalised) -> provider id | -| `providers.bitcoinCompany.enabled` | boolean | `false` | Provider switch for new money. A country routed to a disabled provider gets `GIFT_CARD_PROVIDER_UNAVAILABLE`; orders that already exist still settle through the registered adapter | +| `routing.byCountry` | map CC -> provider id | `{}` | ISO 3166-1 alpha-2 -> provider id. Keys and lookups are both case-normalised (trim + upper case), so `jm:` and `JM:` are the same entry | +| `providers.bitcoinCompany.enabled` | boolean | `false` | Provider switch for new money. A country routed to a disabled (or unregistered) provider gets `GIFT_CARD_PROVIDER_UNAVAILABLE`; orders that already exist still settle through the registered adapter | | `providers.bitcoinCompany.baseUrl` | string | `https://api.dev.thebitcoincompany.com` | Sandbox (Mutinynet). Production is `https://api.thebitcoincompany.com` | | `providers.bitcoinCompany.email` / `password` | string | `""` | Login credentials for `POST /auth/login`. Empty: every authenticated call fails with `GIFT_CARD_VENDOR_UNAVAILABLE` (`BitcoinCompanyAuthError`) | | `providers.bitcoinCompany.referralCode` | string | `""` | **Not read by the client today** | @@ -29,7 +29,7 @@ Local overrides go in `$CONFIG_PATH/dev-overrides.yaml` | `providers.bitrefill.*` | | disabled, empty | Schema only. **No Bitrefill adapter exists**; routing to it is always unavailable | | `catalog.syncIntervalSeconds` | integer | `21600` (6 h) | Minimum gap between catalog pulls, enforced by the `giftcards:catalog-sync:last-run` marker | | `catalog.ttlSeconds` | integer | `21600` (6 h) | Age past which a catalog is served `stale: true` and a `catalog-stale` ops event posts | -| `catalog.staleAfterSeconds` | integer | `86400` (24 h) | Redis TTL on every catalog key. Past this the catalog is gone and reads throw `GIFT_CARD_CATALOG_UNAVAILABLE`. Must be > `ttlSeconds` and > `syncIntervalSeconds` | +| `catalog.staleAfterSeconds` | integer | `86400` (24 h) | Redis TTL on every catalog key. Past this the catalog is gone and reads throw `GIFT_CARD_CATALOG_UNAVAILABLE`. Must be > `ttlSeconds` and > `syncIntervalSeconds`. De-listed cards do not wait for it: each sync deletes their product keys; rows cached before `maxQuantity` / `wholeUnitsOnly` existed read as 1 / false until rewritten | | `limits.mode` | `off` \| `log-only` \| `enforce` | `log-only` | See "Limits modes" | | `limits.minAccountLevel` | integer | `1` | Level floor. Level 0 is refused regardless | | `limits.minAccountAgeHours` | integer | `24` | New-account cooldown | @@ -43,6 +43,10 @@ Local overrides go in `$CONFIG_PATH/dev-overrides.yaml` A level the config does not name (a future level 4) inherits `level3` (`levelLimits` in `src/app/gift-cards/authorize-purchase.ts`). +Vendor-side, not configurable: TBC's `disputed` order status is held as pending +(the order stays `PAID` and keeps being polled), never mapped to a refund; a +dispute resolves through TBC or the 24 h `fulfillment-timeout`. + Every sub-object is `additionalProperties: false`; a typo in an override fails config validation at boot rather than being ignored. @@ -80,23 +84,31 @@ openssl rand -base64 32 # 44 chars, decodes to 32 bytes Both formats are accepted; surrounding whitespace from an override is tolerated. The key id stored on orders is derived from the raw bytes, so the -same key in either encoding yields the same `claimKeyId`. +same key in either encoding yields the same `claimKeyId`. The key must be +loadable before the rail is enabled: with it empty, every purchase is failed at +the pay step (`claim-key-not-configured`, after the vendor order is created but +before any money moves) rather than allowed to pay for a card whose claim could +not be stored. ## How routing works `resolveGiftCardProviderIdForCountry(cc)` (`src/services/gift-cards/registry.ts`): -1. Normalise `cc` (trim, upper case). +1. Normalise `cc` (trim, upper case); `byCountry` keys are normalised the same + way when read. 2. `routing.byCountry[cc]`, else `routing.default`. -3. The result must be enabled (`enabled && providers..enabled`) **and** - registered by an adapter; otherwise `GiftCardProviderUnavailableError`. +3. The result must be **registered** by an adapter **and** enabled + (`enabled && providers..enabled`); otherwise + `GiftCardProviderUnavailableError`. The account's country is its phone country when that is unambiguous (`resolveAccountCountryCode`); otherwise the sentinel `"XX"`, which is user-assigned in ISO 3166 and can never appear in `byCountry`, so unknown countries always take `routing.default`. `giftCardCatalog(countryCode:)` lets the client browse another country, but the purchase always routes by the -account's country. +account's country, and a product whose own `countryCode` differs from a known +account country is refused at quote and purchase +(`GIFT_CARD_PRODUCT_NOT_AVAILABLE_IN_COUNTRY`); the check is skipped for `"XX"`. Because only `bitcoinCompany` is registered, the only useful `byCountry` entries today are none; the map exists so a second provider is config, not @@ -120,6 +132,7 @@ orders count toward the daily sum (money left); `FAILED`, `PAYMENT_FAILED`, `EXPIRED` do not. A Mongo or Redis fault is `limits-unavailable`: allowed in `log-only`, refused (`GIFT_CARD_UNKNOWN`, level Critical) in `enforce`. -Independent of mode: the 10/min purchase attempt limiter, the 30/min -`giftCardQuote` attempt limiter (`GIFT_CARD_QUOTE_RATE_LIMITED`, 5 min block) and -the ENG-573 send guard on the payment itself always run. +Independent of mode: the 10/min purchase attempt limiter (5 min block; not +charged on a same-key replay), the 30/min `giftCardQuote` attempt limiter +(`GIFT_CARD_QUOTE_RATE_LIMITED`, 5 min block) and the ENG-573 send guard on the +payment itself always run. diff --git a/docs/gift-cards/FLOWS.md b/docs/gift-cards/FLOWS.md index 2c050fbd4..c3cbf1938 100644 --- a/docs/gift-cards/FLOWS.md +++ b/docs/gift-cards/FLOWS.md @@ -6,23 +6,26 @@ use case in `src/app/gift-cards/`, `Repo` `GiftCardOrdersRepository`, `Vendor` the `IGiftCardProvider` adapter, `IBEX` the payment rail via `payLnInvoiceViaIbex`, `Worker` `reconcileGiftCardOrders`. -Every purchase opens the same way (steps 1-7 below): gate, product, validate, -`provider.quote`, `authorizeGiftCardPurchase`, `repo.create` (CREATED), -`releaseGiftCardReservation`. The diagrams that follow start after that unless -they say otherwise. +Every purchase opens as flow 1 up to `repo.create`: wallet ownership, the +same-key replay lookup (a hit returns or resumes the existing order and charges +nothing, flow 7), `consumeLimiter`, gate, claim-key check, product, validation +(value, `wholeUnitsOnly`, quantity up to `maxQuantity`, product country vs +account country), `provider.quote`, `authorizeGiftCardPurchase`, `repo.create` +(CREATED), `releaseGiftCardReservation`. Later diagrams start after that. ## 1. Purchase, happy path ```ascii App API UC (purchaseGiftCard) Repo Vendor IBEX | giftCardPurchase(input) | | | | - |------------->| | | | | - | | gateGiftCardsForAccount | | | - | |------------------>| consumeLimiter (10/min) | | | + |------------->| (no gate here) | | | | + | |------------------>| wallet belongs to account | | | | | | findByIdempotencyKey --> | (miss) | | + | | | consumeLimiter (10/min) | | | | | | giftCardsMasterGate | | | + | | | giftCardClaimKeyId loads? | | | | | | getGiftCardProduct (Redis)| | | - | | | checkedGiftCardValue/Quantity | | + | | | checkedGiftCardValue/Quantity, country | | | | | provider.quote ------------------------>| | | | | authorizeGiftCardPurchase (limits, hold)| | | | | repo.create ------------>| CREATED | | @@ -48,8 +51,8 @@ App API UC (purchaseGiftCard) Repo Vendor |<-------------| { order: FULFILLED, claim } | | ``` -If the first `fetchAndSettle` returns pending or errors, the mutation returns -the `PAID` order and the worker finishes the job (flow 4's second half). +If the first `fetchAndSettle` is pending or errors, the mutation returns the +`PAID` order and the worker finishes the job (flow 4's second half). ## 2. Vendor rejects the order @@ -66,8 +69,9 @@ UC (purchaseGiftCard) Repo Vendor | | GIFT_CARD_VENDOR_UNAVAILABLE] } ``` -Nothing was paid. The client may retry with a **new** idempotency key. The -same applies to an undecodable invoice (`vendor-invoice-undecodable`). +Nothing was paid; the client may retry with a **new** key. Same for an +undecodable invoice (`vendor-invoice-undecodable`). The `GIFT_CARD_VENDOR_REJECTED` +message is a fixed customer string; the vendor's own text is logged, not relayed. ## 3. Quote mismatch @@ -86,8 +90,7 @@ UC Repo Vendor | return GiftCardQuoteMismatchError -> GIFT_CARD_QUOTE_MISMATCH ``` -The vendor's invoice is left unpaid and expires on its side. The customer sees -"The gift card price changed; please try again" and re-quotes. +The vendor's invoice expires unpaid on its side; the customer re-quotes. ## 4. Payment pending, settled by the worker @@ -112,15 +115,19 @@ UC Repo IBEX Worker (30s) | pending -> leave (IBEX's word beats a vendor poll) | unknown / no ref -> fetchAndSettle ------------->| | fulfilled -> PAID -> FULFILLED + | not paid AND now > expiresAt + 24h + | -> PAYMENT_FAILED (payment-unresolved-expired) | else leave; warn after 60 min ``` An order with **no** `providerPaymentRef` (the send errored before IBEX handed back an id, or a crash between the IBEX call and the transition that writes it) answers "unknown" every run: there is no hash-based fallback on the IBEX -rail. The worker asks the vendor instead: a vendor `fulfilled` is proof of -payment (below). An order the vendor cannot vouch for either stays pending; -RUNBOOK (c) after 60 min. +rail, so the worker asks the vendor; a vendor `fulfilled` is proof of payment +(below). While the vendor reports the invoice unpaid the order stays pending +(RUNBOOK c after 60 min) until 24 h past `expiresAt`, when it is closed as +`PAYMENT_FAILED` (`payment-unresolved-expired`). A vendor error leaves it +pending for the next run. ### 4a. A send error without a verdict @@ -131,18 +138,24 @@ runs immediately before the IBEX call), `InvalidIdempotencyKeyError` / `InsufficientIbexBalance` (IBEX's 400), `FailedIbexPayment` (a corroborated FAILED on the 200). Every other error (the generic `IbexError` for a socket reset, gateway 5xx or timeout after the request was accepted; -`UnconfirmedIbexPayment`; `CompletedInvoice`; a busy idempotency lock while a -concurrent same-key attempt is in flight) says nothing about whether money +`UnconfirmedIbexPayment`; `CompletedInvoice`) says nothing about whether money moved, so the order goes `INVOICE_ISSUED -> PAYMENT_PENDING` with reason -`payment-unconfirmed: ` in `statusHistory` (`[ops: payment-pending]`, -`error` naming the class) and the **pending order** is returned. This flow -then settles it. A replayed order that lost to a concurrent attempt returns -whatever that attempt wrote. +`payment-unconfirmed: ` (`[ops: payment-pending]`, `error` naming the +class) and the **pending order** is returned. A 200 naming no recognised status +is converted to `Pending` by `paymentSendStatusOrPending` and lands the same +way with reason `payment-pending`. This flow then settles it. A replayed order +that lost to a concurrent attempt returns whatever that attempt wrote. + +Once IBEX has answered, the mutation always returns the order. If the `PAID` +write fails after a Success (lost race, store fault) the order is re-read and +returned as it stands and a Critical `paid-not-recorded` event fires (RUNBOOK +i): an error payload would read as "nothing happened" for money already gone. +A Success for a row the worker expired while the send was in flight is written +`EXPIRED -> PAID` (`payment-settled-after-expiry`). If the vendor reports `fulfilled` while Flash still shows `INVOICE_ISSUED` or -`PAYMENT_PENDING`, `settleOrderFromVendor` treats the vendor's word as proof of -payment and moves the order to `PAID` (reason `vendor-reported-fulfilled`) and -then `FULFILLED`. +`PAYMENT_PENDING`, `settleOrderFromVendor` takes the vendor's word as proof of +payment: `PAID` (reason `vendor-reported-fulfilled`), then `FULFILLED`. ## 5. Paid, then the vendor cancels (REFUND_REQUIRED) @@ -159,39 +172,41 @@ Worker Repo Vendor ``` After `GIFT_CARD_PAID_TIMEOUT_MS` (24 h) in `PAID`, `processPaid` makes one -final `fetchAndSettle` before escalating: a vendor `fulfilled` ends -`FULFILLED` (a worker gap over 24 h must not refund cards that shipped), a -vendor `failed` / `refunded` takes the path above, and only otherwise — -including a vendor error, which the event names in `meta.lastVendorPoll` — does -it write `REFUND_REQUIRED` with reason `fulfillment-timeout`. From here on the -order is an operator's problem: RUNBOOK (a). The customer must not be told to -buy again. +final `fetchAndSettle` before escalating: a vendor `fulfilled` ends `FULFILLED` +(a worker gap over 24 h must not refund cards that shipped), `failed` / +`refunded` takes the path above, and **only** a positive not-fulfilled answer +(`awaitingPayment` / `paidPendingFulfillment`) writes `REFUND_REQUIRED` with +reason `fulfillment-timeout`. A vendor error keeps the order `PAID` (warned) +and the next run polls again; a page never rests on an unreachable vendor. +TBC's `disputed` is held as pending, not refunded, so it reaches this point +only via the timeout. From here the order is an operator's problem: RUNBOOK (a). +The customer must not be told to buy again. ## 6. Expiry ```ascii -Worker Repo IBEX +Worker Repo Vendor | listByStatus(CREATED, INVOICE_ISSUED) | | filter expiresAt < now | | | processExpiry: | | - | INVOICE_ISSUED? lookupSentPaymentStatus ----------------->| - | settled -> settleAsPaid (payment-settled-on-reconcile) | - | pending -> warn, do not expire | - | unknown -> fetchAndSettle (vendor): fulfilled -> PAID -> FULFILLED - | failed / vendor has nothing -> fall through | + | INVOICE_ISSUED? fetchAndSettle (vendor) --------------------------------->| + | fulfilled -> PAID (vendor-reported-fulfilled) -> FULFILLED + | anything else (not paid, vendor error) -> fall through | | transition CREATED|INVOICE_ISSUED->EXPIRED (reason expired) | [ops: order-failed, reason expired] | ``` `expiresAt` is `min(order TTL 15 min, decoded BOLT11 expiry, vendor-stated expiry if the adapter reports one)` set at INVOICE_ISSUED; TBC reports none, so -the invoice governs. An order that reached INVOICE_ISSUED but crashed before the -IBEX call has no payment and is correctly expired. An order that crashed *after* -the IBEX call but before the transition that writes `providerPaymentRef` looks -the same to IBEX, so the worker asks the vendor before expiring it: a card that -shipped is proof of payment and the order settles. Only an invoice neither IBEX -nor the vendor knows as paid is expired. (A same-key replay before expiry -resumes the payment instead: flow 7.) +the invoice governs. There is no IBEX re-read here: an `INVOICE_ISSUED` row +never carries a `providerPaymentRef` (the ref is written with the transition +out of it), so IBEX could only answer "unknown". A row that crashed before the +IBEX call has no payment and is correctly expired; one that crashed *after* the +call but before the ref was written looks the same, so the worker asks the +vendor first: a card that shipped is proof of payment. Two safety nets remain +for a payment that surfaces later: a same-key replay before expiry resumes it +(flow 7), and a late IBEX Success on an `EXPIRED` row is written `EXPIRED -> +PAID` (flow 4a). The worker never polls `EXPIRED`. ## 7. Idempotent replay / double tap @@ -199,6 +214,7 @@ resumes the payment instead: flow 7.) App API UC Repo | giftCardPurchase(key K, P, V, Q) | | |--------------------->|--------------------->| findByIdempotencyKey(wallet, K) --> hit + | | | (before consumeLimiter: no budget spent) | | | providerId/product/value/quantity equal? | | | yes -> return existing order (any state) ... | | | span giftcard.replay=true @@ -215,22 +231,23 @@ Concurrent double tap (both miss the lookup): | tap 1 continues alone to the vendor and IBEX. ``` -The replay never reaches the vendor and never spends attempt budget beyond the -single `consumeLimiter` call at the top. A replay of an order that already -paid also cannot re-pay: even if the row check were bypassed, +The replay never reaches the vendor, never runs the master gate (a retry still +finds its order after the kill switch flips; the resolver does not gate +either), and never spends attempt budget (the lookup precedes `consumeLimiter`). +A replay of a paid order cannot re-pay: even past the row check, `withPaymentIdempotency` returns the cached result for `giftcard:` and -`onResponse` does not run, so `providerPaymentRef` stays whatever the first -call wrote. +`onResponse` does not run, so `providerPaymentRef` stays as first written. **Resumed INVOICE_ISSUED.** A first attempt that died between issuing the invoice and recording the payment's outcome leaves a row that, handed back as -"keep polling", could only ever end in `EXPIRED` — writing the money off if -IBEX had in fact paid. So the replay re-enters `payLnInvoiceViaIbex` (send -guard, IBEX, the same transitions as flow 1/4/4a) under the same -`giftcard:` key: a cached outcome is replayed, an in-flight attempt is -refused (busy lock -> `PAYMENT_PENDING`, flow 4a), or the one send that never -happened is made. Nothing upstream of the pay step (gate, quote, limits, -vendor) runs again. Not resumed: `CREATED` (vendor `createOrder` is not -idempotent), an `INVOICE_ISSUED` row past `expiresAt` (flow 6 handles it), and -any row while `giftCards.enabled` is false (that would be new money leaving; -the worker still settles an invoice the first attempt did pay). +"keep polling", could only end in `EXPIRED` — writing the money off if IBEX had +in fact paid. So the replay re-enters `payLnInvoiceViaIbex` (send guard, IBEX, +the transitions of flow 1/4/4a) under the same `giftcard:` key: a +cached outcome is replayed, or the one send that never happened is made. If +the key's lock is busy (the first call is still inside IBEX) the current row is +returned **unchanged** — still `INVOICE_ISSUED`, the client polls — rather than +moved to `PAYMENT_PENDING` on a guess. Nothing upstream of the pay step runs +again. Not resumed: `CREATED` (vendor `createOrder` is not idempotent), an +`INVOICE_ISSUED` row past `expiresAt` (flow 6), and any row while +`giftCards.enabled` is false (new money leaving; the worker still settles an +invoice the first attempt did pay). diff --git a/docs/gift-cards/RUNBOOK.md b/docs/gift-cards/RUNBOOK.md index 0262dfa81..13457e338 100644 --- a/docs/gift-cards/RUNBOOK.md +++ b/docs/gift-cards/RUNBOOK.md @@ -1,12 +1,11 @@ # Gift Cards Runbook Operator procedures. Mongo queries use the node + mongoose one-liner pattern -from `docs/bridge-integration/ENG-276-IMPLEMENTATION-RESUME.md` (the repo does -not ship `mongosh`); run them from a pod or a jumpbox with `MONGODB_CON` set. -Redis commands assume `redis-cli` against the cluster's Redis (locally, -`make redis-cli`). Config changes follow `docs/send-guard.md`: edit the values -file, `helm upgrade`, then roll the api, trigger **and** cron workloads; the -YAML is read once at process start. +from `docs/bridge-integration/ENG-276-IMPLEMENTATION-RESUME.md` (no `mongosh` +in the repo); run them from a pod or jumpbox with `MONGODB_CON` set. Redis +commands assume `redis-cli` against the cluster's Redis (`make redis-cli` +locally). Config changes follow `docs/send-guard.md`: edit the values file, +`helm upgrade`, roll api, trigger **and** cron; the YAML is read once at start. Helper used throughout (replace the query): @@ -16,13 +15,12 @@ node -e "const m=require('mongoose');(async()=>{await m.connect(process.env.MONG ``` The projection **excludes** `claimCiphertext` and `claimKeyId`; never print -them, and never decrypt a claim outside the owner-scoped read -(`getGiftCardOrderForAccount`). +them, and never decrypt a claim outside `getGiftCardOrderForAccount`. ## (a) A `REFUND_REQUIRED` order -The only state that means money left Flash and no card came. It pages -(`giftcard / refund-required`, see ALERTING.md). +The only state meaning money left Flash and no card came. It pages +(`giftcard / refund-required`, ALERTING.md). 1. Find it. From the page, `meta.orderId`; or list them all: `Q='{"status":"REFUND_REQUIRED"}'`. Read `id`, `accountId`, `walletId`, @@ -31,10 +29,9 @@ The only state that means money left Flash and no card came. It pages (the IBEX transaction id), `failureReason`, and `statusHistory` (when PAID was written and why REFUND_REQUIRED followed: `vendor-failed: ...`, `vendor-refunded: ...`, or `fulfillment-timeout`). -2. Confirm our payment. In the IBEX dashboard (or `Ibex.getTransactionDetails` - with `providerPaymentRef`) verify the send for `paymentHash` settled. If - IBEX shows it failed, the order should never have been PAID; note that and - continue, since the state is terminal. +2. Confirm our payment in the IBEX dashboard (or `Ibex.getTransactionDetails` + with `providerPaymentRef`): the send for `paymentHash` settled. If IBEX + shows it failed, the order should never have been PAID; note it and go on. 3. Confirm the vendor side (TBC). Log in with the same credentials as `giftCards.providers.bitcoinCompany` against the configured `baseUrl`, then: ```bash @@ -48,211 +45,214 @@ The only state that means money left Flash and no card came. It pages ``` Do not pipe the second response through anything that logs it: it may contain claim data. If TBC now reports a fulfilled card with claim data, the - order was escalated on a timeout and the card exists: skip to step 5b. + order was escalated on a timeout and the card exists: skip to 5b. 4. Chase the refund. Open a ticket with TBC support quoting our order id (their - label), their order uuid (`providerOrderId`), the invoice, and the paid + label), their order uuid (`providerOrderId`), the invoice and the paid amount. TBC refunds to the account balance or by Lightning; record which. 5. Record the outcome. There is no transition out of `REFUND_REQUIRED` - (`GIFT_CARD_TRANSITIONS`), by design. Either: - - **5a. Refund received:** credit the user's wallet by the standard - support path for a manual credit, then annotate the order with a - `$set` of `failureReason` to - `"; refunded "` (only that field). Reply to - the page thread with the order id and the credit reference. - - **5b. Card actually issued:** ask engineering to run the settlement by - hand (`fetchAndSettle(order)` will refuse because the order is terminal; - `settleOrderFromVendor` on a terminal order pages - `vendor-fulfilled-unexpected`). This case needs a code path or a - one-off script that re-encrypts the claim onto the order; do not paste - claim codes anywhere. + (`GIFT_CARD_TRANSITIONS`), by design. Either **5a, refund received:** credit + the wallet by the standard manual-credit support path, then `$set` + `failureReason` to `"; refunded "` (only that + field) and reply to the page thread with the order id and credit reference. + Or **5b, card actually issued:** engineering settles by hand with a one-off + script that re-encrypts the claim onto the order (`fetchAndSettle` refuses a + terminal order; `settleOrderFromVendor` on one pages + `vendor-fulfilled-unexpected`); never paste claim codes anywhere. 6. Tell the customer. They must not be told to buy again; the app already shows the `REFUND_REQUIRED` copy. ## (b) Order stuck in `PAID` > 15 min Normal fulfilment is seconds. The worker polls at 5s/15s/60s/5m then every -15m, and escalates to `REFUND_REQUIRED` only at 24 h. A PAID order older than -15 min is a vendor delay or a worker problem. +15m, and escalates to `REFUND_REQUIRED` only at 24 h and only when the vendor +positively reports not fulfilled (a vendor error keeps PAID and retries). A +PAID order older than 15 min is a vendor delay or a worker problem. 1. `Q='{"status":"PAID","updatedAt":{"$lt":{"$date":""}}}'`. Note the JSON date form; adjust the timestamp. -2. Is the worker running? In the trigger pod logs look for - `gift card reconcile finished` (cron) or reconcile tick warnings. If every - run says `skipped: lock-held`, a run died holding the lock; it lapses after - 5 min (`giftcards:reconcile:lock`). The worker runs regardless of - `giftCards.enabled`; a tick only skips when no non-terminal order exists. +2. Is the worker running? The trigger tick logs **nothing** on a successful or + lock-held run; look for `gift card reconcile tick failed` / `threw` + (trigger) and `gift card reconcile finished` with its summary (cron). A + cron summary of `skipped: lock-held` on every run means a run died holding + the lock; it lapses after 5 min (`giftcards:reconcile:lock`). The worker + runs regardless of `giftCards.enabled`; a tick only skips when no + non-terminal order exists. 3. Check the vendor for that invoice (step (a)3). `unpaid`/`underpaid`/ `confirming` while we show PAID means IBEX settled and TBC has not seen it: compare `paidSats` with the invoice amount and escalate to TBC. `pending` / `senttofulfillment` is a vendor delay: wait for the 15-minute poll. - `completed` without `claimData` is held as pending by design; ask TBC. + `completed` without `claimData`, and `disputed`, are held as pending by + design (a dispute may still fulfil or refund); ask TBC. 4. Do not move the order by hand. If the vendor confirms the card will not - come, the 24 h timeout will write `REFUND_REQUIRED`; to bring that forward, - engineering can run `settleOrderFromVendor(order, { kind: "failed", reason })` - from a script. + come, the 24 h timeout writes `REFUND_REQUIRED`; to bring that forward, + engineering runs `settleOrderFromVendor(order, { kind: "failed", reason })`. ## (c) Order stuck in `PAYMENT_PENDING` with no `providerPaymentRef` -The worker cannot re-read IBEX without the transaction id, and there is no -hash-based fallback on this rail (`lookupSentPaymentStatus`), so every run it -asks the vendor instead (`fetchAndSettle`): a vendor `fulfilled` settles the -order as `PAID` then `FULFILLED` on its own, no operator needed. An order still -`PAYMENT_PENDING` after 60 min is therefore one the vendor shows as unpaid (or -cannot be reached for); the worker logs `Gift card payment has been pending -for over an hour with no resolvable status` every run. +The worker cannot re-read IBEX without the transaction id (no hash-based +fallback on this rail, `lookupSentPaymentStatus`), so every run it asks the +vendor instead (`fetchAndSettle`): a vendor `fulfilled` settles the order as +`PAID` then `FULFILLED` with no operator. An order still `PAYMENT_PENDING` after +60 min is therefore one the vendor shows unpaid (or cannot be reached for); the +worker logs `Gift card payment has been pending for over an hour with no +resolvable status` every run. Once `expiresAt` is 24 h past and the vendor +still reports unpaid, the worker closes it as `PAYMENT_FAILED` +(`payment-unresolved-expired`) on its own. The steps below are for that +window, or for an order the vendor cannot be reached for. 1. `Q='{"status":"PAYMENT_PENDING","providerPaymentRef":null}'`. Read `paymentHash`, `paymentRequest`, `walletId`, `invoiceSats`. -2. In the IBEX dashboard, find the outgoing payment on account `walletId` - for `paymentHash`. -3. Settled: `$set` `providerPaymentRef` to the IBEX transaction id (only that - field). The next worker run re-reads IBEX, moves the order to `PAID`, and - polls the vendor. -4. Failed at IBEX: same `$set` of the transaction id; the worker will write - `PAYMENT_FAILED`. If IBEX has no record at all, the send never left; ask - engineering to transition `PAYMENT_PENDING -> PAYMENT_FAILED` with reason - `payment-failed` via the repository, not by editing `status` directly (the - `statusHistory` push matters for audits). - -Orders that show `PAYMENT_PENDING` **with** a ref for more than an hour are an -IBEX question: the transaction exists but reports no recognised payment -status. Escalate to IBEX with the transaction id. +2. In the IBEX dashboard, find the outgoing payment on account `walletId` for + `paymentHash`. +3. Settled or failed at IBEX: `$set` `providerPaymentRef` to the IBEX + transaction id (only that field); the next run re-reads IBEX and writes + `PAID` (then polls the vendor) or `PAYMENT_FAILED`. No IBEX record at all: + the send never left; engineering transitions `PAYMENT_PENDING -> + PAYMENT_FAILED` (reason `payment-failed`) via the repository, never by + editing `status` (the `statusHistory` push matters for audits). + +`PAYMENT_PENDING` **with** a ref for over an hour is an IBEX question (the +transaction exists but reports no recognised status); escalate with the id. ## (d) Disable the feature or one provider -One config change, then roll api + trigger + cron: - -- Whole rail: `giftCards.enabled: false`. Catalog, quote and purchase return - `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, catalog sync - no-ops. **The reconcile worker keeps running** (trigger interval and cron) - for as long as any non-terminal order exists: in-flight orders still settle, - customers who already paid still receive their codes, and the 24 h - `REFUND_REQUIRED` alert still fires. The switch stops new money leaving, - nothing else: a same-key replay of an unpaid `INVOICE_ISSUED` order is not - resumed while it is off (the worker expires that order after checking IBEX - and the vendor). `giftCardOrder` / `giftCardOrders` keep working so - customers can still read cards they own. +One config change, then roll api + trigger + cron (not live-reloaded): + +- Whole rail: `giftCards.enabled: false`. Catalog, quote and a fresh purchase + return `GIFT_CARDS_DISABLED`, `globals.giftCardsEnabled` is false, catalog + sync no-ops. **The reconcile worker keeps running** (trigger and cron) while + any non-terminal order exists: in-flight orders settle, paid customers get + their codes, the 24 h `REFUND_REQUIRED` alert fires. The switch stops new + money leaving, nothing else: a same-key retry still returns its order (an + unpaid `INVOICE_ISSUED` row as-is, not resumed; the worker expires it after a + vendor poll). `giftCardOrder` / `giftCardOrders` keep working. - One provider: `giftCards.providers.bitcoinCompany.enabled: false`. Countries routed to it get `GIFT_CARD_PROVIDER_UNAVAILABLE` on catalog, quote and - purchase. Settlement resolves the adapter by registration - (`getRegisteredGiftCardProviderOrError`), so that provider's in-flight orders - keep reconciling exactly as above. + purchase; settlement resolves the adapter by registration + (`getRegisteredGiftCardProviderOrError`), so its in-flight orders keep + reconciling as above. - Open-loop only: `giftCards.allowOpenLoop: false`. -- Kill new purchases but keep settling: that is what `giftCards.enabled: - false` does. - -Not live-reloaded; budget for a pod roll. ## (e) Rotate TBC credentials; rotate the claim key **TBC credentials.** Change `providers.bitcoinCompany.email`/`password` in the overrides, roll the pods, then clear the cached session so no pod keeps using the old refresh token: - -``` -redis-cli DEL giftcards:auth:bitcoinCompany giftcards:auth:bitcoinCompany:lock -``` - +`redis-cli DEL giftcards:auth:bitcoinCompany giftcards:auth:bitcoinCompany:lock`. The next authenticated call logs in fresh (`Bitcoin Company login succeeded`). If TBC revokes the old credentials first, calls fail with `GIFT_CARD_VENDOR_UNAVAILABLE` and a Critical `BitcoinCompanyAuthError` until -the new ones are deployed; the refresh path retries login on every call, so -nothing else needs resetting. - -**Claim key.** `claimDataEncryptionKey` is a single key; there is no multi-key -map yet. `claimKeyId` (first 16 hex chars of sha256 of the key bytes) is stored -on every `FULFILLED` order so a reader can tell which key sealed it. What -happens on a rotation: - -- New orders encrypt under the new key with the new `claimKeyId`. -- Every existing `FULFILLED` order's `claimKeyId` no longer matches; - `decryptGiftCardClaim` returns `GiftCardClaimCryptoError("key rotated")` and - the customer sees `GIFT_CARD_CLAIM_UNAVAILABLE`. - -So a rotation today is a re-encryption migration: decrypt under the old key, -encrypt under the new, update `claimCiphertext` + `claimKeyId`, order by -order, in a script that never logs plaintext. Count what is affected first: +the new ones deploy; the refresh path retries login on every call. + +**Claim key.** `claimDataEncryptionKey` is a single key; no multi-key map yet. +It must load before any money moves: empty or malformed, every `giftCardPurchase` +fails its order at the pay step (`claim-key-not-configured`, after the vendor +order exists, before IBEX is called) and returns `GIFT_CARD_CLAIM_UNAVAILABLE`. `claimKeyId` +(first 16 hex chars of sha256 of the key bytes) is stored on every `FULFILLED` +order and the ciphertext is bound (GCM AAD) to that key id **and** the order +id, so a ciphertext copied onto another order fails to decrypt. After a +rotation every existing `FULFILLED` order's `claimKeyId` mismatches: +`decryptGiftCardClaim` returns `GiftCardClaimCryptoError("key rotated")`, +`giftCardOrder` returns `claim: null` and logs Critical, only the purchase +payload shows `GIFT_CARD_CLAIM_UNAVAILABLE`. A rotation is therefore a +re-encryption migration — decrypt under the old key, encrypt under the new for +the same order id, update `claimCiphertext` + `claimKeyId`, order by order, in +a script that never logs plaintext. Count what is affected first: ``` Q='{"status":"FULFILLED","claimKeyId":{"$ne":""}}' ``` -Get the new key id from `giftCardClaimKeyId()` in -`src/services/gift-cards/claim-crypto.ts` with the new key configured. Do not -rotate without the script; losing the old key makes every stored claim -unreadable for good. +Get the new key id from `giftCardClaimKeyId()` (`src/services/gift-cards/claim-crypto.ts`) +with the new key configured. Never rotate without the script: losing the old +key makes every stored claim unreadable for good. ## (f) Flip limits `log-only -> enforce` -Mirror `docs/send-guard.md` "Flipping to enforce": - -1. Run at least a week in `log-only` with real traffic. -2. Count `giftcard / would-reject` by `meta.reason` from the span attribute - `giftcard.limits.reason` (not from Discord): - - `limits-unavailable` must be **zero**. On enforce it refuses the purchase - with `GIFT_CARD_UNKNOWN` and pages. Non-zero means Mongo or Redis faults on - the read path; fix those first. - - `daily-cap`, `per-card-cap`, `velocity`: real customers the caps would - refuse. If any of it is legitimate, raise `perLevel.*` or the account's - level **before** enforcing. - - `vendor-daily-cap`, `vendor-card-cap`: never raise these; they are TBC's - FinCEN exemption limits. - - `level-not-eligible`, `account-too-new`, `open-loop-not-allowed`: - expected; confirm the counts match the product decision. -3. Confirm the reservation hold path is healthy: no - `Could not write gift card reservation; allowing in log-only mode` warnings. - On enforce that warning becomes a refusal. -4. Set `giftCards.limits.mode: enforce`, `helm upgrade`, roll the api pods. -5. Watch `giftcard / rejected` for the first day. Roll back with `log-only` - (or `off` if the limits code itself is the outage), same pod roll. +Mirror `docs/send-guard.md` "Flipping to enforce". Run at least a week in +`log-only` with real traffic, then: + +1. Count `giftcard / would-reject` by `meta.reason` from the span attribute + `giftcard.limits.reason` (not Discord). `limits-unavailable` must be **zero** + (on enforce it refuses with `GIFT_CARD_UNKNOWN` and pages; non-zero means + Mongo/Redis read faults). `daily-cap`, `per-card-cap`, `velocity` are real + customers the caps would refuse: raise `perLevel.*` or the account's level + **before** enforcing if legitimate. `vendor-daily-cap`, `vendor-card-cap` + are TBC's FinCEN limits; never raise them. `level-not-eligible`, + `account-too-new`, `open-loop-not-allowed` are expected; confirm the counts. +2. Confirm the hold path is healthy: no `Could not write gift card + reservation; allowing in log-only mode` warnings (a refusal on enforce). +3. Set `giftCards.limits.mode: enforce`, `helm upgrade`, roll the api pods, and + watch `giftcard / rejected` for a day. Roll back with `log-only` (or `off` + if the limits code itself is the outage), same pod roll. ## (g) Catalog stale or sync failing Symptoms: `catalog-stale` events, then `GIFT_CARD_CATALOG_UNAVAILABLE` once -`staleAfterSeconds` passes; `catalog-sync-failed` events; no -`catalog-synced` for > 2 x `syncIntervalSeconds`. - -1. Read the last sync time: `redis-cli GET giftcards:catalog-sync:last-run` - and `redis-cli TTL giftcards:catalog:bitcoinCompany:countries`. -2. Cron logs: `gift card catalog sync finished` with `summaries: []` means it - was skipped (lock held, marker within interval, or Redis down) or every - provider failed. `gift card catalog sync failed` carries the error. -3. Vendor down (`GiftCardVendorUnavailableError`, `listProducts` retries 3x - on 5xx/network): nothing to do but wait; a failed run releases the interval - marker so the next cron tick retries. Confirm - `GET $BASE_URL/giftcards?size=1&offset=0` answers (no auth needed). -4. Stuck lock: `redis-cli TTL giftcards:catalog-sync:lock`; it lapses in 10 min. - Delete it only if the run that took it is confirmed dead. -5. Force a refresh now: `redis-cli DEL giftcards:catalog-sync:last-run` and - run the cron job (`syncGiftCardCatalogsJob`) once. There is no CLI entry - point; trigger the k8s CronJob manually. -6. Rows skipped during mapping (`no-country`, `unknown-denomination-type`, - `no-denominations`) are logged by `Bitcoin Company catalog mapped`; a sudden - jump means the vendor changed a field. See `mapVendorProduct`. +`staleAfterSeconds` passes; `catalog-sync-failed` events (a pull that received +zero rows, or kept none after mapping, is a failure by design: the previous +catalog keeps serving); no `catalog-synced` for > 2 x `syncIntervalSeconds`. + +1. Last sync time: `redis-cli GET giftcards:catalog-sync:last-run` and + `redis-cli TTL giftcards:catalog:bitcoinCompany:countries`. +2. Cron logs: `gift card catalog sync finished` with `summaries: []` means + skipped (lock held, marker within interval, Redis down) or every provider + failed; `gift card catalog sync failed` carries the error. +3. Vendor down (`GiftCardVendorUnavailableError`; `listProducts` retries 3x on + 5xx/network): wait; a failed run releases the interval marker so the next + tick retries. Check `GET $BASE_URL/giftcards?size=1&offset=0` answers. +4. Stuck lock: `redis-cli TTL giftcards:catalog-sync:lock` (lapses in 10 min); + delete it only if the run that took it is confirmed dead. +5. Force a refresh: `redis-cli DEL giftcards:catalog-sync:last-run`, then + trigger the k8s CronJob (`syncGiftCardCatalogsJob`) once; no CLI entry point. +6. The adapter's sync log carries `{ received, kept, skipped: {...}, flagged: + { notResellable } }`. Skip reasons (`PRODUCT_MAPPING_SKIP_REASONS` in + `mapping.ts`): `invalid`, `noCountry`, `physical`, `noLightning`, + `unknownDenominationType`, `noDenominations`. `resellingEnabled: false` rows + are **kept** and counted under `flagged.notResellable` (TBC shows false on + every row until KYB; ENG-586 flips it to a skip). A jump in any bucket means + the vendor changed a field (`mapVendorProduct`). ## (h) Adding a new provider Checklist, using `src/services/gift-cards/bitcoin-company/` as the template: -1. Add the id to `GiftCardProviderId` and `GIFT_CARD_PROVIDER_IDS` - (`src/domain/gift-cards/index.types.d.ts`, `primitives.ts`); add +1. Add the id to `GiftCardProviderId` / `GIFT_CARD_PROVIDER_IDS` + (`src/domain/gift-cards/index.types.d.ts`, `primitives.ts`) and `providers.` to `src/config/schema.ts`, `schema.types.d.ts`, - `dev/config/base-config.yaml`, and the `routing` enums. + `dev/config/base-config.yaml` and the `routing` enums. 2. Write the adapter: `client.ts` (transport, auth, retries on idempotent - reads only, never retry order creation), `schemas.ts` (zod on every - response), `mapping.ts` (pure vendor -> domain, minor units, a status table - that never reports `fulfilled` without a claim), `index.ts` exporting an - idempotent `registerProvider()`. -3. Register it in `src/services/gift-cards/index.ts` (one import + call). -4. Redact: add the vendor's token/claim field names to the log redaction set - if the client logs bodies. -5. Tests: a `client.spec.ts`, a `mapping.spec.ts`, and a `contract.spec.ts` - that calls `runGiftCardProviderContract` - (`test/flash/unit/services/gift-cards/provider-contract.ts`). -6. If the vendor pushes webhooks, route them into `settleOrderFromVendor`; - do not add a second settlement path. -7. Config: `providers..enabled: true` and `routing.byCountry` entries; - the registry, master gate, catalog sync and `globals.giftCardsEnabled` - pick it up with no further code. -8. Add its ops-event/alert specifics to ALERTING.md and its credential - rotation to (e) above. + reads only, never on order creation), `schemas.ts` (zod on every response), + `mapping.ts` (pure vendor -> domain, minor units, `maxQuantity` and + `wholeUnitsOnly`, a status table that never reports `fulfilled` without a + claim), `index.ts` exporting an idempotent `registerProvider()`. +3. Register it in `src/services/gift-cards/index.ts` (the router requires + registration **and** `providers..enabled`); redact the vendor's + token/claim field names if the client logs bodies. +4. Tests: `client.spec.ts`, `mapping.spec.ts`, and a `contract.spec.ts` calling + `runGiftCardProviderContract` (`test/flash/unit/services/gift-cards/provider-contract.ts`). +5. Webhooks, if any, route into `settleOrderFromVendor`; never a second path. +6. Config: `providers..enabled: true` and `routing.byCountry` entries; the + registry, master gate, catalog sync and `globals.giftCardsEnabled` pick it + up with no further code. Add alerts to ALERTING.md, rotation to (e). + +## (i) A `paid-not-recorded` page + +IBEX confirmed the send but the `PAID` write failed; the customer was handed +the order as it stood and told to poll. Until the row says `PAID` the worker +treats it as unpaid and, past `expiresAt`, expires it unless the vendor has +seen the payment. + +1. Find it: `meta.orderId` from the page, or + `Q='{"providerPaymentRef":""}'`. Read `status`, + `paymentHash`, `invoiceSats`, `walletId`. +2. Confirm at IBEX (dashboard or `Ibex.getTransactionDetails`) that the send + for `paymentHash` settled. If IBEX shows it failed, leave the order alone; + the worker's re-read writes `PAYMENT_FAILED`. +3. Already `PAID` / `FULFILLED` / `REFUND_REQUIRED`: the worker or a replay + recorded it after the page; nothing to do. +4. Otherwise engineering transitions it via the repository — `INVOICE_ISSUED | + PAYMENT_PENDING | EXPIRED -> PAID`, reason `payment-settled` + (`payment-settled-after-expiry` from `EXPIRED`), patch `paidSats: + invoiceSats` and `providerPaymentRef` — never by editing `status` directly. + The next run polls the vendor and fulfils or, after 24 h, escalates per (a). diff --git a/docs/gift-cards/TESTING.md b/docs/gift-cards/TESTING.md index eb594c588..229224d5f 100644 --- a/docs/gift-cards/TESTING.md +++ b/docs/gift-cards/TESTING.md @@ -12,25 +12,39 @@ the logger. TEST=test/flash/unit/app/gift-cards yarn test:unit TEST=test/flash/unit/services/gift-cards yarn test:unit TEST=test/flash/unit/services/mongoose/gift-card-orders yarn test:unit +TEST=test/flash/unit/domain/gift-cards yarn test:unit +TEST="test/flash/unit/graphql/public test/flash/unit/config/rate-limits.spec.ts" yarn test:unit TEST=test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts yarn test:unit ``` | Spec | Covers | | --- | --- | -| `test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts` | Happy path to FULFILLED on the first poll; PAID returned when the poll is pending or errors; `providerPaymentRef` captured on PAID / PAYMENT_PENDING / PAYMENT_FAILED and null on replay; idempotent replay, key reuse refusal, unique-index race; replay of an unpaid INVOICE_ISSUED order resumes the pay step (and does not while expired, while the kill switch is off, or for CREATED / PAYMENT_PENDING); every refusal before a row exists (rate limit, wallet ownership, gate, routing, value, quantity, limits); vendor reject / 5xx / undecodable invoice / tolerance boundary; order expiry = min(TTL, decoded BOLT11, vendor-stated); payment Failure; send errors that prove refusal -> PAYMENT_FAILED (InsufficientIbexBalance, FailedIbexPayment, idempotency-key errors, send-guard rejection) vs. unknown outcome -> PAYMENT_PENDING (generic IbexError, UnconfirmedIbexPayment, CompletedInvoice, busy lock; ref kept when IBEX answered; concurrent attempt wins); claim never in ops events or logs | +| `test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts` | Happy path to FULFILLED on the first poll; PAID returned when the poll is pending or errors; `providerPaymentRef` captured on PAID / PAYMENT_PENDING / PAYMENT_FAILED and null on replay; idempotent replay (before the attempt budget, no point spent, no gate), key reuse refusal, unique-index race; replay of an unpaid INVOICE_ISSUED order resumes the pay step (busy lock returns the row unchanged; not resumed while expired, while the kill switch is off, or for CREATED / PAYMENT_PENDING); every refusal before a row exists (rate limit, wallet ownership, gate, claim key not configured, routing, product country vs account country, value, `wholeUnitsOnly`, quantity and `maxQuantity`, limits); vendor reject (fixed customer message) / 5xx / undecodable invoice / tolerance boundary; order expiry = min(TTL, decoded BOLT11, vendor-stated); send errors that prove refusal -> PAYMENT_FAILED vs. unknown outcome -> PAYMENT_PENDING (ref kept when IBEX answered; concurrent attempt wins); after IBEX answers the order is always returned — lost PAID write -> `paid-not-recorded` Critical event, late Success on EXPIRED -> PAID; claim never in ops events or logs | | `authorize-purchase.spec.ts` | Levels x amounts x modes table; account age; open-loop switch and level floor; velocity; daily cap including REFUND_REQUIRED and live Redis holds; vendor caps; reservations write/release; `limits-unavailable` in each mode; would-reject / rejected event shape | +| `quote-gift-card.spec.ts` | Quote attempt budget charged first (refusal, store-fault fall-through, refused attempts count); gate on the account's country; product lookup, provider routing and out-of-stock refusals; value (min/max/off-denomination/non-integer) and quantity validation; disabled-provider refusal; vendor quote failure pass-through | +| `get-order.spec.ts` | NotFound for unknown id and for another account's order (indistinguishable, never decrypts); repository fault pass-through; no decrypt before FULFILLED or without ciphertext; decrypt under the stored key id for the order; decrypt failure returned as DATA (`claimError`) with the order; span carries id and status, never ciphertext or claim | +| `list-orders.spec.ts` | Owner-scoped listing, newest first; keyset paging on `createdAt`; `first` defaults and caps; repository errors | | `settle-order.spec.ts` | PAID -> FULFILLED with encrypted claim, event and push; idempotent on FULFILLED; vendor-first payment (PAYMENT_PENDING / INVOICE_ISSUED -> PAID -> FULFILLED); terminal-unpaid + vendor fulfilled pages; encryption failure leaves PAID; losing the FULFILLED race; failed/refunded on PAID -> REFUND_REQUIRED, on INVOICE_ISSUED -> FAILED, on PAYMENT_PENDING left alone; `fetchAndSettle` resolves the provider by registration (not `enabled`) and guards | -| `reconcile-orders.spec.ts` | Poll schedule (`nextGiftCardPollAt`); lock acquire/release/foreign token; expiry with and without a settled or in-flight payment, vendor fallback for a no-ref INVOICE_ISSUED (fulfilled -> FULFILLED, nothing -> EXPIRED, IBEX FAILED -> no poll); PAYMENT_PENDING settled/failed/in-flight (no vendor poll), unknown and no-ref (vendor poll; fulfilled -> FULFILLED, vendor error -> stays pending), IBEX error, unconfirmed; PAID backoff and fresh-process behaviour; 24 h escalation only after a final vendor poll (fulfilled -> FULFILLED, vendor refund -> once, vendor error -> escalate and name it); per-order isolation; `hasOpenGiftCardOrders`; job and interval run while `giftCards.enabled` is false and skip the lock with nothing open; error propagation | -| `sync-catalog.spec.ts` | Per-provider write and counts; vendor failure, adapter throw, cache write failure; multi-provider isolation; lock TTL and ownership; Redis-down and registry-throw safety; interval marker claim/skip/release | +| `reconcile-orders.spec.ts` | Poll schedule (`nextGiftCardPollAt`); lock acquire/release/foreign token; expiry: CREATED expires outright, INVOICE_ISSUED gets one vendor poll (fulfilled -> FULFILLED, not paid or vendor error -> EXPIRED), no IBEX re-read; PAYMENT_PENDING settled/failed/in-flight (no vendor poll), unknown and no-ref (vendor poll; fulfilled -> FULFILLED, vendor error -> stays pending, vendor unpaid 24 h past `expiresAt` -> PAYMENT_FAILED `payment-unresolved-expired`), IBEX error, unconfirmed; PAID backoff and fresh-process behaviour; 24 h escalation only on a positive not-fulfilled (fulfilled -> FULFILLED, vendor refund -> once, vendor error -> stays PAID and retries); per-order isolation; `hasOpenGiftCardOrders`; job and interval run while `giftCards.enabled` is false and skip the lock with nothing open; error propagation | +| `sync-catalog.spec.ts` | Per-provider write and counts; vendor failure, adapter throw, cache write failure, an empty catalog (received zero or kept none) is a failure; multi-provider isolation; lock TTL and ownership; Redis-down and registry-throw safety; interval marker claim/skip/release | | `list-products.spec.ts` | Stock and open-loop filters; category/search; ordering; cursor paging without gaps or repeats; unknown cursor restart; `first` defaults and caps; routing and cache errors; stale-catalog event coalescing; `getGiftCardProduct` | -| `gift-cards-master-gate.spec.ts` | Rail off, provider disabled, case-insensitive routing, country routed to a disabled provider, unknown-country sentinel; phone-country resolution (single, ambiguous, none, user load failure, never throws) | -| `test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts` | Login/cache/refresh/401-retry/fallback-to-login/credentials-missing/lock serialisation/Redis-down; catalog paging (500 + short page), row skipping, GET retries and 4xx no-retry; purchase never retried, 4xx-with-message -> rejected, envelope-with-null-result; invoice-status retries; numeric-string coercion; timeout applied; log hygiene (`redactForLog`) | -| `bitcoin-company/mapping.spec.ts` | Unit conversion; product mapping (fixed/variable/VariableNoCents, dedupe, country, currency, logo, terms, categories); `stripBrand`; quote/purchase/claim mapping (purchase `expiresAt` is null, never fabricated); full vendor status table, case-insensitivity, unknown status held as pending | -| `registry.spec.ts` | `getRegisteredGiftCardProviderOrError` returns the adapter while the master or provider switch is off and errors only when unregistered; `getEnabledGiftCardProvider` refuses on either switch; byCountry/default routing to a disabled provider is unavailable | +| `gift-cards-master-gate.spec.ts` | Rail off, provider disabled or unregistered, case-insensitive routing (keys and lookup), country routed to a disabled provider, unknown-country sentinel; phone-country resolution (single, ambiguous, none, user load failure, never throws) | +| `test/flash/unit/domain/gift-cards/primitives.spec.ts` | `checkedGiftCardValue` (fixed / variable / `wholeUnitsOnly` / non-integer) and `checkedGiftCardQuantity` (1..`min(maxQuantity, 10)`; a vendor cap below 1 reads as 1) | +| `test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts` | Login/cache/refresh/401-retry/fallback-to-login/credentials-missing/lock serialisation/Redis-down; catalog paging (500 + short page), received/kept/skipped/flagged counts, GET retries and 4xx no-retry; purchase never retried, 4xx-with-message -> rejected, envelope-with-null-result; quote 4xx -> `GiftCardInvalidValueError`; invoice-status retries; numeric-string coercion; timeout applied; log hygiene (`redactForLog`); `giftcard.op` set only on the `call` pipeline | +| `bitcoin-company/mapping.spec.ts` | Unit conversion; product mapping (fixed/variable/VariableNoCents -> `wholeUnitsOnly`, `maxQuantity` 1, dedupe, country, currency, logo, terms, categories); `stripBrand`; quote/purchase/claim mapping (purchase `expiresAt` is null, never fabricated); full vendor status table, case-insensitivity, `disputed` and unknown statuses held as pending | +| `registry.spec.ts` | `getRegisteredGiftCardProviderOrError` returns the adapter while the master or provider switch is off and errors only when unregistered; `getEnabledGiftCardProvider` refuses on either switch; byCountry/default routing requires the result to be registered and enabled; `byCountry` key normalisation | | `bitcoin-company/contract.spec.ts` | Runs `runGiftCardProviderContract` (`test/flash/unit/services/gift-cards/provider-contract.ts`, the shared port contract every adapter must pass) plus TBC-specific assertions and registration idempotency | -| `catalog-cache.spec.ts` | Per-country grouping, per-product keys, countries index written last, `staleAfterSeconds` retention, write failure short-circuits, fresh/stale boundary, normalisation, missing/unreadable/garbage records | -| `claim-crypto.spec.ts` | Round trip, envelope layout, fresh iv per call, tamper detection on payload/tag/iv, version and length checks, key id, key rotation mismatch, hex/base64/whitespace/wrong-length key loading | -| `test/flash/unit/services/mongoose/gift-card-orders.spec.ts`, `.mapping.spec.ts`, `.schema.spec.ts` | Repository create/find/list/transition against a mocked model (the exact `findOneAndUpdate` issued); record -> domain mapping; transition table derived from the domain; duplicate-key attribution; the five indexes match the migration; `toJSON` drops `claimCiphertext`; status enum enforced | +| `catalog-cache.spec.ts` | Per-country grouping, per-product keys, countries index written last, de-listed product keys deleted on the next sync, legacy rows default `maxQuantity` 1 / `wholeUnitsOnly` false, `staleAfterSeconds` retention, write failure short-circuits, fresh/stale boundary, normalisation, missing/unreadable/garbage records | +| `claim-crypto.spec.ts` | Round trip, envelope layout, fresh iv per call, tamper detection on payload/tag/iv, version and length checks, key id, key rotation mismatch, hex/base64/whitespace/wrong-length key loading; AAD binding — a ciphertext decrypts only under the key id and order id it was sealed for (transplant to another order fails) | +| `test/flash/unit/services/mongoose/gift-card-orders.spec.ts`, `.mapping.spec.ts`, `.schema.spec.ts` | Repository create/find/list/transition against a mocked model (the exact `findOneAndUpdate` issued); record -> domain mapping; transition table derived from the domain (including `EXPIRED -> PAID`); duplicate-key attribution; the five indexes match the migration; `toJSON` drops `claimCiphertext`; status enum enforced | +| `test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts` | Scalar and `idempotencyKey` validation before any purchase; no resolver-level gate (a same-key replay while the rail is off returns the order; a fresh purchase relays the app layer's `GIFT_CARDS_DISABLED`); arguments passed through unchanged, quantity default; limiter not consumed here; every pre-payment error mapped to its code; post-IBEX PAID / PAYMENT_PENDING orders carried with `claim: null` and empty `errors`; claim attached via the owner-scoped read when already FULFILLED; decrypt failure and read failure ride alongside the order; ciphertext never on the payload | +| `root/query/gift-card-catalog.spec.ts` | Gate thrown (rail off, provider unavailable); account vs explicit country for gate and list; scalar error for a malformed code; filters and paging passed through; relay edges with the app's cursor codec; empty connection; mapped catalog errors | +| `root/query/gift-card-quote.spec.ts` | Scalar and quantity validation before the gate; gate thrown; quote for THIS account in minor units; wire mapping drops the vendor price; `expiresAt` as a Date; mapped refusals; declared complexity budget | +| `root/query/gift-card-order.spec.ts` | Reads while the rail is off; owner scoping; null for unknown and foreign ids alike; repository fault thrown; FULFILLED with unreadable claim still returned with `claim: null`, recorded at Critical, ciphertext never on span/log/wire; claim only when FULFILLED; wire field mapping | +| `root/query/gift-card-orders.spec.ts` | Cursor codec round-trip and garbage rejection; reads while the rail is off; owner scoping; page-size and cursor argument refusals; relay edges with `createdAt` cursors; claim never in a listing; empty connection; store errors | +| `root/query/globals-gift-cards.spec.ts` | `giftCardsEnabled` true only with the rail on and any provider enabled | +| `types/object/gift-card-order.spec.ts` | Status and denomination enums mirror the domain; `toGiftCardOrderSource` exposes only public fields and attaches the claim only when FULFILLED; the GraphQL types declare no ciphertext-bearing field; every field described; connection names | +| `test/flash/unit/config/rate-limits.spec.ts` | Purchase (10/60 s/300 s) and quote (30/60 s/300 s) attempt budgets pinned literally, wired under their own prefixes and errors | | `test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts` | Pays from the wallet's IBEX account; Success/Pending/unrecognised mapping; `IbexError` pass-through; `onResponse` gets the raw 200 and never runs on a guard rejection or replay; the idempotency wrapper receives key, fingerprint and guard unchanged | Shared fixtures: `test/flash/unit/app/gift-cards/fixtures.ts` @@ -97,13 +111,8 @@ another account; a second purchase with the same key replays. ## Not yet covered -- GraphQL resolver tests for `giftCardCatalog`, `giftCardQuote`, - `giftCardOrder`, `giftCardOrders`, `giftCardPurchase` and - `globals.giftCardsEnabled` (scalar validation, gate errors thrown vs returned, - the claim-only-when-FULFILLED rule at the wire, cursor codecs). -- `quote-gift-card.ts`, `get-order.ts`, `list-orders.ts`, - `send-fulfilled-notification.ts` and `ops.ts` have no direct spec; they are - exercised indirectly through purchase and settle specs. +- `send-fulfilled-notification.ts` and `ops.ts` have no direct spec; they are + exercised through the purchase and settle specs. - The end-to-end Lightning payment: `payLnInvoiceViaIbex` is mocked in every suite, the integration harness mocks `@services/ibex/client`, and the sandbox e2e above does not exist yet. @@ -112,3 +121,5 @@ another account; a second purchase with the same key replays. exists). - Claim key rotation / re-encryption: the crypto spec proves the mismatch is detected; there is no migration and no test for one. +- The GraphQL schema itself is exercised only by the resolver specs above + (no executable-schema query test); the generated SDL is the contract check. diff --git a/src/app/gift-cards/get-order.ts b/src/app/gift-cards/get-order.ts index bc98821d2..6428d5bb3 100644 --- a/src/app/gift-cards/get-order.ts +++ b/src/app/gift-cards/get-order.ts @@ -59,9 +59,12 @@ export const getGiftCardOrderForAccount = async ({ return { order, claim: null, claimError: null } } + // The ciphertext is bound to its order id: one transplanted from another + // row fails here the same way a tampered one does. const claim = decryptGiftCardClaim({ ciphertext: order.claimCiphertext, keyId: order.claimKeyId, + orderId: order.id, }) if (claim instanceof Error) return { order, claim: null, claimError: claim } diff --git a/src/app/gift-cards/gift-cards-master-gate.ts b/src/app/gift-cards/gift-cards-master-gate.ts index b4f1488e7..155eb1f23 100644 --- a/src/app/gift-cards/gift-cards-master-gate.ts +++ b/src/app/gift-cards/gift-cards-master-gate.ts @@ -23,10 +23,21 @@ import { addAttributesToCurrentSpan } from "@services/tracing" * mutation then refuses. * * Hands back the provider id it gated on, so a caller cannot pass the gate for - * one provider and then order from another. + * one provider and then order from another, and the normalised country it + * routed on, so quote and purchase can refuse a product from a different + * country's catalog (`product.countryCode`) the way API.md says they do. + * `countryKnown` is false when the country was `UNKNOWN_COUNTRY_CODE`; callers + * skip the product-country comparison then — we cannot know the user's + * country, and the routed provider's catalog is all we have. */ export type GiftCardsMasterGate = - | { ok: true; providerId: GiftCardProviderId } + | { + ok: true + providerId: GiftCardProviderId + /** Normalised (trimmed, upper-cased) country the route was resolved for; `UNKNOWN_COUNTRY_CODE` when unknown. */ + countryCode: string + countryKnown: boolean + } | { ok: false; error: GiftCardsDisabledError | GiftCardProviderUnavailableError } /** @@ -45,7 +56,12 @@ export const giftCardsMasterGate = (countryCode: string): GiftCardsMasterGate => const providerId = resolveGiftCardProviderIdForCountry(cc) if (providerId instanceof Error) return { ok: false, error: providerId } - return { ok: true, providerId } + return { + ok: true, + providerId, + countryCode: cc, + countryKnown: cc !== UNKNOWN_COUNTRY_CODE, + } } /** diff --git a/src/app/gift-cards/ops.ts b/src/app/gift-cards/ops.ts index 5357f64e1..0d857c1cf 100644 --- a/src/app/gift-cards/ops.ts +++ b/src/app/gift-cards/ops.ts @@ -18,6 +18,8 @@ export type GiftCardOpsPhase = | "refund-required" | "claim-encrypt-failed" | "vendor-fulfilled-unexpected" + /** IBEX answered Success/Pending but the order row could not record it. Money moved; the row lies. */ + | "paid-not-recorded" export const notifyGiftCardOpsEvent = ({ phase, diff --git a/src/app/gift-cards/purchase-gift-card.ts b/src/app/gift-cards/purchase-gift-card.ts index a9a24e94e..97ebd868a 100644 --- a/src/app/gift-cards/purchase-gift-card.ts +++ b/src/app/gift-cards/purchase-gift-card.ts @@ -22,12 +22,16 @@ import { GiftCardQuoteMismatchError, GiftCardVendorRejectedOrderError, GiftCardVendorUnavailableError, + normalizeCountryCode, parseGiftCardProductId, + UnknownGiftCardError, } from "@domain/gift-cards" +import { LockServiceError } from "@domain/lock" import { LnPaymentRequestNonZeroAmountRequiredError } from "@domain/payments" import { RateLimitConfig } from "@domain/rate-limit" import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" -import { ValidationError } from "@domain/shared" +import { ErrorLevel, ValidationError } from "@domain/shared" +import { claimCryptoReady } from "@services/gift-cards/claim-crypto" import { getEnabledGiftCardProvider } from "@services/gift-cards/registry" import { FailedIbexPayment, InsufficientIbexBalance } from "@services/ibex/errors" import { baseLogger } from "@services/logger" @@ -71,12 +75,22 @@ import { fetchAndSettle } from "./settle-order" * send (see `isDefinitiveSendRejection`). The client reads that state as * "nothing left the wallet, buy again with a new key", so an error that merely * says "we don't know" — a socket reset or gateway 5xx after IBEX took the - * request, an unreadable 200, a busy idempotency lock — must never land there. - * Those go to PAYMENT_PENDING and the reconcile worker settles them against - * IBEX and, failing that, the vendor. + * request, an unreadable 200 — must never land there. Those go to + * PAYMENT_PENDING and the reconcile worker settles them against IBEX and, + * failing that, the vendor. A busy idempotency lock writes NOTHING: a + * concurrent same-key attempt owns the outcome and will record it. + * + * Once IBEX has answered Success or Pending this function never returns a bare + * error. If the row cannot record the outcome (a concurrent attempt moved it, + * the worker expired it, Mongo failed) the caller still gets an order — the + * row as it now stands when that is consistent with the money having moved, + * otherwise the in-memory order with a Critical `paid-not-recorded` page. + * Returning an error there would invite the client to buy again. * * Claim data never passes through this module: fulfilment is `settle-order`'s - * job and it stores ciphertext only. + * job and it stores ciphertext only. The claim KEY is checked before paying, + * though: a card we could not store the code for is a customer who paid for + * something they cannot see. */ export type PurchaseGiftCardArgs = { accountId: AccountId @@ -253,22 +267,6 @@ export const purchaseGiftCard = async ( return new InvalidIdempotencyKeyError(args.idempotencyKey) } - // Attempt budget, charged before any vendor or store round-trip so a client - // looping on a refused request bounds its own cost. A limiter STORE fault - // falls through (same posture as fygaroCheckoutCreate): refusing every first - // attempt during a Redis blip would page nobody and block everyone. - const limitOk = await consumeLimiter({ - rateLimitConfig: RateLimitConfig.giftCardPurchase, - keyToConsume: accountId, - }) - if (limitOk instanceof GiftCardPurchaseRateLimiterExceededError) return limitOk - if (limitOk instanceof Error) { - baseLogger.warn( - { accountId, error: limitOk.constructor.name }, - "Gift card purchase rate limiter unavailable; continuing", - ) - } - const account = await AccountsRepository().findById(accountId) if (account instanceof Error) return account @@ -308,6 +306,24 @@ export const purchaseGiftCard = async ( return replayExistingOrder({ existing, account, walletId }) } + // Attempt budget, charged before any vendor round-trip so a client looping + // on a refused request bounds its own cost — but AFTER the replay lookup, so + // polling an order you already own by re-sending its key costs nothing. A + // limiter STORE fault falls through (same posture as fygaroCheckoutCreate): + // refusing every first attempt during a Redis blip would page nobody and + // block everyone. + const limitOk = await consumeLimiter({ + rateLimitConfig: RateLimitConfig.giftCardPurchase, + keyToConsume: accountId, + }) + if (limitOk instanceof GiftCardPurchaseRateLimiterExceededError) return limitOk + if (limitOk instanceof Error) { + baseLogger.warn( + { accountId, error: limitOk.constructor.name }, + "Gift card purchase rate limiter unavailable; continuing", + ) + } + // Master gate with the account's routing country. const countryCode = await resolveAccountCountryCodeOrUnknown(account) const gate = giftCardsMasterGate(countryCode) @@ -321,13 +337,23 @@ export const purchaseGiftCard = async ( if (product.providerId !== gate.providerId) { return new GiftCardProductNotAvailableInCountryError() } + // ...and from this country's catalog. The storefront lists by country, so a + // mismatch is a client that browsed one country and bought from another. + // Skipped when the account's country is unknown: the routed provider's + // catalog is all we have to go on then. + if ( + gate.countryKnown && + normalizeCountryCode(product.countryCode) !== gate.countryCode + ) { + return new GiftCardProductNotAvailableInCountryError() + } if (!product.inStock) { return new GiftCardProductNotFoundError("This gift card is currently out of stock") } const valueMinor = checkedGiftCardValue(product, args.valueMinor) if (valueMinor instanceof Error) return valueMinor - const quantity = checkedGiftCardQuantity(args.quantity) + const quantity = checkedGiftCardQuantity(args.quantity, product.maxQuantity) if (quantity instanceof Error) return quantity const provider = getEnabledGiftCardProvider(gate.providerId) @@ -373,7 +399,21 @@ export const purchaseGiftCard = async ( ? raced : new GiftCardIdempotencyKeyReuseError() } - return created + // No winner to hand back: the create failed for a reason that was not the + // index, or the re-read failed too. Either way a repository fault, and + // surfaced as one — never as the repository-private duplicate-key error, + // which the GraphQL error map does not know and would turn into a 500. + recordExceptionInCurrentSpan({ error: created }) + baseLogger.error( + { + accountId, + walletId, + error: created.constructor.name, + reread: raced.constructor.name, + }, + "Could not create gift card order", + ) + return new UnknownGiftCardError("Could not create gift card order") } addAttributesToCurrentSpan({ "giftcard.orderId": created.id }) notifyGiftCardOpsEvent({ phase: "order-created", status: "pending", order: created }) @@ -475,10 +515,11 @@ export const purchaseGiftCard = async ( * one send that never happened. * * CREATED is left alone (the vendor's createOrder is not idempotent; the worker - * expires it), as is an INVOICE_ISSUED row past its expiry (the worker re-reads - * IBEX and the vendor before expiring it). The kill switch stops the resume - * too — it is new money leaving — and the worker's vendor poll still settles - * an invoice the first attempt did pay. + * expires it), as is an INVOICE_ISSUED row past its expiry (the worker asks the + * vendor before expiring it, and a send that settles after expiry revives the + * row from the pay step: EXPIRED → PAID). The kill switch stops the resume too + * — it is new money leaving — and the worker's vendor poll still settles an + * invoice the first attempt did pay. */ const replayExistingOrder = async ({ existing, @@ -537,6 +578,17 @@ const payIssuedOrder = async ({ paymentRequest: string invoiceSats: Satoshis }): Promise => { + // Last free refusal. The vendor will hand back a claim code the moment this + // invoice is paid; if the key that seals it is missing or malformed we could + // take the money and then be unable to store what it bought. Checked on the + // first attempt and on every replay-resume, both of which pass through here. + const cryptoReady = claimCryptoReady() + if (cryptoReady instanceof Error) { + recordExceptionInCurrentSpan({ error: cryptoReady }) + await failUnpaidOrder(order, "claim-key-not-configured", cryptoReady) + return cryptoReady + } + const captured: { providerPaymentRef: string | null; guardRejected: boolean } = { providerPaymentRef: null, guardRejected: false, @@ -564,6 +616,19 @@ const payIssuedOrder = async ({ addAttributesToCurrentSpan({ "giftcard.providerPaymentRef": providerPaymentRef ?? "" }) if (payment instanceof Error) { + if (payment instanceof LockServiceError) { + // A concurrent same-key attempt holds the payment lock. IBEX was not + // asked by THIS call, and that attempt's outcome — with its transaction + // id — is the order's. Writing anything here would race its transition + // and could overwrite the ref it captured with null. Hand back the row + // as it stands; the client polls. + addAttributesToCurrentSpan({ "giftcard.replay.lockBusy": true }) + baseLogger.info( + { orderId: order.id, error: payment.constructor.name }, + "Gift card payment lock busy; a concurrent attempt owns the outcome", + ) + return GiftCardOrdersRepository().findById(order.id) + } if (isDefinitiveSendRejection(payment, captured.guardRejected)) { await markPaymentFailed( order, @@ -584,18 +649,71 @@ const payIssuedOrder = async ({ return markPaidAndSettle(order, invoiceSats, providerPaymentRef) case PaymentSendStatus.Pending.value: return markPaymentPending(order, { reason: "payment-pending", providerPaymentRef }) - default: { - const failed = await markPaymentFailed( - order, - "payment-failed", - null, - providerPaymentRef, - ) - return failed ?? order - } + default: + return markPaymentFailed(order, "payment-failed", null, providerPaymentRef) } } +/** + * The `providerPaymentRef` half of a transition patch. Only written when IBEX + * actually handed one back on this call: a replayed (cached) outcome never + * invokes `onResponse`, and `$set: { providerPaymentRef: null }` from such a + * replay would erase the ref the first attempt recorded. + */ +const refPatch = (providerPaymentRef: string | null): { providerPaymentRef?: string } => + providerPaymentRef ? { providerPaymentRef } : {} + +/** The row already says the money moved (or may have). Nothing to add. */ +const MONEY_MOVED_STATUSES: readonly GiftCardOrderStatus[] = [ + GiftCardOrderStatus.PaymentPending, + GiftCardOrderStatus.Paid, + GiftCardOrderStatus.Fulfilled, +] + +/** + * IBEX answered Success or Pending but the order row could not take the + * transition that records it. The money has (or may have) left the wallet and + * the row says otherwise: page, and hand the caller the order as this call last + * knew it rather than an error — an error would read as "buy again". + */ +const paidNotRecorded = ({ + order, + providerPaymentRef, + intended, + cause, + rowStatus, +}: { + order: GiftCardOrder + providerPaymentRef: string | null + intended: GiftCardOrderStatus + cause: ApplicationError + rowStatus?: GiftCardOrderStatus +}): GiftCardOrder => { + recordExceptionInCurrentSpan({ error: cause, level: ErrorLevel.Critical }) + baseLogger.error( + { + orderId: order.id, + providerPaymentRef, + intended, + rowStatus: rowStatus ?? "unreadable", + error: cause.constructor.name, + }, + "Gift card payment answered by IBEX but the order row could not record it", + ) + notifyGiftCardOpsEvent({ + phase: "paid-not-recorded", + status: "failed", + order, + error: cause.constructor.name, + meta: { + providerPaymentRef: providerPaymentRef ?? "none", + intendedStatus: intended, + rowStatus: rowStatus ?? "unreadable", + }, + }) + return order +} + /** * The send errored without proving IBEX refused it: money may have left the * wallet. PAYMENT_PENDING is the only honest state — the reconcile worker @@ -607,7 +725,7 @@ const markPaymentUnconfirmed = async ( order: GiftCardOrder, error: ApplicationError, providerPaymentRef: string | null, -): Promise => { +): Promise => { const reason = `payment-unconfirmed: ${error.constructor.name}` recordExceptionInCurrentSpan({ error }) baseLogger.warn( @@ -624,22 +742,46 @@ const markPaymentPending = async ( providerPaymentRef, error, }: { reason: string; providerPaymentRef: string | null; error?: ApplicationError }, -): Promise => { +): Promise => { const repo = GiftCardOrdersRepository() const pending = await repo.transition({ id: order.id, from: [GiftCardOrderStatus.InvoiceIssued], to: GiftCardOrderStatus.PaymentPending, reason, - patch: { providerPaymentRef }, + patch: refPatch(providerPaymentRef), }) if (pending instanceof GiftCardOrderStateError) { // A concurrent same-key attempt already moved the order on — to its own - // PAYMENT_PENDING, or to the PAID / PAYMENT_FAILED its send resolved to. - // Whatever it wrote is the truth. - return repo.findById(order.id) + // PAYMENT_PENDING, or to the PAID / FULFILLED its send resolved to. + // Whatever it wrote is the truth. A row in any other state contradicts an + // IBEX that says the send is in flight: page it. + const latest = await repo.findById(order.id) + if (latest instanceof Error) { + return paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: latest, + }) + } + if (MONEY_MOVED_STATUSES.includes(latest.status)) return latest + return paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: pending, + rowStatus: latest.status, + }) + } + if (pending instanceof Error) { + return paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: pending, + }) } - if (pending instanceof Error) return pending notifyGiftCardOpsEvent({ phase: "payment-pending", status: "pending", @@ -650,20 +792,42 @@ const markPaymentPending = async ( return pending } +/** + * Nothing left the wallet. Returns the PAYMENT_FAILED row, or — if a + * concurrent same-key attempt already recorded an outcome — that row, or — if + * the write itself failed — the order as last known, logged; the client reads + * the row's true state on its next poll either way. + */ const markPaymentFailed = async ( order: GiftCardOrder, reason: string, error: ApplicationError | null, providerPaymentRef: string | null, -): Promise => { - const failed = await GiftCardOrdersRepository().transition({ +): Promise => { + const repo = GiftCardOrdersRepository() + const failed = await repo.transition({ id: order.id, from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], to: GiftCardOrderStatus.PaymentFailed, reason, - patch: { failureReason: reason, providerPaymentRef }, + patch: { failureReason: reason, ...refPatch(providerPaymentRef) }, }) + if (failed instanceof GiftCardOrderStateError) { + // A concurrent attempt got there first and has already reported whatever + // it recorded. Whatever it wrote is the truth; do not report it twice. + const latest = await repo.findById(order.id) + baseLogger.warn( + { + orderId: order.id, + reason, + rowStatus: latest instanceof Error ? "unreadable" : latest.status, + }, + "Gift card payment failed but a concurrent attempt already moved the order", + ) + return latest instanceof Error ? order : latest + } if (failed instanceof Error) { + recordExceptionInCurrentSpan({ error: failed }) baseLogger.error( { orderId: order.id, reason, error: failed.constructor.name }, "Could not mark gift card order PAYMENT_FAILED", @@ -676,7 +840,7 @@ const markPaymentFailed = async ( error: error?.constructor.name ?? "PaymentSendStatusFailure", meta: { reason }, }) - return failed instanceof Error ? null : failed + return failed instanceof Error ? order : failed } const markPaidAndSettle = async ( @@ -684,31 +848,61 @@ const markPaidAndSettle = async ( invoiceSats: Satoshis, providerPaymentRef: string | null, ): Promise => { - const paid = await GiftCardOrdersRepository().transition({ + const repo = GiftCardOrdersRepository() + const patch = { paidSats: invoiceSats, ...refPatch(providerPaymentRef) } + const notRecorded = (cause: ApplicationError, rowStatus?: GiftCardOrderStatus) => + paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.Paid, + cause, + rowStatus, + }) + + let paid = await repo.transition({ id: order.id, from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], to: GiftCardOrderStatus.Paid, reason: "payment-settled", - patch: { paidSats: invoiceSats, providerPaymentRef }, + patch, }) - if (paid instanceof Error) { - // The payment went through; only the bookkeeping failed. Critical: until - // the reconcile worker picks this up, an order shows unpaid for money that - // has left the wallet. - recordExceptionInCurrentSpan({ error: paid }) - baseLogger.error( - { orderId: order.id, error: paid.constructor.name }, - "Gift card payment settled but PAID transition failed", - ) - return paid + if (paid instanceof GiftCardOrderStateError) { + // The row is no longer INVOICE_ISSUED / PAYMENT_PENDING. Either a + // concurrent same-key attempt recorded the outcome first, or the reconcile + // worker expired the row while this send was in flight at IBEX. + const latest = await repo.findById(order.id) + if (latest instanceof Error) return notRecorded(latest) + if (latest.status === GiftCardOrderStatus.Expired) { + // The invoice outlived its expiry on our books, not at IBEX: the money + // left. EXPIRED → PAID exists for exactly this. + paid = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.Expired], + to: GiftCardOrderStatus.Paid, + reason: "payment-settled-after-expiry", + patch, + }) + if (paid instanceof GiftCardOrderStateError) { + const again = await repo.findById(order.id) + if (again instanceof Error) return notRecorded(again) + if (MONEY_MOVED_STATUSES.includes(again.status)) return again + return notRecorded(paid, again.status) + } + } else if (MONEY_MOVED_STATUSES.includes(latest.status)) { + return latest + } else { + return notRecorded(paid, latest.status) + } } + if (paid instanceof Error) return notRecorded(paid) notifyGiftCardOpsEvent({ phase: "order-paid", status: "success", order: paid }) // One immediate look: most vendors fulfil within seconds of payment, and a // card in the mutation response is a better experience than a push a moment - // later. A vendor error here is NOT the order's error — PAID is returned and - // the reconcile worker finishes the job. - const settled = await fetchAndSettle(paid) + // later. No retries — the customer is waiting on this response and the + // reconcile worker asks again in seconds. A vendor error here is NOT the + // order's error — PAID is returned and the worker finishes the job. + const settled = await fetchAndSettle(paid, { retry: false }) if (settled instanceof Error) { baseLogger.info( { orderId: paid.id, error: settled.constructor.name }, diff --git a/src/app/gift-cards/quote-gift-card.ts b/src/app/gift-cards/quote-gift-card.ts index 37636871f..894fe5c6c 100644 --- a/src/app/gift-cards/quote-gift-card.ts +++ b/src/app/gift-cards/quote-gift-card.ts @@ -3,6 +3,7 @@ import { checkedGiftCardValue, GiftCardProductNotAvailableInCountryError, GiftCardProductNotFoundError, + normalizeCountryCode, } from "@domain/gift-cards" import { RateLimitConfig } from "@domain/rate-limit" import { GiftCardQuoteRateLimiterExceededError } from "@domain/rate-limit/errors" @@ -89,13 +90,23 @@ export const quoteGiftCard = async ({ if (product.providerId !== gate.providerId) { return new GiftCardProductNotAvailableInCountryError() } + // Same provider, another country's catalog: the storefront lists by country, + // so a mismatch here is a client that browsed one country and bought from + // another. Skipped when the account's country is unknown — the routed + // provider's catalog is all we have to go on then. + if ( + gate.countryKnown && + normalizeCountryCode(product.countryCode) !== gate.countryCode + ) { + return new GiftCardProductNotAvailableInCountryError() + } if (!product.inStock) { return new GiftCardProductNotFoundError("This gift card is currently out of stock") } const checkedValue = checkedGiftCardValue(product, valueMinor) if (checkedValue instanceof Error) return checkedValue - const checkedQuantity = checkedGiftCardQuantity(quantity) + const checkedQuantity = checkedGiftCardQuantity(quantity, product.maxQuantity) if (checkedQuantity instanceof Error) return checkedQuantity const provider = getEnabledGiftCardProvider(gate.providerId) diff --git a/src/app/gift-cards/reconcile-orders.ts b/src/app/gift-cards/reconcile-orders.ts index 785dc0ae8..4446bf180 100644 --- a/src/app/gift-cards/reconcile-orders.ts +++ b/src/app/gift-cards/reconcile-orders.ts @@ -17,7 +17,11 @@ import { } from "@services/tracing" import { notifyGiftCardOpsEvent } from "./ops" -import { fetchAndSettle } from "./settle-order" +import { + fetchAndSettle, + fetchVendorOrderStatus, + settleOrderFromVendor, +} from "./settle-order" // Lazy, for the same reason as reservation-store.ts: no live Redis client as // an import-time side effect of anything that pulls in the app barrel. @@ -30,19 +34,33 @@ const logger = baseLogger.child({ module: "gift-cards.reconcile" }) * * Everything the purchase mutation does not wait for ends up here: * - * CREATED / INVOICE_ISSUED past `expiresAt` → EXPIRED (after one payment re-read - * and, for INVOICE_ISSUED, one vendor poll) + * CREATED past `expiresAt` → EXPIRED + * INVOICE_ISSUED past `expiresAt` → EXPIRED, after one vendor poll (an + * INVOICE_ISSUED row never carries an + * IBEX ref — every path that writes one + * leaves the state — so the vendor is + * the only one who can vouch for it) * PAYMENT_PENDING → PAID | PAYMENT_FAILED (payment re-read); - * IBEX unknown / no ref → vendor poll + * IBEX unknown / no ref → vendor poll; + * no ref + vendor "awaiting payment" + + * 24h past `expiresAt` → PAYMENT_FAILED * PAID → poll vendor with backoff → FULFILLED * | REFUND_REQUIRED; 24h → final vendor - * poll, then REFUND_REQUIRED + * poll, then REFUND_REQUIRED only if the + * vendor positively says not fulfilled + * EXPIRED → never polled here. Its one exit + * (→ PAID) is taken by the purchase path + * when a send that was in flight at + * expiry comes back settled. * * The vendor is the arbiter of last resort: `settleOrderFromVendor` accepts a - * vendor `fulfilled` on an INVOICE_ISSUED or PAYMENT_PENDING order as proof of - * payment, so an order whose send IBEX cannot account for (no transaction id, - * IBEX unreachable, an error after IBEX accepted the request) is never written - * off while the vendor says a card shipped. + * vendor `fulfilled` on an INVOICE_ISSUED, PAYMENT_PENDING or EXPIRED order as + * proof of payment, so an order whose send IBEX cannot account for (no + * transaction id, IBEX unreachable, an error after IBEX accepted the request) + * is never written off while the vendor says a card shipped. Conversely the + * 24h escalation to REFUND_REQUIRED needs the vendor's positive "not + * fulfilled": a 5xx, a claim-key fault or a repository error says nothing about + * the card, so the order stays PAID and the next run asks again. * * Runs under a Redis lock so the cron pod and the trigger's 30s interval never * work the same batch at once. Never throws: a per-order failure is logged, @@ -55,10 +73,12 @@ const logger = baseLogger.child({ module: "gift-cards.reconcile" }) * gate on "a non-terminal order exists" instead (`hasOpenGiftCardOrders`). * * Backoff per PAID order: 5s, 15s, 60s, 5m, then every 15m, measured from the - * PAID transition. Attempts are tracked in process memory (nothing on the - * order changes on a poll, by design — `transition` is the only write), which - * gives the trigger's interval its real schedule and costs the cron exactly one - * attempt per run, which is what a 15-minute cadence would do anyway. + * PAID transition. Attempts are tracked in process memory, which gives the + * trigger's interval its real schedule and costs the cron exactly one attempt + * per run, which is what a 15-minute cadence would do anyway. A poll that + * leaves the status where it was still bumps the row's `updatedAt` (`touch`), + * so `listByStatus` — oldest `updatedAt` first — rotates through a batch + * instead of pinning the same stuck rows at its front run after run. */ export type GiftCardReconcileSummary = { scanned: number @@ -66,6 +86,8 @@ export type GiftCardReconcileSummary = { refundRequired: number expired: number paymentSettled: number + /** PAID orders whose 24h final vendor poll errored and were left PAID for the next run. */ + finalPollFailed: number /** Set when another worker held the lock and this run did nothing. */ skipped?: "lock-held" } @@ -81,6 +103,10 @@ export const GIFT_CARD_PAID_TIMEOUT_MS = 24 * 60 * MINUTE // bounded time, and an order neither our rail nor the vendor can tell us about // after this long needs a human, not another poll. const PAYMENT_PENDING_WARN_MS = 60 * MINUTE +// A PAYMENT_PENDING order with no IBEX ref can only be resolved by the vendor. +// Once the invoice has been expired this long AND the vendor still reports it +// unpaid, no send can settle it any more: the money never left. +export const GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS = 24 * 60 * MINUTE /** Every status the worker still has work to do on. */ export const GIFT_CARD_OPEN_STATUSES: readonly GiftCardOrderStatus[] = Object.values( @@ -115,6 +141,17 @@ export const __resetGiftCardReconcileStateForTest = (): void => { lastAttemptAt.clear() } +/** Bump `updatedAt` after a poll that changed nothing, so the batch rotates. Best effort. */ +const touch = async (order: GiftCardOrder): Promise => { + const touched = await GiftCardOrdersRepository().touch(order.id) + if (touched instanceof Error) { + logger.warn( + { orderId: order.id, error: touched.constructor.name }, + "Could not bump updatedAt on a polled gift card order", + ) + } +} + const lastTransitionAt = (order: GiftCardOrder, status: GiftCardOrderStatus): Date => { for (let i = order.statusHistory.length - 1; i >= 0; i -= 1) { if (order.statusHistory[i].status === status) return order.statusHistory[i].at @@ -250,28 +287,44 @@ const countVendorOutcome = ( } } +type VendorPoll = { + /** What the vendor said, when it could be asked. */ + vendorStatus: GiftCardProviderOrderStatus["kind"] | null + /** The order the vendor's answer produced, when it moved the order. */ + moved: GiftCardOrder | null +} + /** - * Ask the vendor about an order IBEX could not account for. Returns the order - * the vendor's answer produced when it moved the order, null when it did not - * (still awaiting payment, vendor unreachable, or the vendor's word is not - * enough for this state — `settleOrderFromVendor` decides). + * Ask the vendor about an order IBEX could not account for, and settle on the + * answer. `moved` is null when the order stayed put (still awaiting payment, + * vendor unreachable, or the vendor's word is not enough for this state — + * `settleOrderFromVendor` decides); `vendorStatus` is kept separately because + * the PAYMENT_PENDING path needs the vendor's own answer, not just its effect. */ -const settleFromVendorIfMoved = async ( +const pollVendor = async ( order: GiftCardOrder, summary: GiftCardReconcileSummary, -): Promise => { - if (!order.providerOrderId) return null - const settled = await fetchAndSettle(order) +): Promise => { + if (!order.providerOrderId) return { vendorStatus: null, moved: null } + const status = await fetchVendorOrderStatus(order) + if (status instanceof Error) { + logger.info( + { orderId: order.id, status: order.status, error: status.constructor.name }, + "Vendor poll for an unaccounted payment failed", + ) + return { vendorStatus: null, moved: null } + } + const settled = await settleOrderFromVendor(order, status) if (settled instanceof Error) { logger.info( { orderId: order.id, status: order.status, error: settled.constructor.name }, "Vendor poll for an unaccounted payment did not settle", ) - return null + return { vendorStatus: status.kind, moved: null } } - if (settled.status === order.status) return null + if (settled.status === order.status) return { vendorStatus: status.kind, moved: null } countVendorOutcome(order, settled, summary) - return settled + return { vendorStatus: status.kind, moved: settled } } const settleAsPaid = async ( @@ -315,29 +368,16 @@ const processExpiry = async ( if (order.expiresAt.getTime() >= now.getTime()) return // An INVOICE_ISSUED order may have been paid by a call that crashed or - // errored before recording it. One IBEX re-read before writing it off: - // settled → PAID, in flight → leave it for the next run. When IBEX cannot - // answer (no transaction id was ever persisted, or the re-read failed) the - // vendor gets the last word: a card that shipped is proof of payment, and - // `settleOrderFromVendor` records it as such. Only an invoice neither IBEX - // nor the vendor knows as paid is expired. + // errored before recording it. It never carries an IBEX transaction id + // (every path that captures one leaves INVOICE_ISSUED in the same write), so + // there is nothing to re-read at IBEX; the vendor gets the last word: a card + // that shipped is proof of payment, and `settleOrderFromVendor` records it + // as such. Only an invoice the vendor does not know as paid is expired — and + // if a send was still in flight at IBEX, a late Success revives it + // (EXPIRED → PAID) from the purchase path. if (order.status === GiftCardOrderStatus.InvoiceIssued) { - const payment = await lookupSentPaymentStatus(order) - if (payment === "settled") { - await settleAsPaid(order, summary, "payment-settled-on-reconcile") - return - } - if (payment === "pending") { - logger.warn( - { orderId: order.id }, - "Expired gift card invoice still has an in-flight payment; not expiring", - ) - return - } - if (payment === "unknown") { - const moved = await settleFromVendorIfMoved(order, summary) - if (moved) return - } + const { moved } = await pollVendor(order, summary) + if (moved) return } const expired = await GiftCardOrdersRepository().transition({ @@ -387,9 +427,34 @@ const processPendingPayment = async ( case "unknown": { // IBEX cannot account for this send (no transaction id, or the re-read // failed). The vendor can: `fulfilled` settles the order as paid and - // fulfilled in one step. Anything else leaves it pending for next run. - const moved = await settleFromVendorIfMoved(order, summary) + // fulfilled in one step. Anything else leaves it pending for next run — + // with one terminal exit. No ref means IBEX never handed back an id, so + // only the vendor can ever resolve this row; once the invoice has been + // expired a full day and the vendor still says it was never paid, no send + // can settle it any more. The money did not leave. + const { vendorStatus, moved } = await pollVendor(order, summary) if (moved) return + if ( + !order.providerPaymentRef && + vendorStatus === "awaitingPayment" && + now.getTime() > order.expiresAt.getTime() + GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + ) { + const failed = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.PaymentFailed, + reason: "payment-unresolved-expired", + patch: { failureReason: "payment-unresolved-expired" }, + }) + if (failed instanceof Error) throw failed + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order: failed, + meta: { reason: "payment-unresolved-expired", vendorStatus }, + }) + return + } break } case "pending": @@ -409,6 +474,7 @@ const processPendingPayment = async ( "Gift card payment has been pending for over an hour with no resolvable status", ) } + await touch(order) } const processPaid = async ( @@ -426,15 +492,24 @@ const processPaid = async ( lastAttemptAt.set(order.id, nowMs) const settled = await fetchAndSettle(order) if (settled instanceof Error) { + // A vendor 5xx, a claim-key fault (the card IS issued; only our storage + // failed), a repository error — none of these say the card is not + // coming. REFUND_REQUIRED on such an answer would refund a delivered + // card. Stay PAID; log the class; ask again next run. + summary.finalPollFailed += 1 logger.warn( { orderId: order.id, error: settled.constructor.name }, - "Final vendor poll before fulfilment timeout failed; escalating", + "Final vendor poll before fulfilment timeout errored; keeping PAID for the next run", ) - } else if (settled.status !== GiftCardOrderStatus.Paid) { + await touch(order) + return + } + if (settled.status !== GiftCardOrderStatus.Paid) { countVendorOutcome(order, settled, summary) return } + // The vendor answered and positively reports no card after 24h. const refund = await GiftCardOrdersRepository().transition({ id: order.id, from: [GiftCardOrderStatus.Paid], @@ -450,11 +525,7 @@ const processPaid = async ( status: "failed", order: refund, error: "fulfillment-timeout", - meta: { - reason: "fulfillment-timeout", - lastVendorPoll: - settled instanceof Error ? settled.constructor.name : "not-fulfilled", - }, + meta: { reason: "fulfillment-timeout", lastVendorPoll: "not-fulfilled" }, }) return } @@ -470,6 +541,8 @@ const processPaid = async ( } else if (settled.status === GiftCardOrderStatus.RefundRequired) { summary.refundRequired += 1 lastAttemptAt.delete(order.id) + } else { + await touch(order) } } @@ -502,6 +575,7 @@ export const reconcileGiftCardOrders = async ( refundRequired: 0, expired: 0, paymentSettled: 0, + finalPollFailed: 0, } let token: string | null @@ -564,6 +638,7 @@ export const reconcileGiftCardOrders = async ( "giftcard.reconcile.refundRequired": summary.refundRequired, "giftcard.reconcile.expired": summary.expired, "giftcard.reconcile.paymentSettled": summary.paymentSettled, + "giftcard.reconcile.finalPollFailed": summary.finalPollFailed, }) return summary } finally { diff --git a/src/app/gift-cards/settle-order.ts b/src/app/gift-cards/settle-order.ts index 5d54e534c..ca9c54b59 100644 --- a/src/app/gift-cards/settle-order.ts +++ b/src/app/gift-cards/settle-order.ts @@ -58,15 +58,21 @@ const fulfil = async ( let current = order // The vendor saw our payment before we recorded it (a crash between the pay - // call and the PAID transition, or a Pending that settled). Their word that - // the card shipped is proof of payment; record it so the order can proceed. + // call and the PAID transition, a Pending that settled, or a send still in + // flight when the worker expired the row). Their word that the card shipped + // is proof of payment; record it so the order can proceed. if ( current.status === GiftCardOrderStatus.InvoiceIssued || - current.status === GiftCardOrderStatus.PaymentPending + current.status === GiftCardOrderStatus.PaymentPending || + current.status === GiftCardOrderStatus.Expired ) { const paid = await repo.transition({ id: current.id, - from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + from: [ + GiftCardOrderStatus.InvoiceIssued, + GiftCardOrderStatus.PaymentPending, + GiftCardOrderStatus.Expired, + ], to: GiftCardOrderStatus.Paid, reason: "vendor-reported-fulfilled", patch: { paidSats: current.invoiceSats ?? current.quoteSats }, @@ -77,8 +83,8 @@ const fulfil = async ( } if (current.status !== GiftCardOrderStatus.Paid) { - // FAILED / PAYMENT_FAILED / EXPIRED / REFUND_REQUIRED, yet the vendor says - // a card was issued. Someone paid for it; our records say not us. Page. + // FAILED / PAYMENT_FAILED / REFUND_REQUIRED, yet the vendor says a card + // was issued. Someone paid for it; our records say not us. Page. const error = new GiftCardOrderStateError( `Vendor reports fulfilled but order is ${current.status}`, ) @@ -93,7 +99,8 @@ const fulfil = async ( return error } - const encrypted = encryptGiftCardClaim(claim) + // Sealed to THIS order: the ciphertext will not open on any other row. + const encrypted = encryptGiftCardClaim(claim, { orderId: current.id }) if (encrypted instanceof Error) { // The card exists at the vendor; only our storage failed. Leave PAID so the // next poll retries once the key is fixed, and page because until then the @@ -207,19 +214,28 @@ const vendorFailed = async ( } } +export type FetchVendorOrderOptions = { + /** + * Passed through to the adapter. The purchase mutation's inline first poll + * sets `retry: false`: the customer is waiting on the response, and the + * reconcile worker will ask again in seconds anyway. The worker leaves the + * adapter's default. + */ + retry?: boolean +} + /** - * Ask the vendor where the order stands and settle on the answer. The - * reconcile worker's PAID path, its fallback for payments IBEX cannot account - * for, and the purchase mutation's single fulfilled-already? poll. + * Ask the vendor where the order stands, without acting on the answer. * * Resolves the order's provider by registration, not by `enabled`: the kill * switch (`giftCards.enabled`, `providers..enabled`) stops NEW money * leaving via quote/purchase. An order that already exists has already paid or * may have, and refusing to look it up would strand the customer's code. */ -export const fetchAndSettle = async ( +export const fetchVendorOrderStatus = async ( order: GiftCardOrder, -): Promise => { + opts?: FetchVendorOrderOptions, +): Promise => { const provider = getRegisteredGiftCardProviderOrError(order.providerId) if (provider instanceof Error) return provider @@ -227,10 +243,27 @@ export const fetchAndSettle = async ( return new GiftCardOrderStateError("Order has no provider order id to look up") } - const status = await provider.getOrder({ - providerOrderId: order.providerOrderId, - paymentRequest: order.paymentRequest, - }) + return provider.getOrder( + { + providerOrderId: order.providerOrderId, + paymentRequest: order.paymentRequest, + }, + opts, + ) +} + +/** + * Ask the vendor where the order stands and settle on the answer. The + * reconcile worker's PAID path and the purchase mutation's single + * fulfilled-already? poll. (The worker's fallback for payments IBEX cannot + * account for uses the two halves separately: it needs the vendor's answer + * itself, not just what it did to the order.) + */ +export const fetchAndSettle = async ( + order: GiftCardOrder, + opts?: FetchVendorOrderOptions, +): Promise => { + const status = await fetchVendorOrderStatus(order, opts) if (status instanceof Error) return status return settleOrderFromVendor(order, status) diff --git a/src/app/gift-cards/sync-catalog.ts b/src/app/gift-cards/sync-catalog.ts index 640359690..4da4bdd91 100644 --- a/src/app/gift-cards/sync-catalog.ts +++ b/src/app/gift-cards/sync-catalog.ts @@ -1,6 +1,10 @@ import { randomBytes } from "crypto" -import { GiftCardError, UnknownGiftCardError } from "@domain/gift-cards" +import { + GiftCardError, + GiftCardVendorUnavailableError, + UnknownGiftCardError, +} from "@domain/gift-cards" import { notifyOpsEvent } from "@services/alerts/ops-events" import { enabledGiftCardProviders } from "@services/gift-cards" import { GiftCardCatalogCache } from "@services/gift-cards/catalog-cache" @@ -79,6 +83,16 @@ const syncForProvider = async ( } if (products instanceof Error) return fail(products) + // Zero products is a failed pull wearing a success status, not a catalog. + // Writing it would blank the countries index and every country's listing + // until the next sync; failing keeps the last good catalog serving and + // frees the interval marker so the next tick retries. + if (products.length === 0) { + return fail( + new GiftCardVendorUnavailableError("gift card provider returned an empty catalog"), + ) + } + const written = await GiftCardCatalogCache().write({ providerId, products, diff --git a/src/domain/gift-cards/index.ts b/src/domain/gift-cards/index.ts index 871c2b2e5..273efa511 100644 --- a/src/domain/gift-cards/index.ts +++ b/src/domain/gift-cards/index.ts @@ -8,6 +8,15 @@ export * from "./primitives" * * Terminal: FULFILLED, FAILED, PAYMENT_FAILED, EXPIRED, REFUND_REQUIRED. * REFUND_REQUIRED is the only state meaning "money left Flash and no card arrived". + * + * EXPIRED has one way out: EXPIRED → PAID. An INVOICE_ISSUED pay can still be + * in flight at IBEX when `expiresAt` passes and the reconcile worker expires + * the row; if that send then settles (a late Success on the purchase path, or + * the vendor reporting the card shipped) the money has left and the order must + * be able to say so. EXPIRED is still "terminal" for every listing purpose — + * the worker never polls it and `hasOpenGiftCardOrders` does not count it — + * because nothing the worker could do would revive it: only the purchase + * path's late Success or a vendor poll from that path can. */ export const GiftCardOrderStatus = { Created: "CREATED", @@ -21,6 +30,12 @@ export const GiftCardOrderStatus = { RefundRequired: "REFUND_REQUIRED", } as const +/** + * "Nothing left for the worker to do." EXPIRED is included even though it has + * a legal exit (→ PAID): that exit is only ever taken by the purchase path + * (see the lifecycle doc above), never by the worker, so an EXPIRED order is + * not open work. + */ export const GIFT_CARD_TERMINAL_STATUSES: readonly GiftCardOrderStatus[] = [ GiftCardOrderStatus.Fulfilled, GiftCardOrderStatus.Failed, @@ -40,7 +55,8 @@ export const GIFT_CARD_TRANSITIONS: Readonly< FULFILLED: [], FAILED: [], PAYMENT_FAILED: [], - EXPIRED: [], + // A pay still in flight when the worker expired the row, then settled. + EXPIRED: ["PAID"], REFUND_REQUIRED: [], } diff --git a/src/domain/gift-cards/index.types.d.ts b/src/domain/gift-cards/index.types.d.ts index 637bfde5b..dc12bdaa2 100644 --- a/src/domain/gift-cards/index.types.d.ts +++ b/src/domain/gift-cards/index.types.d.ts @@ -31,6 +31,14 @@ type GiftCardProduct = { readonly termsUrl: string | null readonly rewardBps: number // vendor reward/commission on face value, basis points readonly inStock: boolean + /** + * Cards the vendor will fulfil in one order. Flash clamps the request to + * `min(maxQuantity, GIFT_CARD_MAX_QUANTITY)`; adapters set 1 until a vendor's + * multi-card response shape has been captured and validated. + */ + readonly maxQuantity: number + /** Variable-value cards that refuse cents (e.g. TBC "VariableNoCents"). */ + readonly wholeUnitsOnly: boolean } type GiftCardQuote = { @@ -99,6 +107,14 @@ interface IGiftCardProvider { }): Promise getOrder( ref: GiftCardProviderOrderRef, + opts?: { + /** + * Retry transient failures (network, 5xx) before giving up. Default + * true. Callers with their own retry loop pass false so one poll is + * one vendor call. + */ + retry?: boolean + }, ): Promise } diff --git a/src/domain/gift-cards/primitives.ts b/src/domain/gift-cards/primitives.ts index dc44d770f..0d116c962 100644 --- a/src/domain/gift-cards/primitives.ts +++ b/src/domain/gift-cards/primitives.ts @@ -5,6 +5,9 @@ export const GIFT_CARD_PROVIDER_IDS: readonly GiftCardProviderId[] = [ "bitrefill", ] +/** Flash-wide ceiling on cards per order, whatever the vendor allows. */ +export const GIFT_CARD_MAX_QUANTITY = 10 + export const isGiftCardProviderId = (value: string): value is GiftCardProviderId => (GIFT_CARD_PROVIDER_IDS as readonly string[]).includes(value) @@ -50,13 +53,13 @@ export const decodeGiftCardProductCursor = (cursor: string): string => /** * Validates a requested face value (minor units) against the product's - * denomination rules. Integers only; fixed products must match exactly; - * variable products must sit inside [min, max]. + * denomination rules. Integers only; whole-unit-only cards refuse cents; fixed + * products must match exactly; variable products must sit inside [min, max]. */ export const checkedGiftCardValue = ( product: Pick< GiftCardProduct, - "denominationType" | "denominations" | "minValue" | "maxValue" + "denominationType" | "denominations" | "minValue" | "maxValue" | "wholeUnitsOnly" >, valueMinor: number, ): number | GiftCardInvalidValueError => { @@ -65,6 +68,9 @@ export const checkedGiftCardValue = ( "Gift card value must be a positive whole amount", ) } + if (product.wholeUnitsOnly && valueMinor % 100 !== 0) { + return new GiftCardInvalidValueError("This card only accepts whole-unit amounts") + } if (product.denominationType === "fixed") { return product.denominations.includes(valueMinor) ? valueMinor @@ -85,9 +91,25 @@ export const checkedGiftCardValue = ( return valueMinor } +/** + * Validates the number of cards. The effective cap is the lower of the + * product's vendor-stated `maxQuantity` and Flash's own ceiling; a malformed + * vendor cap (non-integer, below 1) is treated as single-card rather than + * trusted. + */ export const checkedGiftCardQuantity = ( quantity: number, -): number | GiftCardInvalidValueError => - Number.isSafeInteger(quantity) && quantity >= 1 && quantity <= 10 - ? quantity - : new GiftCardInvalidValueError("Quantity must be between 1 and 10") + maxQuantity: number, +): number | GiftCardInvalidValueError => { + const vendorCap = + Number.isSafeInteger(maxQuantity) && maxQuantity >= 1 ? maxQuantity : 1 + const cap = Math.min(vendorCap, GIFT_CARD_MAX_QUANTITY) + if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > cap) { + return new GiftCardInvalidValueError( + cap === 1 + ? "This card can only be bought one at a time" + : `Quantity must be between 1 and ${cap}`, + ) + } + return quantity +} diff --git a/src/graphql/public/root/mutation/gift-card-purchase.ts b/src/graphql/public/root/mutation/gift-card-purchase.ts index 0e57363da..a6aa3a9be 100644 --- a/src/graphql/public/root/mutation/gift-card-purchase.ts +++ b/src/graphql/public/root/mutation/gift-card-purchase.ts @@ -3,7 +3,6 @@ import { GiftCardOrderStatus } from "@domain/gift-cards" import { InputValidationError } from "@graphql/error" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import { GT } from "@graphql/index" -import { gateGiftCardsForAccount } from "@graphql/public/root/gift-card-gate" import GiftCardPurchaseInput from "@graphql/public/types/input/gift-card-purchase-input" import { toGiftCardOrderSource } from "@graphql/public/types/object/gift-card-order" import GiftCardPurchasePayload from "@graphql/public/types/payload/gift-card-purchase" @@ -64,12 +63,13 @@ const GiftCardPurchaseMutation = GT.Field< } } - // The deploy-level gate, before anything else — the same one the catalog - // and quote opened with, so nothing offered upstream is refused here for a - // different reason. `purchaseGiftCard` gates again internally (it must stand - // alone); the cost is one user read. - const gate = await gateGiftCardsForAccount({ account: domainAccount }) - if (!gate.ok) return { errors: [mapAndParseErrorForGqlResponse(gate.error)] } + // No deploy-level gate here, on purpose. `purchaseGiftCard` runs it AFTER + // the same-key replay lookup, so a retry of a timed-out purchase still finds + // its order once the kill switch is off. A gate at this layer would answer + // that retry with GIFT_CARDS_DISABLED and leave the app layer's "rail off → + // return the existing order" branch unreachable from the wire. The catalog + // and quote still open with `gateGiftCardsForAccount`; a fresh purchase + // while the rail is off gets the same GIFT_CARDS_DISABLED from the app layer. // The purchase attempt budget (RateLimitConfig.giftCardPurchase) is consumed // INSIDE purchaseGiftCard, before any vendor or store round-trip. Not here: diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 74024f72b..962536674 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1173,7 +1173,7 @@ type GiftCardOrder { id: ID! """ - What actually left the wallet, in sats. Null until the payment settles (and forever null for an order that never paid). + The vendor invoice amount in sats — what was paid for the card, excluding the Lightning routing fee. Null until the payment settles (and forever null for an order that never paid). """ paidSats: SatAmount @@ -1294,6 +1294,11 @@ type GiftCardProduct { """Brand artwork, when the vendor supplies one.""" logoUrl: String + """ + Largest quantity a single order may carry for this product. Vendor-specific; 1 while a vendor's multi-card fulfilment response has not been verified. + """ + maxQuantity: Int! + """Largest face value for a VARIABLE card, minor units. Null for FIXED.""" maxValue: CentAmount @@ -1310,6 +1315,11 @@ type GiftCardProduct { """The card's terms and conditions, when the vendor supplies a link.""" termsUrl: String + + """ + True when the vendor only accepts whole currency units for a variable-value card (no cents). Values that are not a multiple of 100 minor units are refused. + """ + wholeUnitsOnly: Boolean! } """A connection to a list of items.""" diff --git a/src/graphql/public/types/object/gift-card-order.ts b/src/graphql/public/types/object/gift-card-order.ts index 4d69fa0e9..2fa8fe3d7 100644 --- a/src/graphql/public/types/object/gift-card-order.ts +++ b/src/graphql/public/types/object/gift-card-order.ts @@ -245,8 +245,9 @@ const GiftCardOrder = GT.Object({ paidSats: { type: SatAmount, description: - "What actually left the wallet, in sats. Null until the payment settles " + - "(and forever null for an order that never paid).", + "The vendor invoice amount in sats — what was paid for the card, excluding " + + "the Lightning routing fee. Null until the payment settles (and forever null " + + "for an order that never paid).", }, claim: { type: GiftCardClaim, diff --git a/src/graphql/public/types/object/gift-card-product.ts b/src/graphql/public/types/object/gift-card-product.ts index e7854dab0..ea0c44102 100644 --- a/src/graphql/public/types/object/gift-card-product.ts +++ b/src/graphql/public/types/object/gift-card-product.ts @@ -112,6 +112,18 @@ const GiftCardProduct = GT.Object({ "Vendor reward on face value, in basis points (100 = 1%). Informational; the " + "sats actually rebated for a given purchase are on the quote as rewardSats.", }, + maxQuantity: { + type: GT.NonNull(GT.Int), + description: + "Largest quantity a single order may carry for this product. Vendor-specific; " + + "1 while a vendor's multi-card fulfilment response has not been verified.", + }, + wholeUnitsOnly: { + type: GT.NonNull(GT.Boolean), + description: + "True when the vendor only accepts whole currency units for a variable-value " + + "card (no cents). Values that are not a multiple of 100 minor units are refused.", + }, }), }) diff --git a/src/services/gift-cards/bitcoin-company/client.ts b/src/services/gift-cards/bitcoin-company/client.ts index ff0234c1d..7eb0bc82f 100644 --- a/src/services/gift-cards/bitcoin-company/client.ts +++ b/src/services/gift-cards/bitcoin-company/client.ts @@ -7,6 +7,7 @@ import { GiftCardsConfig } from "@config" import { GiftCardError, + GiftCardInvalidValueError, GiftCardVendorRejectedOrderError, GiftCardVendorUnavailableError, } from "@domain/gift-cards" @@ -260,6 +261,23 @@ export type BitcoinCompanyClientDeps = { const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) +/** + * Vendor catalog as the client hands it to the adapter: the rows that validated + * plus a count of those that did not, so the sync log can report + * received-vs-kept without the client having to know mapping rules. + */ +export type VendorCatalog = { + products: VendorProduct[] + /** Rows that failed `vendorProductSchema` and were dropped. */ + invalid: number +} + +// Fixed customer-facing messages. The vendor's own text is useful to an +// operator reading the warn log and to nobody else: it names internal limits, +// SKUs, and account state that must not reach a customer or a failureReason. +const CUSTOMER_MSG_ORDER_REJECTED = "The provider declined this order" +const CUSTOMER_MSG_QUOTE_REJECTED = "The provider could not quote this amount" + const extractVendorError = (data: unknown): string | null => { if (!data || typeof data !== "object") return null const { error, message } = data as { error?: unknown; message?: unknown } @@ -306,9 +324,9 @@ export class BitcoinCompanyClient { * otherwise hand the sync a duplicate-heavy list that overwrites the last * good catalog wholesale. Failing keeps the previous catalog serving. */ - async listProducts(): Promise { + async listProducts(): Promise { const products: VendorProduct[] = [] - let skipped = 0 + let invalid = 0 let offset = 0 let pages = 0 let reachedEnd = false @@ -335,7 +353,7 @@ export class BitcoinCompanyClient { if (parsed.success) { products.push(parsed.data) } else { - skipped++ + invalid++ } } offset += result.svs.length @@ -349,13 +367,13 @@ export class BitcoinCompanyClient { return new GiftCardVendorUnavailableError() } - if (skipped > 0) { + if (invalid > 0) { baseLogger.warn( - { provider: PROVIDER, op: "listProducts", skipped, kept: products.length }, + { provider: PROVIDER, op: "listProducts", invalid, kept: products.length }, "Bitcoin Company catalog rows failed validation and were skipped", ) } - return products + return { products, invalid } } async quoteCard(args: { @@ -407,15 +425,22 @@ export class BitcoinCompanyClient { }) } - /** Idempotent read despite being a POST; retried like a GET. */ - async invoiceStatus(invoice: string): Promise { + /** + * Idempotent read despite being a POST; retried like a GET by default. + * `retry: false` makes one attempt with the normal timeout, for callers + * (the reconcile worker) that schedule their own retries. + */ + async invoiceStatus( + invoice: string, + opts: { retry?: boolean } = {}, + ): Promise { return this.call({ op: "getOrder", method: "POST", path: "/giftcards/invoice-status", body: { invoice }, auth: true, - retry: true, + retry: opts.retry ?? true, schema: purchasedProductSchema, }) } @@ -575,13 +600,17 @@ export class BitcoinCompanyClient { { provider: PROVIDER, op, status: err.status, vendorError: err.vendorError }, "Bitcoin Company rejected the request", ) - if ( - op === "createOrder" && - err.status >= 400 && - err.status < 500 && - err.vendorError - ) { - return new GiftCardVendorRejectedOrderError(err.vendorError) + // A 4xx carrying a vendor message is a deterministic rejection of OUR + // input, not an outage: retrying the same request will not help, and + // paging on it would be noise. Without a message we cannot tell the two + // apart and stay on the unavailable (Critical) path. + const rejectedInput = + err.status >= 400 && err.status < 500 && Boolean(err.vendorError) + if (rejectedInput && op === "createOrder") { + return new GiftCardVendorRejectedOrderError(CUSTOMER_MSG_ORDER_REJECTED) + } + if (rejectedInput && op === "quote") { + return new GiftCardInvalidValueError(CUSTOMER_MSG_QUOTE_REJECTED) } return new GiftCardVendorUnavailableError() } diff --git a/src/services/gift-cards/bitcoin-company/index.ts b/src/services/gift-cards/bitcoin-company/index.ts index bfeaf5b7c..25b536b3a 100644 --- a/src/services/gift-cards/bitcoin-company/index.ts +++ b/src/services/gift-cards/bitcoin-company/index.ts @@ -1,4 +1,8 @@ -import { GiftCardError, GiftCardVendorUnavailableError } from "@domain/gift-cards" +import { + GiftCardError, + GiftCardInvalidValueError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" import { baseLogger } from "@services/logger" import { addAttributesToCurrentSpan, @@ -9,9 +13,12 @@ import { getRegisteredGiftCardProvider, registerGiftCardProvider } from "../regi import { BitcoinCompanyClient, BitcoinCompanyClientDeps } from "./client" import { + BITCOIN_COMPANY_MAX_QUANTITY, BITCOIN_COMPANY_PROVIDER_ID, + PRODUCT_MAPPING_SKIP_REASONS, ProductMappingSkipReason, isProductMappingSkip, + isResellingDisabled, mapVendorOrderStatus, mapVendorProduct, mapVendorPurchase, @@ -42,6 +49,25 @@ const spanAttributes = (op: string) => ({ "giftcard.op": op, }) +const emptySkipCounts = (): Record => + Object.fromEntries(PRODUCT_MAPPING_SKIP_REASONS.map((reason) => [reason, 0])) as Record< + ProductMappingSkipReason, + number + > + +/** + * Every product this adapter lists carries `maxQuantity: 1`, so the app layer + * rejects a larger order before reaching here. This is the belt to that brace: + * a caller that bypasses the product check still never sends the vendor a + * quantity whose status response we cannot read back. + */ +const checkQuantity = (quantity: number): GiftCardInvalidValueError | null => + quantity > BITCOIN_COMPANY_MAX_QUANTITY + ? new GiftCardInvalidValueError( + "Only one card per order is supported for this provider", + ) + : null + export const BitcoinCompanyProvider = ( deps: BitcoinCompanyProviderDeps = {}, ): IGiftCardProvider => { @@ -50,30 +76,55 @@ export const BitcoinCompanyProvider = ( const listProducts = async (): Promise => { addAttributesToCurrentSpan(spanAttributes("listProducts")) - const rows = await client.listProducts() - if (rows instanceof Error) return rows + const catalog = await client.listProducts() + if (catalog instanceof Error) return catalog const products: GiftCardProduct[] = [] - const skipped: Partial> = {} - for (const row of rows) { + const skipped = emptySkipCounts() + skipped.invalid = catalog.invalid + let notResellable = 0 + for (const row of catalog.products) { const mapped = mapVendorProduct(row) if (isProductMappingSkip(mapped)) { - skipped[mapped.skipped] = (skipped[mapped.skipped] ?? 0) + 1 + skipped[mapped.skipped] += 1 continue } + if (isResellingDisabled(row)) notResellable += 1 products.push(mapped) } - baseLogger.info( - { - provider: BITCOIN_COMPANY_PROVIDER_ID, - op: "listProducts", - received: rows.length, - mapped: products.length, - skipped, - }, - "Bitcoin Company catalog mapped", - ) + const received = catalog.products.length + catalog.invalid + const summary = { + provider: BITCOIN_COMPANY_PROVIDER_ID, + op: "listProducts", + received, + kept: products.length, + skipped, + flagged: { notResellable }, + } + + // Rows came back but none survived mapping: that is a vendor-side shape or + // policy change, not a catalog. Returning [] would let the sync blank the + // read model; failing keeps the last good catalog serving. + if (received > 0 && products.length === 0) { + baseLogger.error( + summary, + "Bitcoin Company catalog received rows but kept none; failing the sync", + ) + return new GiftCardVendorUnavailableError( + "Bitcoin Company catalog produced no usable products", + ) + } + + if (notResellable > 0) { + // TODO(ENG-586): flip to skip once KYB flips resellingEnabled. + baseLogger.warn( + { provider: BITCOIN_COMPANY_PROVIDER_ID, op: "listProducts", notResellable }, + "Bitcoin Company catalog rows have resellingEnabled=false; kept pending KYB", + ) + } + + baseLogger.info(summary, "Bitcoin Company catalog mapped") return products } @@ -87,6 +138,8 @@ export const BitcoinCompanyProvider = ( quantity: number }): Promise => { addAttributesToCurrentSpan(spanAttributes("quote")) + const tooMany = checkQuantity(quantity) + if (tooMany) return tooMany const vendor = await client.quoteCard({ productId: product.providerProductId, cardValue: toMajorUnits(valueMinor), @@ -108,6 +161,8 @@ export const BitcoinCompanyProvider = ( reference: GiftCardOrderId }): Promise => { addAttributesToCurrentSpan(spanAttributes("createOrder")) + const tooMany = checkQuantity(quantity) + if (tooMany) return tooMany const vendor = await client.purchase({ productId: product.providerProductId, cardValue: toMajorUnits(valueMinor), @@ -120,13 +175,16 @@ export const BitcoinCompanyProvider = ( const getOrder = async ( ref: GiftCardProviderOrderRef, + opts: { retry?: boolean } = {}, ): Promise => { addAttributesToCurrentSpan(spanAttributes("getOrder")) // The vendor keys order status by invoice, not by order id. if (!ref.paymentRequest) { return new GiftCardVendorUnavailableError("status lookup requires payment request") } - const vendor = await client.invoiceStatus(ref.paymentRequest) + const vendor = await client.invoiceStatus(ref.paymentRequest, { + retry: opts.retry ?? true, + }) if (vendor instanceof Error) return vendor const { status, warning } = mapVendorOrderStatus(vendor) diff --git a/src/services/gift-cards/bitcoin-company/mapping.ts b/src/services/gift-cards/bitcoin-company/mapping.ts index d405492e7..53e02e6d9 100644 --- a/src/services/gift-cards/bitcoin-company/mapping.ts +++ b/src/services/gift-cards/bitcoin-company/mapping.ts @@ -22,6 +22,18 @@ export const BITCOIN_COMPANY_PROVIDER_ID: GiftCardProviderId = "bitcoinCompany" export const QUOTE_TTL_MS = 60 * 1000 +/** + * Cards per order this adapter will place. Held at 1 until a sandbox + * `/giftcards/invoice-status` response for a quantity-2 order has been + * captured: `purchasedProductSchema` models exactly one `{ status, claimData }` + * and a per-card array would fail validation on every status poll, stranding a + * paid order. Raising this is a fixture-plus-schema change, not a config flip. + */ +export const BITCOIN_COMPANY_MAX_QUANTITY = 1 + +/** The vendor's name for the payment rail we settle over. */ +export const LIGHTNING_PAYMENT_TYPE = "Lightning" + /** Vendor money is in major units of the product currency; the domain is minor units. */ export const toMinorUnits = (major: number): number => Math.round(major * 100) export const toMajorUnits = (minor: number): number => minor / 100 @@ -55,8 +67,27 @@ export const mapDenominationType = (raw: string): GiftCardDenominationType | nul } } +/** + * Why a catalog row was left out. `invalid` (failed schema validation) is + * counted by the client, which never hands such rows to the mapper; it is + * listed here so the sync log reports every reason under one key set. + */ export type ProductMappingSkipReason = - "no-country" | "unknown-denomination-type" | "no-denominations" + | "invalid" + | "noCountry" + | "physical" + | "noLightning" + | "unknownDenominationType" + | "noDenominations" + +export const PRODUCT_MAPPING_SKIP_REASONS: readonly ProductMappingSkipReason[] = [ + "invalid", + "noCountry", + "physical", + "noLightning", + "unknownDenominationType", + "noDenominations", +] export type ProductMappingSkip = { readonly skipped: ProductMappingSkipReason } @@ -64,6 +95,20 @@ export const isProductMappingSkip = ( value: GiftCardProduct | ProductMappingSkip, ): value is ProductMappingSkip => "skipped" in value +/** + * `resellingEnabled: false` on a row we nevertheless keep. The public catalog + * shows false on every product until the account is KYB'd, so skipping would + * empty the catalog; the adapter counts these and warns instead. + * TODO(ENG-586): flip to a skip reason once KYB flips resellingEnabled. + */ +export const isResellingDisabled = (product: VendorProduct): boolean => + product.resellingEnabled === false + +const acceptsLightning = (paymentTypes: readonly string[]): boolean => + paymentTypes.some( + (type) => type.trim().toLowerCase() === LIGHTNING_PAYMENT_TYPE.toLowerCase(), + ) + const toMinorDenominations = (major: readonly number[]): number[] => [ ...new Set(major.map(toMinorUnits).filter((d) => Number.isSafeInteger(d) && d > 0)), @@ -73,13 +118,21 @@ export const mapVendorProduct = ( product: VendorProduct, ): GiftCardProduct | ProductMappingSkip => { const rawCountry = product.countries.find((c) => c.trim().length > 0) - if (!rawCountry) return { skipped: "no-country" } + if (!rawCountry) return { skipped: "noCountry" } + + // We deliver codes in-app; a shipped card has nothing to deliver. + if (product.isPhysical === true) return { skipped: "physical" } + + // Absent means the vendor did not say; present-but-without-Lightning means no. + if (product.paymentTypes && !acceptsLightning(product.paymentTypes)) { + return { skipped: "noLightning" } + } const denominationType = mapDenominationType(product.denominationType) - if (!denominationType) return { skipped: "unknown-denomination-type" } + if (!denominationType) return { skipped: "unknownDenominationType" } const denominations = toMinorDenominations(product.denominations) - if (denominations.length === 0) return { skipped: "no-denominations" } + if (denominations.length === 0) return { skipped: "noDenominations" } const logoUrl = product.logo || product.panelImg || null @@ -103,6 +156,10 @@ export const mapVendorProduct = ( termsUrl: null, rewardBps: Math.round(product.satsBackPercentage * 100), inStock: product.stock !== 0, + maxQuantity: BITCOIN_COMPANY_MAX_QUANTITY, + // Fixed products: every denomination is already a whole unit and the value + // must match one exactly, so the extra rule would be redundant. + wholeUnitsOnly: product.denominationType === "VariableNoCents", } } @@ -126,7 +183,10 @@ export const mapVendorQuote = ({ fiatCostMinor: toMinorUnits(vendor.fiatCost), satsCost: toSats(Math.round(vendor.satsCost)), rewardSats: toSats(Math.round(vendor.satsBack)), - bitcoinPriceMinor: toMinorUnits(vendor.bitcoinPrice), + bitcoinPriceMinor: + vendor.bitcoinPrice === null || vendor.bitcoinPrice === undefined + ? null + : toMinorUnits(vendor.bitcoinPrice), expiresAt: new Date(now.getTime() + QUOTE_TTL_MS), }) @@ -135,11 +195,16 @@ export const mapVendorQuote = ({ * the one encoded in the BOLT11 itself, which `purchaseGiftCard` decodes. Not * invented here: a fabricated "now + 15 min" would silently outlive a shorter * invoice and keep an unpayable order open. + * + * `amountSats` is non-null on the port, so a missing vendor `amount` maps to 0. + * That is safe because the purchase path decodes the BOLT11 and pays + * `max(invoiceSats, amountSats)`: the invoice amount governs, the vendor's + * figure can only ever raise it, never lower it. */ export const mapVendorPurchase = (vendor: VendorPurchase): GiftCardProviderOrder => ({ providerOrderId: toGiftCardProviderOrderId(String(vendor.uuid)), paymentRequest: vendor.invoice, - amountSats: toSats(Math.round(vendor.amount)), + amountSats: toSats(Math.round(vendor.amount ?? 0)), expiresAt: null, }) @@ -160,7 +225,13 @@ export const mapVendorClaim = ( return { codes, claimLink, barcode } } -type VendorStatusBucket = GiftCardProviderOrderStatus["kind"] +/** + * Port status, plus `held`: a vendor status that is neither settled nor + * terminal for us (a dispute in flight). Held orders stay + * `paidPendingFulfillment` with a warning until a later Refunded / ClawedBack / + * Completed poll, or the 24h REFUND_REQUIRED timeout, decides. + */ +type VendorStatusBucket = GiftCardProviderOrderStatus["kind"] | "held" // Keys are lower-cased so a casing change on the vendor side does not turn a // known status into an "unknown" one. @@ -177,7 +248,9 @@ const VENDOR_STATUS_TABLE: Readonly> = { expired: "failed", cancelled: "failed", refunded: "refunded", - disputed: "refunded", + // A dispute is not a refund: money may still come back as a card or as a + // refund, and calling it terminal either way would be a guess. + disputed: "held", clawedback: "refunded", } @@ -201,6 +274,11 @@ export const mapVendorOrderStatus = ( case "failed": case "refunded": return { status: { kind: bucket, reason: vendorStatus }, warning: null } + case "held": + return { + status: { kind: "paidPendingFulfillment" }, + warning: `Bitcoin Company reported "${vendorStatus}"; holding as pending until the vendor settles it or the timeout decides`, + } case "fulfilled": { const claim = mapVendorClaim(vendor.claimData) if (!claim) { diff --git a/src/services/gift-cards/bitcoin-company/schemas.ts b/src/services/gift-cards/bitcoin-company/schemas.ts index 3c4d5c133..a4e774763 100644 --- a/src/services/gift-cards/bitcoin-company/schemas.ts +++ b/src/services/gift-cards/bitcoin-company/schemas.ts @@ -7,6 +7,11 @@ import { z } from "zod" * * `z.object` strips unknown keys, which is what we want: vendor fields we do * not model never travel past the adapter (and never reach a log line). + * + * Strictness is deliberate per field: anything that decides money or state + * (`invoice`, `uuid`, `fiatCost`, `satsCost`, `status`) is required; fields we + * only display or log (`bitcoinPrice`, `satsBack`, `amount`, `orderId`) are + * `nullish` so a vendor dropping one cannot stall a purchase. */ /** Numbers the vendor may serialise as strings. Rejects null/empty rather than coercing to 0. */ @@ -84,8 +89,10 @@ export const quoteResultSchema = z.object({ // Major units of the product currency. fiatCost: numeric, satsCost: numeric, - satsBack: numeric, - bitcoinPrice: numeric, + // Reward is informational: absent or null reads as "no reward", never as a failed quote. + satsBack: numeric.nullish().transform((value) => value ?? 0), + // Informational; surfaced to the client as-is when present. + bitcoinPrice: numeric.nullish(), quoteMode: z.string().nullish(), customerDiscountFiat: numeric.nullish(), }) @@ -94,8 +101,11 @@ export type VendorQuote = z.infer export const purchaseResultSchema = z.object({ invoice: z.string().min(1), address: z.string().nullish(), - amount: numeric, - orderId: z.union([z.string(), z.number()]), + // Sats the vendor says the invoice is for. Informational: the BOLT11 itself + // is decoded by the purchase path and its amount governs what we pay. + amount: numeric.nullish(), + // Vendor's human-facing order number; `uuid` is the key we store and query by. + orderId: z.union([z.string(), z.number()]).nullish(), uuid: z.union([z.string(), z.number()]), satsBack: numeric.nullish(), }) @@ -116,6 +126,12 @@ export const claimDataSchema = z.object({ }) export type VendorClaimData = z.infer +/** + * Exactly one `{ status, claimData }`. No sandbox capture exists yet of what + * `/giftcards/invoice-status` returns for an order of two or more cards (one + * entry? an array?), which is why the adapter caps `maxQuantity` at 1: a + * per-card array here would fail this schema on every status poll. + */ export const purchasedProductSchema = z.object({ status: z.string(), claimData: claimDataSchema.nullish(), diff --git a/src/services/gift-cards/catalog-cache.ts b/src/services/gift-cards/catalog-cache.ts index 4c35f2e48..325b421d5 100644 --- a/src/services/gift-cards/catalog-cache.ts +++ b/src/services/gift-cards/catalog-cache.ts @@ -1,3 +1,5 @@ +import { z } from "zod" + import { GiftCardsConfig } from "@config" import { CacheUndefinedError } from "@domain/cache" @@ -5,6 +7,7 @@ import { toSeconds } from "@domain/primitives" import { GiftCardCatalogUnavailableError, GiftCardProductNotFoundError, + isGiftCardProviderId, normalizeCountryCode, } from "@domain/gift-cards" import { baseLogger } from "@services/logger" @@ -25,6 +28,7 @@ const cacheService = async () => (await import("@services/cache")).RedisCacheSer * * giftcards:catalog:: -> { syncedAt: ISO, products: GiftCardProduct[] } * giftcards:catalog::countries -> string[] (upper-case ISO 3166-1 alpha-2) + * giftcards:products: -> GiftCardProductId[] written by the last sync * giftcards:product: -> GiftCardProduct * * Every key lives for `catalog.staleAfterSeconds`, NOT `catalog.ttlSeconds`. @@ -34,6 +38,10 @@ const cacheService = async () => (await import("@services/cache")).RedisCacheSer * key and the read becomes `GiftCardCatalogUnavailableError`. The countries * index is written last so a sync that dies half-way never advertises a * country whose catalog key was not written. + * + * The product-id set exists so a sync can delete the product keys of cards the + * vendor delisted since the previous sync. Without it a delisted product stays + * quotable for up to `staleAfterSeconds` and the vendor rejects the purchase. */ export type CatalogRead = { products: GiftCardProduct[] @@ -43,7 +51,7 @@ export type CatalogRead = { type StoredCatalog = { syncedAt: string - products: GiftCardProduct[] + products: unknown[] } export const giftCardCatalogKey = (providerId: GiftCardProviderId, countryCode: string) => @@ -52,6 +60,9 @@ export const giftCardCatalogKey = (providerId: GiftCardProviderId, countryCode: export const giftCardCountriesKey = (providerId: GiftCardProviderId) => `giftcards:catalog:${providerId}:countries` +export const giftCardProductIdsKey = (providerId: GiftCardProviderId) => + `giftcards:products:${providerId}` + export const giftCardProductKey = (productId: GiftCardProductId) => `giftcards:product:${productId}` @@ -60,6 +71,45 @@ const retentionSeconds = (): Seconds => const staleAfterMs = (): number => GiftCardsConfig.catalog.ttlSeconds * 1000 +/** + * Shape of a product as the sync wrote it. Validated on every read so a + * half-written or hand-edited key can never reach a quote. `maxQuantity` and + * `wholeUnitsOnly` arrived after the first catalogs were cached; they default + * so a running cache stays usable across that deploy. The next sync writes + * real values. + */ +const cachedProductSchema = z.object({ + id: z.string().min(1), + providerId: z.string().refine(isGiftCardProviderId), + providerProductId: z.string().min(1), + name: z.string(), + brand: z.string(), + countryCode: z.string(), + currency: z.string(), + denominationType: z.enum(["fixed", "variable"]), + denominations: z.array(z.number()), + minValue: z.number().nullable(), + maxValue: z.number().nullable(), + isOpenLoop: z.boolean(), + categories: z.array(z.string()), + logoUrl: z.string().nullable(), + termsUrl: z.string().nullable(), + rewardBps: z.number(), + inStock: z.boolean(), + maxQuantity: z.number().int().min(1).default(1), + wholeUnitsOnly: z.boolean().default(false), +}) + +const toCachedProduct = (value: unknown): GiftCardProduct | null => { + const parsed = cachedProductSchema.safeParse(value) + if (!parsed.success) return null + return { + ...parsed.data, + id: parsed.data.id as GiftCardProductId, + providerId: parsed.data.providerId as GiftCardProviderId, + } +} + const isStoredCatalog = (value: unknown): value is StoredCatalog => typeof value === "object" && value !== null && @@ -71,7 +121,36 @@ const parseSyncedAt = (raw: string): Date | null => { return Number.isNaN(date.getTime()) ? null : date } +const isStringList = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string") + export const GiftCardCatalogCache = () => { + // Ids the previous sync wrote. Unreadable or missing reads as "none": the + // diff then deletes nothing, and stale keys fall back to expiring on TTL. + const previousProductIds = async ( + cache: ICacheService, + providerId: GiftCardProviderId, + ): Promise => { + const stored = await cache.get({ key: giftCardProductIdsKey(providerId) }) + if (stored instanceof Error) { + if (!(stored instanceof CacheUndefinedError)) { + baseLogger.warn( + { providerId, err: stored }, + "gift card product id set read failed; skipping stale-key cleanup", + ) + } + return [] + } + if (!isStringList(stored)) { + baseLogger.warn( + { providerId }, + "gift card product id set has an unexpected shape; skipping stale-key cleanup", + ) + return [] + } + return stored + } + const write = async ({ providerId, products, @@ -84,6 +163,8 @@ export const GiftCardCatalogCache = () => { const cache = await cacheService() const ttlSecs = retentionSeconds() + const previousIds = await previousProductIds(cache, providerId) + const byCountry = new Map() for (const product of products) { const cc = normalizeCountryCode(product.countryCode) @@ -95,9 +176,9 @@ export const GiftCardCatalogCache = () => { for (const cc of countries) { const group = byCountry.get(cc) as GiftCardProduct[] - const stored: StoredCatalog = { syncedAt: syncedAt.toISOString(), products: group } + const stored = { syncedAt: syncedAt.toISOString(), products: group } - const catalogWrite = await cache.set({ + const catalogWrite = await cache.set({ key: giftCardCatalogKey(providerId, cc), value: stored, ttlSecs, @@ -131,6 +212,43 @@ export const GiftCardCatalogCache = () => { } } + // Delisted since the previous sync: drop the product keys so they cannot be + // quoted. Best effort — a miss here is bounded by the key TTL. Deleting + // BEFORE rewriting the id set means a crash in between leaves the stale ids + // listed, so the next sync retries them. + const currentIds = new Set(products.map((product) => product.id)) + const delisted = previousIds.filter((id) => !currentIds.has(id)) + for (const id of delisted) { + const cleared = await cache.clear({ + key: giftCardProductKey(id as GiftCardProductId), + }) + if (cleared instanceof Error) { + baseLogger.warn( + { providerId, productId: id, err: cleared }, + "gift card delisted product key delete failed; it will expire on its own", + ) + } + } + if (delisted.length > 0) { + baseLogger.info( + { providerId, delisted: delisted.length }, + "gift card delisted product keys removed", + ) + } + + const idsWrite = await cache.set({ + key: giftCardProductIdsKey(providerId), + value: [...currentIds].sort(), + ttlSecs, + }) + if (idsWrite instanceof Error) { + baseLogger.error( + { providerId, err: idsWrite }, + "gift card product id set write failed", + ) + return new GiftCardCatalogUnavailableError(idsWrite.message) + } + // Last on purpose: see the module comment. const indexWrite = await cache.set({ key: giftCardCountriesKey(providerId), @@ -186,8 +304,24 @@ export const GiftCardCatalogCache = () => { return new GiftCardCatalogUnavailableError() } + // Rows are validated one at a time: one bad row is dropped and counted, + // not allowed to take the whole country's listing down with it. + const products: GiftCardProduct[] = [] + let dropped = 0 + for (const row of stored.products) { + const product = toCachedProduct(row) + if (product) products.push(product) + else dropped += 1 + } + if (dropped > 0) { + baseLogger.error( + { providerId, countryCode: cc, dropped, kept: products.length }, + "gift card catalog record has rows of an unexpected shape; dropped", + ) + } + return { - products: stored.products, + products, syncedAt, stale: Date.now() - syncedAt.getTime() > staleAfterMs(), } @@ -200,44 +334,23 @@ export const GiftCardCatalogCache = () => { > => { const cache = await cacheService() - const stored = await cache.get({ - key: giftCardProductKey(productId), - }) + const stored = await cache.get({ key: giftCardProductKey(productId) }) if (stored instanceof CacheUndefinedError) return new GiftCardProductNotFoundError() if (stored instanceof Error) { baseLogger.warn({ productId, err: stored }, "gift card product read failed") return new GiftCardCatalogUnavailableError() } - return stored - } - const countries = async ( - providerId: GiftCardProviderId, - ): Promise => { - const cache = await cacheService() - - const stored = await cache.get({ key: giftCardCountriesKey(providerId) }) - if (stored instanceof Error) { - if (!(stored instanceof CacheUndefinedError)) { - baseLogger.warn( - { providerId, err: stored }, - "gift card countries index read failed", - ) - } - return new GiftCardCatalogUnavailableError() - } - if (!Array.isArray(stored) || !stored.every((cc) => typeof cc === "string")) { - baseLogger.error( - { providerId }, - "gift card countries index has an unexpected shape", - ) - return new GiftCardCatalogUnavailableError() + const product = toCachedProduct(stored) + if (!product) { + baseLogger.error({ productId }, "gift card product record has an unexpected shape") + return new GiftCardProductNotFoundError() } - return stored + return product } return wrapAsyncFunctionsToRunInSpan({ namespace: "services.gift-cards.catalog-cache", - fns: { write, read, readProduct, countries }, + fns: { write, read, readProduct }, }) } diff --git a/src/services/gift-cards/claim-crypto.ts b/src/services/gift-cards/claim-crypto.ts index 9c6c038ad..cfcf616ff 100644 --- a/src/services/gift-cards/claim-crypto.ts +++ b/src/services/gift-cards/claim-crypto.ts @@ -11,6 +11,16 @@ import { GiftCardClaimCryptoError } from "@domain/gift-cards" * * version (1 byte, 0x01) | iv (12) | authTag (16) | data * + * Additional authenticated data (not stored, recomputed on decrypt): + * + * version (1 byte) ‖ keyId (16 ascii hex) ‖ orderId (utf8) + * + * Binding the order id into the AAD means a ciphertext is only ever readable + * on the order it was sealed for: anyone with write access to the orders + * collection can copy `claimCiphertext` from one row to another, but the tag + * will not verify there. `keyId` is fixed-length, so the concatenation is + * unambiguous without a separator. + * * `keyId` is the first 16 hex chars of sha256(rawKeyBytes) and is stored next * to every ciphertext so a future multi-key map can pick the right key during * rotation. Today there is exactly one key; a mismatch is reported, never @@ -29,6 +39,10 @@ const ALGORITHM = "aes-256-gcm" const HEX_KEY = /^[0-9a-fA-F]{64}$/ const BASE64_KEY = /^[A-Za-z0-9+/]+={0,2}$/ +// Strict base64: whole quads, at most two '=' of padding, nothing else. +// `Buffer.from(x, "base64")` never throws — it silently skips bad characters — +// so malformed input has to be refused up front. +const STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ const loadKey = (): Buffer | GiftCardClaimCryptoError => { const raw = GiftCardsConfig?.claimDataEncryptionKey @@ -54,6 +68,13 @@ const loadKey = (): Buffer | GiftCardClaimCryptoError => { const keyIdFor = (key: Buffer): string => createHash("sha256").update(key).digest("hex").slice(0, KEY_ID_LENGTH) +const aadFor = (keyId: string, orderId: string): Buffer => + Buffer.concat([ + Buffer.from([VERSION]), + Buffer.from(keyId, "utf8"), + Buffer.from(orderId, "utf8"), + ]) + const isClaimCode = (value: unknown): value is GiftCardClaimCode => typeof value === "object" && value !== null && @@ -89,16 +110,30 @@ export const giftCardClaimKeyId = (): string | GiftCardClaimCryptoError => { return keyIdFor(key) } +/** + * Can a claim be sealed right now? Loads and validates the configured key + * without encrypting anything. The purchase path asks this BEFORE paying: a + * card the vendor issues for an order whose claim we then cannot store is a + * customer who paid for something they cannot see, so a missing or malformed + * key must refuse the purchase while it is still free to refuse. + */ +export const claimCryptoReady = (): true | GiftCardClaimCryptoError => { + const key = loadKey() + return key instanceof Error ? key : true +} + export const encryptGiftCardClaim = ( claim: GiftCardClaim, + { orderId }: { orderId: GiftCardOrderId }, ): { ciphertext: string; keyId: string } | GiftCardClaimCryptoError => { const key = loadKey() if (key instanceof Error) return key + const keyId = keyIdFor(key) try { const iv = randomBytes(IV_LENGTH) const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }) - cipher.setAAD(Buffer.from([VERSION])) + cipher.setAAD(aadFor(keyId, orderId)) const plaintext = Buffer.from(JSON.stringify(claim), "utf8") const data = Buffer.concat([cipher.update(plaintext), cipher.final()]) const authTag = cipher.getAuthTag() @@ -108,7 +143,7 @@ export const encryptGiftCardClaim = ( authTag, data, ]).toString("base64") - return { ciphertext, keyId: keyIdFor(key) } + return { ciphertext, keyId } } catch { return new GiftCardClaimCryptoError("Could not encrypt gift card details") } @@ -117,9 +152,11 @@ export const encryptGiftCardClaim = ( export const decryptGiftCardClaim = ({ ciphertext, keyId, + orderId, }: { ciphertext: string keyId: string + orderId: GiftCardOrderId }): GiftCardClaim | GiftCardClaimCryptoError => { const key = loadKey() if (key instanceof Error) return key @@ -128,12 +165,10 @@ export const decryptGiftCardClaim = ({ return new GiftCardClaimCryptoError("key rotated") } - let packed: Buffer - try { - packed = Buffer.from(ciphertext, "base64") - } catch { + if (ciphertext.length === 0 || !STRICT_BASE64.test(ciphertext)) { return new GiftCardClaimCryptoError("Gift card claim ciphertext is malformed") } + const packed = Buffer.from(ciphertext, "base64") if (packed.length < 1 + IV_LENGTH + TAG_LENGTH) { return new GiftCardClaimCryptoError("Gift card claim ciphertext is malformed") } @@ -148,7 +183,7 @@ export const decryptGiftCardClaim = ({ let plaintext: string try { const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }) - decipher.setAAD(Buffer.from([VERSION])) + decipher.setAAD(aadFor(keyId, orderId)) decipher.setAuthTag(authTag) plaintext = Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8") } catch { diff --git a/src/services/gift-cards/registry.ts b/src/services/gift-cards/registry.ts index e9f29807c..be0f42ada 100644 --- a/src/services/gift-cards/registry.ts +++ b/src/services/gift-cards/registry.ts @@ -9,7 +9,10 @@ import { * Provider registry. Adapters call `registerGiftCardProvider` at module load * (from `src/services/gift-cards/index.ts`), so adding a vendor is one import * line plus config. Routing is config-driven: `routing.byCountry[cc]`, else - * `routing.default`; a provider is eligible only when `providers..enabled`. + * `routing.default`; a provider is eligible only when `providers..enabled` + * AND an adapter is registered under the id. Config alone is not enough: an + * enabled-but-unregistered id would advertise gift cards (the globals flag, + * the country gate) while every read and write failed. * * Config is read at call time, not module load, so tests can mock `@config`. */ @@ -38,18 +41,29 @@ export const getRegisteredGiftCardProviderOrError = ( return provider } +/** Master switch on, provider switch on, and an adapter registered under the id. */ export const isGiftCardProviderEnabled = (id: GiftCardProviderId): boolean => - GiftCardsConfig.enabled === true && GiftCardsConfig.providers[id]?.enabled === true + GiftCardsConfig.enabled === true && + GiftCardsConfig.providers[id]?.enabled === true && + providers.has(id) /** Registered AND enabled providers, for the catalog sync job. */ export const enabledGiftCardProviders = (): IGiftCardProvider[] => [...providers.values()].filter((p) => isGiftCardProviderEnabled(p.id)) +// Country keys are compared normalised on both sides so `{ jm: bitrefill }` in +// the yaml routes the same as `{ JM: bitrefill }`. +const routedProviderId = (cc: string): GiftCardProviderId => { + for (const [key, id] of Object.entries(GiftCardsConfig.routing.byCountry)) { + if (normalizeCountryCode(key) === cc) return id + } + return GiftCardsConfig.routing.default +} + export const resolveGiftCardProviderIdForCountry = ( countryCode: string, ): GiftCardProviderId | GiftCardProviderUnavailableError => { - const cc = normalizeCountryCode(countryCode) - const routed = GiftCardsConfig.routing.byCountry[cc] ?? GiftCardsConfig.routing.default + const routed = routedProviderId(normalizeCountryCode(countryCode)) if (!isGiftCardProviderEnabled(routed)) return new GiftCardProviderUnavailableError() return routed } diff --git a/src/services/mongoose/gift-card-orders.ts b/src/services/mongoose/gift-card-orders.ts index c5098289a..ab84906e4 100644 --- a/src/services/mongoose/gift-card-orders.ts +++ b/src/services/mongoose/gift-card-orders.ts @@ -89,6 +89,13 @@ export interface IGiftCardOrdersRepository { ): Promise< GiftCardOrder | GiftCardOrderStateError | GiftCardOrderNotFoundError | RepositoryError > + /** + * Bump `updatedAt` without changing anything else. The reconcile worker + * calls this after a poll that left the status where it was, so + * `listByStatus` (oldest `updatedAt` first) rotates through a batch instead + * of pinning the same stuck rows at the front of every run. + */ + touch(id: GiftCardOrderId): Promise } /** @@ -282,6 +289,21 @@ export const GiftCardOrdersRepository = (): IGiftCardOrdersRepository => { } } + const touch = async ( + id: GiftCardOrderId, + ): Promise => { + try { + const result = await GiftCardOrders.updateOne( + { id: { $eq: id } }, + { $set: { updatedAt: new Date() } }, + ) + if (result.matchedCount === 0) return new GiftCardOrderNotFoundError() + return true + } catch (err) { + return parseRepositoryError(err) + } + } + return { create, findById, @@ -290,5 +312,6 @@ export const GiftCardOrdersRepository = (): IGiftCardOrdersRepository => { listByAccount, listByStatus, transition, + touch, } } diff --git a/test/flash/unit/app/gift-cards/fixtures.ts b/test/flash/unit/app/gift-cards/fixtures.ts index 8630a9cef..c9e4bb891 100644 --- a/test/flash/unit/app/gift-cards/fixtures.ts +++ b/test/flash/unit/app/gift-cards/fixtures.ts @@ -1,7 +1,9 @@ // Shared fixtures for the gift-card app-layer specs. Not a spec itself (no // `.spec.ts` suffix), so jest never tries to run it. +import { RepositoryError } from "@domain/errors" import { GiftCardOrderNotFoundError, GiftCardOrderStateError } from "@domain/gift-cards" +import { checkGiftCardOrderTransition } from "@services/mongoose/gift-card-orders.mapping" export const ACCOUNT_ID = "6a8203ce490716aa69381454" as AccountId export const WALLET_ID = "6a8203ce490716aa69381455" as WalletId @@ -30,6 +32,8 @@ export const makeProduct = ( termsUrl: null, rewardBps: 100, inStock: true, + maxQuantity: 10, + wholeUnitsOnly: false, ...overrides, }) @@ -143,9 +147,14 @@ export const makeGiftCardsConfig = (overrides: Record = {}) => /** * In-memory `GiftCardOrdersRepository` with the real transition semantics: - * `from` must include the current status, the patch is applied, the history - * grows, `updatedAt` moves. Every method is a `jest.fn` so specs can assert - * calls or override one method for a single test. + * every `from` → `to` must be legal per the domain table (the same + * `checkGiftCardOrderTransition` the real repository runs before touching + * Mongo, so an illegal transition fails here the way it fails in prod), `from` + * must include the current status, the patch is applied, the history grows, + * `updatedAt` moves. Every method is a `jest.fn` so specs can assert calls or + * override one method for a single test. Return types are widened to the real + * repository's (`| RepositoryError`) so a spec can mock a store fault without + * casting. */ export const makeFakeOrdersRepo = () => { const store = new Map() @@ -153,27 +162,35 @@ export const makeFakeOrdersRepo = () => { const now = () => new Date(Date.now()) + type Found = GiftCardOrder | GiftCardOrderNotFoundError | RepositoryError + type Listed = GiftCardOrder[] | RepositoryError + type Transitioned = + GiftCardOrder | GiftCardOrderStateError | GiftCardOrderNotFoundError | RepositoryError + const repo = { store, seed: (order: GiftCardOrder) => { store.set(order.id, order) return order }, - create: jest.fn(async (args: Record) => { - seq += 1 - const order = makeOrder({ - ...(args as Partial), - id: `order-${seq}` as GiftCardOrderId, - status: "CREATED", - statusHistory: [{ status: "CREATED", at: now(), reason: null }], - createdAt: now(), - updatedAt: now(), - }) - store.set(order.id, order) - return order - }), + create: jest.fn( + async (args: Record): Promise => { + seq += 1 + const order = makeOrder({ + ...(args as Partial), + id: `order-${seq}` as GiftCardOrderId, + status: "CREATED", + statusHistory: [{ status: "CREATED", at: now(), reason: null }], + createdAt: now(), + updatedAt: now(), + }) + store.set(order.id, order) + return order + }, + ), findById: jest.fn( - async (id: string) => store.get(id) ?? new GiftCardOrderNotFoundError(), + async (id: string): Promise => + store.get(id) ?? new GiftCardOrderNotFoundError(), ), findByIdempotencyKey: jest.fn( async ({ @@ -182,13 +199,13 @@ export const makeFakeOrdersRepo = () => { }: { walletId: string idempotencyKey: string - }) => + }): Promise => [...store.values()].find( (o) => o.walletId === walletId && o.idempotencyKey === idempotencyKey, ) ?? new GiftCardOrderNotFoundError(), ), findByProviderOrderId: jest.fn( - async ({ providerOrderId }: { providerOrderId: string }) => + async ({ providerOrderId }: { providerOrderId: string }): Promise => [...store.values()].find((o) => o.providerOrderId === providerOrderId) ?? new GiftCardOrderNotFoundError(), ), @@ -201,7 +218,7 @@ export const makeFakeOrdersRepo = () => { accountId: string limit: number before?: Date - }) => + }): Promise => [...store.values()] .filter((o) => o.accountId === accountId) .filter((o) => !before || o.createdAt < before) @@ -209,7 +226,13 @@ export const makeFakeOrdersRepo = () => { .slice(0, limit), ), listByStatus: jest.fn( - async ({ statuses, limit }: { statuses: GiftCardOrderStatus[]; limit: number }) => + async ({ + statuses, + limit, + }: { + statuses: GiftCardOrderStatus[] + limit: number + }): Promise => [...store.values()].filter((o) => statuses.includes(o.status)).slice(0, limit), ), transition: jest.fn( @@ -225,7 +248,9 @@ export const makeFakeOrdersRepo = () => { to: GiftCardOrderStatus reason?: string patch?: Partial - }) => { + }): Promise => { + const allowed = checkGiftCardOrderTransition(from, to) + if (allowed instanceof Error) return allowed const current = store.get(id) if (!current) return new GiftCardOrderNotFoundError() if (!from.includes(current.status)) { @@ -245,6 +270,16 @@ export const makeFakeOrdersRepo = () => { return next }, ), + touch: jest.fn( + async ( + id: string, + ): Promise => { + const current = store.get(id) + if (!current) return new GiftCardOrderNotFoundError() + store.set(id, { ...current, updatedAt: now() }) + return true + }, + ), } return repo } diff --git a/test/flash/unit/app/gift-cards/get-order.spec.ts b/test/flash/unit/app/gift-cards/get-order.spec.ts index d35ac2c9d..30f4377e4 100644 --- a/test/flash/unit/app/gift-cards/get-order.spec.ts +++ b/test/flash/unit/app/gift-cards/get-order.spec.ts @@ -112,31 +112,37 @@ describe("getGiftCardOrderForAccount", () => { expect(result.claimError).toBeNull() expect(mockDecrypt).not.toHaveBeenCalled() }) - - it("does not decrypt a FULFILLED order that carries no ciphertext", async () => { - fulfilledOrder({ claimCiphertext: null, claimKeyId: null }) - - const result = ok(await read()) - - expect(result.claim).toBeNull() - expect(result.claimError).toBeNull() - expect(mockDecrypt).not.toHaveBeenCalled() - }) }) describe("FULFILLED", () => { - it("decrypts the stored claim under the stored key id and returns it", async () => { + it("decrypts the stored claim under the stored key id, bound to this order's id, and returns it", async () => { fulfilledOrder() const result = ok(await read()) expect(mockDecrypt).toHaveBeenCalledTimes(1) - expect(mockDecrypt).toHaveBeenCalledWith({ ciphertext: CIPHERTEXT, keyId: KEY_ID }) + // The order id is authenticated data: a ciphertext copied from another + // row will not open here. + expect(mockDecrypt).toHaveBeenCalledWith({ + ciphertext: CIPHERTEXT, + keyId: KEY_ID, + orderId: ORDER_ID, + }) expect(result.order.status).toBe("FULFILLED") expect(result.claim).toEqual(CLAIM) expect(result.claimError).toBeNull() }) + it("does not decrypt a FULFILLED order that carries no ciphertext", async () => { + fulfilledOrder({ claimCiphertext: null, claimKeyId: null }) + + const result = ok(await read()) + + expect(result.claim).toBeNull() + expect(result.claimError).toBeNull() + expect(mockDecrypt).not.toHaveBeenCalled() + }) + it("returns the order with claim null and the crypto error as DATA when the claim will not decrypt", async () => { // A rotated key (or one pod on stale config) must not hide the order from // its owner: the card IS issued and paid for. Only "cannot read the order" diff --git a/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts b/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts index 978ce92c2..962778fa4 100644 --- a/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts +++ b/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts @@ -4,6 +4,11 @@ import { GiftCardsDisabledError, } from "@domain/gift-cards" +import { + __resetGiftCardProvidersForTest, + registerGiftCardProvider, +} from "@services/gift-cards/registry" + import { giftCardsMasterGate, resolveAccountCountryCode, @@ -41,16 +46,38 @@ const withConfig = (overrides: Record) => { mockConfig = makeGiftCardsConfig(overrides) } +// The registry routes only to an id that is enabled in config AND has an +// adapter registered; the gate is about the config half, so register stubs for +// both ids and let config decide. +const stubProvider = (id: GiftCardProviderId): IGiftCardProvider => + ({ id }) as unknown as IGiftCardProvider + beforeEach(() => { jest.clearAllMocks() mockConfig = makeGiftCardsConfig() + __resetGiftCardProvidersForTest() + registerGiftCardProvider(stubProvider("bitcoinCompany")) + registerGiftCardProvider(stubProvider("bitrefill")) mockFindUserById.mockResolvedValue({ id: "kratos-1", phone: "+18765550100" }) mockResolvePhoneCountries.mockReturnValue({ countries: ["JM"], source: "lookup" }) }) describe("giftCardsMasterGate", () => { - it("opens with the routed provider", () => { - expect(giftCardsMasterGate("US")).toEqual({ ok: true, providerId: "bitcoinCompany" }) + it("opens with the routed provider and the normalised country it routed on", () => { + expect(giftCardsMasterGate("US")).toEqual({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "US", + countryKnown: true, + }) + }) + + it("normalises the country it hands back (trim, upper-case) so callers compare like with like", () => { + expect(giftCardsMasterGate(" us ")).toMatchObject({ + ok: true, + countryCode: "US", + countryKnown: true, + }) }) it("closes with GiftCardsDisabledError when the rail is off", () => { @@ -84,8 +111,18 @@ describe("giftCardsMasterGate", () => { bitrefill: { ...base.providers.bitrefill, enabled: true }, }, }) - expect(giftCardsMasterGate("jm")).toEqual({ ok: true, providerId: "bitrefill" }) - expect(giftCardsMasterGate("US")).toEqual({ ok: true, providerId: "bitcoinCompany" }) + expect(giftCardsMasterGate("jm")).toEqual({ + ok: true, + providerId: "bitrefill", + countryCode: "JM", + countryKnown: true, + }) + expect(giftCardsMasterGate("US")).toEqual({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "US", + countryKnown: true, + }) }) it("a country routed to a disabled provider is unavailable even though the default is on", () => { @@ -96,12 +133,22 @@ describe("giftCardsMasterGate", () => { }) }) - it("the unknown-country sentinel and an empty string both take the default route", () => { + it("the unknown-country sentinel and an empty string both take the default route and report the country as unknown", () => { + // `countryKnown: false` is what lets quote/purchase skip the + // product-country comparison: we cannot know where the user is, and the + // routed provider's catalog is all we have. expect(giftCardsMasterGate(UNKNOWN_COUNTRY_CODE)).toEqual({ ok: true, providerId: "bitcoinCompany", + countryCode: UNKNOWN_COUNTRY_CODE, + countryKnown: false, + }) + expect(giftCardsMasterGate("")).toEqual({ + ok: true, + providerId: "bitcoinCompany", + countryCode: UNKNOWN_COUNTRY_CODE, + countryKnown: false, }) - expect(giftCardsMasterGate("")).toEqual({ ok: true, providerId: "bitcoinCompany" }) }) }) diff --git a/test/flash/unit/app/gift-cards/list-orders.spec.ts b/test/flash/unit/app/gift-cards/list-orders.spec.ts new file mode 100644 index 000000000..135d603a6 --- /dev/null +++ b/test/flash/unit/app/gift-cards/list-orders.spec.ts @@ -0,0 +1,199 @@ +import { UnknownRepositoryError } from "@domain/errors" + +let repo: FakeOrdersRepo + +jest.mock("@services/mongoose", () => ({ + GiftCardOrdersRepository: () => repo, +})) + +import { + GIFT_CARD_ORDERS_DEFAULT_FIRST, + GIFT_CARD_ORDERS_MAX_FIRST, + listGiftCardOrdersForAccount, +} from "@app/gift-cards/list-orders" + +import { + ACCOUNT_ID, + makeFakeOrdersRepo, + makeOrder, + NOW_MS, + type FakeOrdersRepo, +} from "./fixtures" + +const OTHER_ACCOUNT_ID = "6a8203ce490716aa69381499" as AccountId + +/** `count` orders for the account, one minute apart, oldest first: order-0 is the oldest. */ +const seedOrders = (count: number, accountId: AccountId = ACCOUNT_ID) => { + for (let i = 0; i < count; i += 1) { + repo.seed( + makeOrder({ + id: `order-${i}` as GiftCardOrderId, + accountId, + status: "FULFILLED", + claimCiphertext: `CIPHERTEXT-${i}`, + claimKeyId: "k1", + createdAt: new Date(NOW_MS + i * 60_000), + }), + ) + } +} + +const list = (args: { first?: number; after?: Date } = {}) => + listGiftCardOrdersForAccount({ accountId: ACCOUNT_ID, ...args }) + +const ok = async (promise: ReturnType) => { + const page = await promise + if (page instanceof Error) throw page + return page +} + +beforeEach(() => { + repo = makeFakeOrdersRepo() +}) + +describe("listGiftCardOrdersForAccount", () => { + it("lists the account's orders newest first", async () => { + seedOrders(3) + + const page = await ok(list()) + + expect(page.orders.map((o) => o.id)).toEqual(["order-2", "order-1", "order-0"]) + expect(page.hasNextPage).toBe(false) + expect(page.endCursor).toEqual(new Date(NOW_MS)) + }) + + it("never returns another account's orders", async () => { + seedOrders(2, OTHER_ACCOUNT_ID) + seedOrders(1) + + const page = await ok(list()) + + expect(page.orders.map((o) => o.id)).toEqual(["order-0"]) + expect(repo.listByAccount).toHaveBeenCalledWith( + expect.objectContaining({ accountId: ACCOUNT_ID }), + ) + }) + + it("returns an empty page with a null cursor for an account with no orders", async () => { + const page = await ok(list()) + + expect(page).toEqual({ orders: [], hasNextPage: false, endCursor: null }) + }) + + describe("page size", () => { + it(`defaults to ${GIFT_CARD_ORDERS_DEFAULT_FIRST}`, async () => { + seedOrders(GIFT_CARD_ORDERS_DEFAULT_FIRST + 5) + + const page = await ok(list()) + + expect(page.orders).toHaveLength(GIFT_CARD_ORDERS_DEFAULT_FIRST) + expect(page.hasNextPage).toBe(true) + }) + + it(`clamps first at ${GIFT_CARD_ORDERS_MAX_FIRST} and asks the store for one extra row`, async () => { + seedOrders(GIFT_CARD_ORDERS_MAX_FIRST + 1) + + const page = await ok(list({ first: 10_000 })) + + expect(page.orders).toHaveLength(GIFT_CARD_ORDERS_MAX_FIRST) + expect(page.hasNextPage).toBe(true) + // One row past the page tells us whether another page exists, with no + // count query. + expect(repo.listByAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + limit: GIFT_CARD_ORDERS_MAX_FIRST + 1, + before: undefined, + }) + }) + + it.each([ + ["zero", 0], + ["negative", -3], + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ])("falls back to the default for a %s first", async (_label, first) => { + seedOrders(GIFT_CARD_ORDERS_DEFAULT_FIRST + 1) + + const page = await ok(list({ first })) + + expect(page.orders).toHaveLength(GIFT_CARD_ORDERS_DEFAULT_FIRST) + expect(page.hasNextPage).toBe(true) + }) + + it("floors a fractional first", async () => { + seedOrders(5) + + const page = await ok(list({ first: 2.9 })) + + expect(page.orders).toHaveLength(2) + }) + }) + + describe("hasNextPage and endCursor", () => { + it("is false when exactly `first` orders exist", async () => { + seedOrders(3) + + const page = await ok(list({ first: 3 })) + + expect(page.orders).toHaveLength(3) + expect(page.hasNextPage).toBe(false) + }) + + it("is true when one more order exists, and the extra row is not on the page", async () => { + seedOrders(4) + + const page = await ok(list({ first: 3 })) + + expect(page.orders.map((o) => o.id)).toEqual(["order-3", "order-2", "order-1"]) + expect(page.hasNextPage).toBe(true) + }) + + it("endCursor is the createdAt of the last order on the page", async () => { + seedOrders(4) + + const page = await ok(list({ first: 3 })) + + expect(page.endCursor).toEqual(new Date(NOW_MS + 1 * 60_000)) + }) + + it("passing endCursor back as `after` continues where the page stopped, with no repeat and no gap", async () => { + seedOrders(5) + + const first = await ok(list({ first: 2 })) + expect(first.orders.map((o) => o.id)).toEqual(["order-4", "order-3"]) + expect(first.hasNextPage).toBe(true) + + const second = await ok(list({ first: 2, after: first.endCursor ?? undefined })) + expect(second.orders.map((o) => o.id)).toEqual(["order-2", "order-1"]) + expect(second.hasNextPage).toBe(true) + expect(repo.listByAccount).toHaveBeenLastCalledWith({ + accountId: ACCOUNT_ID, + limit: 3, + before: first.endCursor, + }) + + const third = await ok(list({ first: 2, after: second.endCursor ?? undefined })) + expect(third.orders.map((o) => o.id)).toEqual(["order-0"]) + expect(third.hasNextPage).toBe(false) + expect(third.endCursor).toEqual(new Date(NOW_MS)) + }) + }) + + it("passes a repository fault through as the error", async () => { + const fault = new UnknownRepositoryError("mongo down") + repo.listByAccount.mockResolvedValueOnce(fault) + + expect(await list()).toBe(fault) + }) + + it("rides the stored ciphertext along but never a decrypted claim", async () => { + // A listing is not where claims are read: `getGiftCardOrderForAccount` + // decrypts, this does not. The order object is returned as stored. + seedOrders(1) + + const page = await ok(list()) + + expect(page.orders[0].claimCiphertext).toBe("CIPHERTEXT-0") + expect(page.orders[0]).not.toHaveProperty("claim") + }) +}) diff --git a/test/flash/unit/app/gift-cards/list-products.spec.ts b/test/flash/unit/app/gift-cards/list-products.spec.ts index 313a09b7c..141aad63b 100644 --- a/test/flash/unit/app/gift-cards/list-products.spec.ts +++ b/test/flash/unit/app/gift-cards/list-products.spec.ts @@ -70,6 +70,8 @@ const product = (id: string, over: Partial = {}): GiftCardProdu termsUrl: null, rewardBps: 0, inStock: true, + maxQuantity: 1, + wholeUnitsOnly: false, ...over, }) diff --git a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts index 91d33ce03..6ff4f09eb 100644 --- a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts +++ b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts @@ -2,9 +2,14 @@ import { CouldNotFindWalletFromIdError, IdempotencyKeyReuseError, InvalidIdempotencyKeyError, + RepositoryError, + UnknownRepositoryError, } from "@domain/errors" import { + GiftCardClaimCryptoError, GiftCardIdempotencyKeyReuseError, + GiftCardOrderNotFoundError, + GiftCardOrderStateError, GiftCardInvalidValueError, GiftCardLevelNotEligibleError, GiftCardProductNotAvailableInCountryError, @@ -12,8 +17,9 @@ import { GiftCardsDisabledError, GiftCardVendorRejectedOrderError, GiftCardVendorUnavailableError, + UnknownGiftCardError, } from "@domain/gift-cards" -import { ResourceAttemptsLockServiceError } from "@domain/lock" +import { ResourceAttemptsLockServiceError, UnknownLockServiceError } from "@domain/lock" import { GiftCardPurchaseRateLimiterExceededError } from "@domain/rate-limit/errors" import { ValidationError } from "@domain/shared" import { @@ -39,6 +45,7 @@ const mockPayInvoice = jest.fn() const mockConsumeLimiter = jest.fn() const mockDecodeInvoice = jest.fn() const mockEncrypt = jest.fn() +const mockClaimCryptoReady = jest.fn() const mockNotifyOpsEvent = jest.fn() const mockSendFulfilledPush = jest.fn() const mockAuthorizeSend = jest.fn() @@ -79,6 +86,7 @@ jest.mock("@services/gift-cards/registry", () => ({ })) jest.mock("@services/gift-cards/claim-crypto", () => ({ encryptGiftCardClaim: (...a: unknown[]) => mockEncrypt(...a), + claimCryptoReady: (...a: unknown[]) => mockClaimCryptoReady(...a), })) jest.mock("@app/gift-cards/send-fulfilled-notification", () => ({ sendGiftCardFulfilledNotificationBestEffort: (...a: unknown[]) => @@ -172,6 +180,59 @@ const purchase = (overrides: Record = {}) => }) const opsPhases = () => mockNotifyOpsEvent.mock.calls.map((c) => c[0].phase) +const opsEvent = (phase: string) => + mockNotifyOpsEvent.mock.calls.map((c) => c[0]).find((e) => e.phase === phase) + +/** The first order the fake repo created (purchase creates exactly one). */ +const storedOrder = () => [...repo.store.values()][0] + +/** + * Make the transition INTO `to` fail with `error`, once, leaving every other + * transition real. Targets the bookkeeping step after IBEX answered without + * disturbing CREATED → INVOICE_ISSUED before it. + */ +const failTransitionTo = ( + to: GiftCardOrderStatus, + error: RepositoryError | GiftCardOrderStateError, +) => { + const real = repo.transition.getMockImplementation() + if (!real) throw new Error("fake repo has no transition implementation") + let fired = false + repo.transition.mockImplementation(async (args) => { + if (!fired && args.to === to) { + fired = true + return error + } + return real(args) + }) +} + +/** + * IBEX answers `status`, but by then a concurrent same-key attempt has already + * moved the row (the fake repo IS the shared row). `ref` is what that attempt + * recorded; `null` means this call's own cached replay carried no response. + */ +const concurrentAttemptWrote = ( + status: { value: string }, + to: GiftCardOrderStatus, + ref: string | null, +) => + mockPayInvoice.mockImplementation(async () => { + const order = storedOrder() + const from: GiftCardOrderStatus[] = + to === "PAYMENT_PENDING" + ? ["INVOICE_ISSUED"] + : ["INVOICE_ISSUED", "PAYMENT_PENDING"] + const moved = await repo.transition({ + id: order.id, + from, + to, + reason: "concurrent-attempt", + patch: ref ? { providerPaymentRef: ref, paidSats: 40_100 as Satoshis } : {}, + }) + if (moved instanceof Error) throw moved + return status + }) beforeEach(() => { jest.clearAllMocks() @@ -183,7 +244,12 @@ beforeEach(() => { mockFindAccountById.mockResolvedValue(makeAccount()) mockFindWalletById.mockResolvedValue(makeWallet()) mockResolveCountry.mockResolvedValue("US") - mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockMasterGate.mockReturnValue({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "US", + countryKnown: true, + }) mockGetGiftCardProduct.mockResolvedValue(makeProduct()) mockQuote.mockResolvedValue({ productId: "bitcoinCompany:amazon-us", @@ -209,6 +275,7 @@ beforeEach(() => { mockDecodeInvoice.mockReturnValue(decoded(40_100)) mockPayInvoice.mockResolvedValue(PaymentSendStatus.Success) mockEncrypt.mockReturnValue({ ciphertext: "ENCRYPTED", keyId: "k1" }) + mockClaimCryptoReady.mockReturnValue(true) mockSendFulfilledPush.mockResolvedValue(undefined) }) @@ -251,10 +318,13 @@ describe("purchaseGiftCard", () => { expect(payArgs.senderAccount.id).toBe(ACCOUNT_ID) expect(typeof payArgs.authorize).toBe("function") - expect(mockGetOrder).toHaveBeenCalledWith({ - providerOrderId: "tbc-123", - paymentRequest: PAYMENT_REQUEST, - }) + // The inline poll asks once: the customer is waiting on this response and + // the reconcile worker will ask again in seconds anyway. + expect(mockGetOrder).toHaveBeenCalledTimes(1) + expect(mockGetOrder).toHaveBeenCalledWith( + { providerOrderId: "tbc-123", paymentRequest: PAYMENT_REQUEST }, + { retry: false }, + ) expect(opsPhases()).toEqual(["order-created", "order-paid", "order-fulfilled"]) expect(mockNotifyOpsEvent.mock.calls[1][0]).toMatchObject({ flow: "giftcard", @@ -309,6 +379,38 @@ describe("purchaseGiftCard", () => { expect(res.status).toBe("PAID") }) + describe("claim key", () => { + // The vendor hands back a claim code the moment the invoice is paid. If the + // key that seals it cannot be loaded we would take the money and then be + // unable to store what it bought — so the check runs before any pay. + it("an unloadable claim key fails the order before paying; no IBEX call", async () => { + const fault = new GiftCardClaimCryptoError("no key") + mockClaimCryptoReady.mockReturnValue(fault) + + const res = await purchase() + + expect(res).toBe(fault) + expect(mockPayInvoice).not.toHaveBeenCalled() + expect(mockGetOrder).not.toHaveBeenCalled() + const order = storedOrder() + expect(order.status).toBe("FAILED") + expect(order.failureReason).toBe("claim-key-not-configured") + expect(opsPhases()).toEqual(["order-created", "order-failed"]) + expect(opsEvent("order-failed")).toMatchObject({ + error: "GiftCardClaimCryptoError", + meta: expect.objectContaining({ reason: "claim-key-not-configured" }), + }) + }) + + it("is checked immediately before the pay, after the vendor order exists", async () => { + await purchase() + expect(mockClaimCryptoReady).toHaveBeenCalledTimes(1) + const ready = mockClaimCryptoReady.mock.invocationCallOrder[0] + expect(ready).toBeGreaterThan(mockCreateOrder.mock.invocationCallOrder[0]) + expect(ready).toBeLessThan(mockPayInvoice.mock.invocationCallOrder[0]) + }) + }) + it("releases the reservation once the order row exists", async () => { const res = await purchase() if (res instanceof Error) throw res @@ -353,7 +455,7 @@ describe("purchaseGiftCard", () => { expect(res.providerPaymentRef).toBe("ibex-tx-1") }) - it("stays null when IBEX was not called (replay) or sent no id", async () => { + it("stays null when IBEX answered with no transaction id", async () => { mockPayInvoice.mockImplementation( async (args: { onResponse?: (r: unknown) => void }) => { args.onResponse?.({ transaction: {} }) @@ -362,8 +464,41 @@ describe("purchaseGiftCard", () => { ) const res = await purchase() if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") expect(res.providerPaymentRef).toBeNull() }) + + it("stays null on a cached replay, where onResponse is never invoked", async () => { + // The wrapper returns the cached outcome without running execute, so + // there is no raw 200 to capture an id from. + mockPayInvoice.mockImplementation(async () => ({ value: "pending" })) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(res.providerPaymentRef).toBeNull() + }) + + it("a cached-replay Success never $sets null over the ref a concurrent attempt recorded", async () => { + // Attempt A's send returned Pending with ibex-tx-1 and wrote it. Attempt + // B (replay-resumed from INVOICE_ISSUED) gets the cached Success with no + // onResponse. Its PAID transition must leave A's ref in place. + concurrentAttemptWrote({ value: "success" }, "PAYMENT_PENDING", "ibex-tx-1") + mockGetOrder.mockResolvedValue({ kind: "paidPendingFulfillment" }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + const paidTransition = repo.transition.mock.calls.find((c) => c[0].to === "PAID") + expect(paidTransition?.[0].patch).not.toHaveProperty("providerPaymentRef") + }) + + it("a Failure with no id does not $set null over an existing ref either", async () => { + concurrentAttemptWrote(PaymentSendStatus.Failure, "PAYMENT_PENDING", "ibex-tx-1") + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_FAILED") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) }) describe("idempotency", () => { @@ -377,6 +512,28 @@ describe("purchaseGiftCard", () => { expect(mockPayInvoice).not.toHaveBeenCalled() }) + it("a same-key replay does not consume the purchase budget", async () => { + // Polling an order you already own by re-sending its key is free; the + // budget is for attempts that can reach the vendor. + repo.seed(makeOrder({ status: "PAID", idempotencyKey: "idem-1" })) + await purchase() + expect(mockConsumeLimiter).not.toHaveBeenCalled() + }) + + it("a first attempt is charged to the budget, after the replay lookup and before the gate", async () => { + await purchase() + expect(mockConsumeLimiter).toHaveBeenCalledTimes(1) + expect(mockConsumeLimiter).toHaveBeenCalledWith({ + rateLimitConfig: { key: "gift_card_purchase" }, + keyToConsume: ACCOUNT_ID, + }) + const limiter = mockConsumeLimiter.mock.invocationCallOrder[0] + expect(limiter).toBeGreaterThan( + repo.findByIdempotencyKey.mock.invocationCallOrder[0], + ) + expect(limiter).toBeLessThan(mockMasterGate.mock.invocationCallOrder[0]) + }) + it("refuses the same key with different parameters", async () => { repo.seed(makeOrder({ status: "PAID", idempotencyKey: "idem-1", valueMinor: 5000 })) const res = await purchase() @@ -411,20 +568,63 @@ describe("purchaseGiftCard", () => { expect(repo.create).toHaveBeenCalledTimes(1) }) - it("a create that loses the unique-index race replays the winner", async () => { - repo.create.mockImplementationOnce(async () => { - const winner = makeOrder({ - id: "order-winner" as GiftCardOrderId, - status: "PAID", + describe("a create that loses the unique-index race", () => { + const loseRaceTo = (winner: GiftCardOrder | null) => + repo.create.mockImplementationOnce(async () => { + if (winner) repo.seed(winner) + return new RepositoryError("duplicate key") }) - repo.seed(winner) - return new (jest.requireActual("@domain/errors").RepositoryError)("duplicate key") + + it("replays the winner when its parameters match", async () => { + loseRaceTo(makeOrder({ id: "order-winner" as GiftCardOrderId, status: "PAID" })) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.id).toBe("order-winner") + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockReleaseReservation).toHaveBeenCalledWith(ACCOUNT_ID, "res-1") + }) + + it("refuses a winner with different parameters as a key reuse", async () => { + loseRaceTo( + makeOrder({ + id: "order-winner" as GiftCardOrderId, + status: "PAID", + valueMinor: 5000, + }), + ) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardIdempotencyKeyReuseError) + expect(mockCreateOrder).not.toHaveBeenCalled() + }) + + it("surfaces a create failure with no winner as UnknownGiftCardError, never the repository's duplicate-key error", async () => { + // The duplicate-key error is repository-private: the GraphQL error map + // does not know it and would turn it into a 500. + loseRaceTo(null) + const res = await purchase() + expect(res).toBeInstanceOf(UnknownGiftCardError) + expect(res).not.toBeInstanceOf(RepositoryError) + expect(mockCreateOrder).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ error: "RepositoryError" }), + expect.stringContaining("Could not create"), + ) + }) + + it("surfaces a failed re-read the same way", async () => { + loseRaceTo(null) + // First lookup is the replay check (no order yet); the second is the + // post-race re-read, which is the one that faults here. + repo.findByIdempotencyKey + .mockResolvedValueOnce(new GiftCardOrderNotFoundError()) + .mockResolvedValueOnce(new UnknownRepositoryError("mongo")) + expect(await purchase()).toBeInstanceOf(UnknownGiftCardError) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ reread: "UnknownRepositoryError" }), + expect.stringContaining("Could not create"), + ) }) - const res = await purchase() - if (res instanceof Error) throw res - expect(res.id).toBe("order-winner") - expect(mockCreateOrder).not.toHaveBeenCalled() - expect(mockReleaseReservation).toHaveBeenCalledWith(ACCOUNT_ID, "res-1") }) it("rejects a blank idempotency key", async () => { @@ -509,6 +709,59 @@ describe("purchaseGiftCard", () => { expect(mockPayInvoice).not.toHaveBeenCalled() }) + it("an INVOICE_ISSUED order with no payment request is returned as-is: nothing to pay", async () => { + const existing = issued({ paymentRequest: null }) + const res = await purchase() + expect(res).toBe(existing) + expect(mockPayInvoice).not.toHaveBeenCalled() + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("the resume checks the claim key too: an unloadable key fails the order, no IBEX call", async () => { + const existing = issued() + const fault = new GiftCardClaimCryptoError("no key") + mockClaimCryptoReady.mockReturnValue(fault) + const res = await purchase() + expect(res).toBe(fault) + expect(mockPayInvoice).not.toHaveBeenCalled() + expect(repo.store.get(existing.id)?.status).toBe("FAILED") + expect(repo.store.get(existing.id)?.failureReason).toBe( + "claim-key-not-configured", + ) + }) + + it("a late Success on a row the worker already EXPIRED revives it: EXPIRED -> PAID -> FULFILLED", async () => { + // The send was in flight at IBEX when expiresAt passed and the worker + // expired the row. IBEX then reports Success: the money left, and the + // order must say so rather than lose the payment under EXPIRED. + const existing = issued() + mockPayInvoice.mockImplementation(async () => { + const expired = await repo.transition({ + id: existing.id, + from: ["INVOICE_ISSUED"], + to: "EXPIRED", + reason: "expired", + patch: { failureReason: "expired" }, + }) + if (expired instanceof Error) throw expired + return PaymentSendStatus.Success + }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + expect(res.paidSats).toBe(40_100) + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "CREATED", + "INVOICE_ISSUED", + "EXPIRED", + "PAID", + "FULFILLED", + ]) + expect(res.statusHistory[3].reason).toBe("payment-settled-after-expiry") + expect(opsPhases()).toEqual(["order-paid", "order-fulfilled"]) + expect(opsEvent("paid-not-recorded")).toBeUndefined() + }) + it("does not resume while the kill switch is off: that is new money leaving", async () => { mockConfig = makeGiftCardsConfig({ enabled: false }) const existing = issued() @@ -548,11 +801,16 @@ describe("purchaseGiftCard", () => { } afterEach(expectNoOrderWork) - it("rate limited", async () => { + it("rate limited: refused before the gate, the catalog or the vendor", async () => { mockConsumeLimiter.mockResolvedValue(new GiftCardPurchaseRateLimiterExceededError()) const res = await purchase() expect(res).toBeInstanceOf(GiftCardPurchaseRateLimiterExceededError) - expect(mockFindAccountById).not.toHaveBeenCalled() + // The replay lookup runs first (a replay is free), so the account and + // wallet are read; nothing past the budget is. + expect(repo.findByIdempotencyKey).toHaveBeenCalledTimes(1) + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockGetGiftCardProduct).not.toHaveBeenCalled() + expect(mockQuote).not.toHaveBeenCalled() }) it("a limiter store fault falls through", async () => { @@ -595,6 +853,15 @@ describe("purchaseGiftCard", () => { expect(res).toBeInstanceOf(GiftCardProductNotAvailableInCountryError) }) + it("same provider, but another country's catalog", async () => { + // API.md: a product not sold in the account's country is refused. The + // provider check cannot see that — one provider serves many countries. + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ countryCode: "GB" })) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardProductNotAvailableInCountryError) + expect(mockQuote).not.toHaveBeenCalled() + }) + it("invalid value for the product", async () => { const res = await purchase({ valueMinor: 100 }) // below minValue 500 expect(res).toBeInstanceOf(GiftCardInvalidValueError) @@ -606,6 +873,13 @@ describe("purchaseGiftCard", () => { expect(res).toBeInstanceOf(GiftCardInvalidValueError) }) + it("quantity above the product's own maxQuantity", async () => { + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ maxQuantity: 1 })) + const res = await purchase({ quantity: 2 }) + expect(res).toBeInstanceOf(GiftCardInvalidValueError) + expect(mockQuote).not.toHaveBeenCalled() + }) + it("limits rejection", async () => { mockAuthorize.mockResolvedValue({ authorized: false, @@ -618,6 +892,32 @@ describe("purchaseGiftCard", () => { }) }) + describe("product country", () => { + it("compares normalised codes, so a lower-case catalog row buys fine", async () => { + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ countryCode: "us" })) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + }) + + it("skips the comparison when the account's country is unknown (XX)", async () => { + // We cannot know where the user is; the routed provider's catalog is all + // we have. Refusing would make the feature unusable for anyone without a + // resolvable phone country. + mockMasterGate.mockReturnValue({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "XX", + countryKnown: false, + }) + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ countryCode: "GB" })) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + }) + }) + describe("vendor order", () => { it("vendor rejection -> FAILED, no payment, error returned", async () => { mockCreateOrder.mockResolvedValue( @@ -721,6 +1021,32 @@ describe("purchaseGiftCard", () => { expect(mockNotifyOpsEvent.mock.calls[1][0].meta.reason).toBe("payment-failed") }) + it("Failure whose PAYMENT_FAILED transition faults still returns the order, logged and reported", async () => { + // Nothing left the wallet, so this is not the paid-not-recorded page: the + // client sees the row's true state on its next poll. + mockPayInvoice.mockResolvedValue(PaymentSendStatus.Failure) + failTransitionTo("PAYMENT_FAILED", new UnknownRepositoryError("mongo")) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("INVOICE_ISSUED") + expect(repo.store.get(res.id)?.status).toBe("INVOICE_ISSUED") + expect(opsPhases()).toEqual(["order-created", "order-failed"]) + expect(opsEvent("paid-not-recorded")).toBeUndefined() + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ orderId: res.id, error: "UnknownRepositoryError" }), + expect.stringContaining("PAYMENT_FAILED"), + ) + }) + + it("Failure after a concurrent attempt already recorded an outcome returns that row and does not report twice", async () => { + concurrentAttemptWrote(PaymentSendStatus.Failure, "PAID", "ibex-tx-1") + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + expect(opsPhases()).toEqual(["order-created"]) + }) + describe("errors that PROVE IBEX never accepted the send -> PAYMENT_FAILED", () => { // Only these may tell the client "nothing left the wallet, buy again". it.each([ @@ -788,10 +1114,6 @@ describe("purchaseGiftCard", () => { "CompletedInvoice (already prepared — possibly by our first attempt)", () => new CompletedInvoice(new Error("payment already prepared")), ], - [ - "a busy idempotency lock (concurrent same-key attempt in flight)", - () => new ResourceAttemptsLockServiceError(), - ], ])("%s", async (_label, make) => { const sendError = make() mockPayInvoice.mockResolvedValue(sendError) @@ -831,20 +1153,83 @@ describe("purchaseGiftCard", () => { expect(res.providerPaymentRef).toBe("ibex-tx-1") }) - it("a concurrent attempt that already moved the order on wins", async () => { - // The replay's send hits the busy lock while the first attempt, still - // running, records PAID. The replay must not clobber that. - mockPayInvoice.mockImplementation(async () => { - const [order] = [...repo.store.values()] - await repo.transition({ - id: order.id, - from: ["INVOICE_ISSUED"], - to: "PAID", - reason: "payment-settled", - patch: { paidSats: 40_100 as Satoshis, providerPaymentRef: "ibex-tx-1" }, - }) - return new ResourceAttemptsLockServiceError() + it("a PAYMENT_PENDING transition that faults still returns the order and pages paid-not-recorded", async () => { + // IBEX may have moved money and the row cannot say so. An error here + // would read as "buy again". + mockPayInvoice.mockImplementation( + async (args: { onResponse?: (r: unknown) => void }) => { + args.onResponse?.({ transaction: { id: "ibex-tx-1" } }) + return new IbexError(new Error("connection reset")) + }, + ) + failTransitionTo("PAYMENT_PENDING", new UnknownRepositoryError("mongo")) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("INVOICE_ISSUED") + expect(opsPhases()).toEqual(["order-created", "paid-not-recorded"]) + expect(opsEvent("paid-not-recorded")).toMatchObject({ + flow: "giftcard", + status: "failed", + error: "UnknownRepositoryError", + meta: expect.objectContaining({ + orderId: res.id, + providerPaymentRef: "ibex-tx-1", + intendedStatus: "PAYMENT_PENDING", + }), }) + }) + }) + + describe("a busy idempotency lock (concurrent same-key attempt in flight)", () => { + // IBEX was not asked by THIS call; the attempt holding the lock owns the + // outcome and will record it, ref included. Writing PAYMENT_PENDING here + // would race that transition and could overwrite its ref with null. + it.each([ + [ + "ResourceAttemptsLockServiceError", + () => new ResourceAttemptsLockServiceError(), + ], + ["UnknownLockServiceError", () => new UnknownLockServiceError()], + ])( + "%s with nothing recorded yet returns the row as it stands, writing nothing", + async (_label, make) => { + mockPayInvoice.mockResolvedValue(make()) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("INVOICE_ISSUED") + expect(res.providerPaymentRef).toBeNull() + expect(repo.transition).toHaveBeenCalledTimes(1) // CREATED → INVOICE_ISSUED only + expect(opsPhases()).toEqual(["order-created"]) + expect(mockGetOrder).not.toHaveBeenCalled() + }, + ) + + it("while the first attempt ends Pending with ref ibex-tx-1 -> PAYMENT_PENDING with that ref intact", async () => { + // Before the fix this replay wrote INVOICE_ISSUED → PAYMENT_PENDING with + // a null ref — clobbering the first attempt's outcome and dropping the + // one handle the worker has to re-query IBEX. + concurrentAttemptWrote( + new ResourceAttemptsLockServiceError() as unknown as { value: string }, + "PAYMENT_PENDING", + "ibex-tx-1", + ) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + // Only the concurrent attempt's transition touched the row. + expect( + repo.transition.mock.calls.filter((c) => c[0].to === "PAYMENT_PENDING"), + ).toHaveLength(1) + expect(opsPhases()).toEqual(["order-created"]) + }) + + it("while the first attempt ends PAID -> that PAID row, untouched", async () => { + concurrentAttemptWrote( + new ResourceAttemptsLockServiceError() as unknown as { value: string }, + "PAID", + "ibex-tx-1", + ) const res = await purchase() if (res instanceof Error) throw res expect(res.status).toBe("PAID") @@ -860,6 +1245,107 @@ describe("purchaseGiftCard", () => { expect(mockGetOrder).not.toHaveBeenCalled() expect(opsPhases()).toEqual(["order-created", "payment-pending"]) }) + + describe("Success that the row cannot record (never a bare error once money moved)", () => { + const ibexSuccessWithRef = () => + mockPayInvoice.mockImplementation( + async (args: { onResponse?: (r: unknown) => void }) => { + args.onResponse?.({ transaction: { id: "ibex-tx-1" } }) + return PaymentSendStatus.Success + }, + ) + + it("PAID transition repository fault -> the order as last known, plus a Critical paid-not-recorded page", async () => { + ibexSuccessWithRef() + failTransitionTo("PAID", new UnknownRepositoryError("mongo")) + const res = await purchase() + expect(res).not.toBeInstanceOf(Error) + if (res instanceof Error) return + expect(res.status).toBe("INVOICE_ISSUED") + expect(mockGetOrder).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created", "paid-not-recorded"]) + expect(opsEvent("paid-not-recorded")).toMatchObject({ + flow: "giftcard", + status: "failed", + accountId: ACCOUNT_ID, + error: "UnknownRepositoryError", + meta: expect.objectContaining({ + orderId: res.id, + providerPaymentRef: "ibex-tx-1", + intendedStatus: "PAID", + }), + }) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: res.id, + providerPaymentRef: "ibex-tx-1", + error: "UnknownRepositoryError", + }), + expect.stringContaining("could not record"), + ) + }) + + it.each(["PAID", "FULFILLED"] as const)( + "a concurrent winner already %s -> that row, no error, no page", + async (status) => { + const real = repo.transition.getMockImplementation() + mockPayInvoice.mockImplementation(async () => { + const order = storedOrder() + const paid = await real?.({ + id: order.id, + from: ["INVOICE_ISSUED"], + to: "PAID", + reason: "payment-settled", + patch: { paidSats: 40_100 as Satoshis, providerPaymentRef: "ibex-tx-1" }, + }) + if (status === "FULFILLED" && !(paid instanceof Error)) { + await real?.({ + id: order.id, + from: ["PAID"], + to: "FULFILLED", + reason: "vendor-fulfilled", + patch: { claimCiphertext: "THEIRS", claimKeyId: "k1" }, + }) + } + return PaymentSendStatus.Success + }) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe(status) + expect(res.providerPaymentRef).toBe("ibex-tx-1") + expect(opsEvent("paid-not-recorded")).toBeUndefined() + // The loser neither re-polls the vendor nor re-reports PAID. + expect(mockGetOrder).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["order-created"]) + }, + ) + + it("a row in a state that contradicts Success (PAYMENT_FAILED) -> the order as last known, plus the page", async () => { + concurrentAttemptWrote(PaymentSendStatus.Success, "PAYMENT_FAILED", null) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("INVOICE_ISSUED") + expect(opsEvent("paid-not-recorded")).toMatchObject({ + meta: expect.objectContaining({ + intendedStatus: "PAID", + rowStatus: "PAYMENT_FAILED", + }), + }) + }) + + it("a failed re-read after the lost transition -> the order as last known, plus the page", async () => { + ibexSuccessWithRef() + failTransitionTo("PAID", new GiftCardOrderStateError("raced")) + repo.findById.mockResolvedValueOnce(new UnknownRepositoryError("mongo")) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("INVOICE_ISSUED") + expect(opsEvent("paid-not-recorded")).toMatchObject({ + error: "UnknownRepositoryError", + meta: expect.objectContaining({ rowStatus: "unreadable" }), + }) + }) + }) }) describe("send guard hook", () => { diff --git a/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts index 28d1a721f..55a579170 100644 --- a/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts +++ b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts @@ -82,7 +82,12 @@ beforeEach(() => { mockConsumeLimiter.mockResolvedValue(true) mockFindAccountById.mockResolvedValue(makeAccount()) mockResolveCountry.mockResolvedValue("US") - mockMasterGate.mockReturnValue({ ok: true, providerId: "bitcoinCompany" }) + mockMasterGate.mockReturnValue({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "US", + countryKnown: true, + }) mockGetGiftCardProduct.mockResolvedValue(PRODUCT) mockGetProvider.mockReturnValue({ id: "bitcoinCompany", quote: mockQuote }) mockQuote.mockResolvedValue(QUOTE) @@ -203,6 +208,40 @@ describe("quoteGiftCard", () => { expect(mockQuote).not.toHaveBeenCalled() }) + describe("product country", () => { + // API.md: a product is refused when it is not sold in the account's + // country. The provider check alone cannot see that — the same provider + // serves many countries — so the gated country is compared too. + it("refuses a same-provider product from another country's catalog", async () => { + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ countryCode: "GB" })) + + expect(await quote()).toBeInstanceOf(GiftCardProductNotAvailableInCountryError) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("compares normalised codes, so a lower-case catalog row is not refused", async () => { + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ countryCode: "us" })) + + expect(await quote()).toBe(QUOTE) + }) + + it("skips the comparison when the account's country is unknown (XX)", async () => { + // We cannot know where the user is; the routed provider's catalog is all + // we have, and refusing every product would make the feature unusable + // for anyone without a resolvable phone country. + mockMasterGate.mockReturnValue({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "XX", + countryKnown: false, + }) + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ countryCode: "GB" })) + + expect(await quote()).toBe(QUOTE) + expect(mockQuote).toHaveBeenCalledTimes(1) + }) + }) + it("refuses a sold-out product as not found", async () => { mockGetGiftCardProduct.mockResolvedValue(makeProduct({ inStock: false })) @@ -246,6 +285,14 @@ describe("quoteGiftCard", () => { expect(await quote({ quantity: 11 })).toBeInstanceOf(GiftCardInvalidValueError) expect(mockQuote).not.toHaveBeenCalled() }) + + it("caps quantity at the product's own maxQuantity when that is lower", async () => { + mockGetGiftCardProduct.mockResolvedValue(makeProduct({ maxQuantity: 1 })) + + expect(await quote({ quantity: 2 })).toBeInstanceOf(GiftCardInvalidValueError) + expect(mockQuote).not.toHaveBeenCalled() + expect(await quote({ quantity: 1 })).toBe(QUOTE) + }) }) it("returns the registry's refusal when the gated provider is not enabled", async () => { diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index b869b9dcf..d56399251 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -1,10 +1,15 @@ -import { GiftCardVendorUnavailableError, UnknownGiftCardError } from "@domain/gift-cards" +import { + GiftCardClaimCryptoError, + GiftCardVendorUnavailableError, + UnknownGiftCardError, +} from "@domain/gift-cards" import { IbexError } from "@services/ibex/errors" import { __resetGiftCardReconcileStateForTest, GIFT_CARD_OPEN_STATUSES, GIFT_CARD_PAID_TIMEOUT_MS, + GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS, GIFT_CARD_RECONCILE_LOCK_KEY, hasOpenGiftCardOrders, nextGiftCardPollAt, @@ -23,6 +28,8 @@ import { const mockGetTransactionDetails = jest.fn() const mockFetchAndSettle = jest.fn() +const mockFetchVendorStatus = jest.fn() +const mockSettleFromVendor = jest.fn() const mockNotifyOpsEvent = jest.fn() const mockRedisSet = jest.fn() const mockRedisGet = jest.fn() @@ -48,6 +55,8 @@ jest.mock("@services/ibex/client", () => ({ })) jest.mock("@app/gift-cards/settle-order", () => ({ fetchAndSettle: (...a: unknown[]) => mockFetchAndSettle(...a), + fetchVendorOrderStatus: (...a: unknown[]) => mockFetchVendorStatus(...a), + settleOrderFromVendor: (...a: unknown[]) => mockSettleFromVendor(...a), })) jest.mock("@services/redis", () => ({ redis: { @@ -159,6 +168,14 @@ beforeEach(() => { mockRedisDel.mockResolvedValue(1) mockGetTransactionDetails.mockResolvedValue(new IbexError(new Error("not found"))) mockFetchAndSettle.mockImplementation(async (order: GiftCardOrder) => order) + // The unaccounted-payment path asks the vendor in two halves (it needs the + // vendor's own answer, not just its effect on the order). By default the + // vendor says "still working" and settling delegates to `mockFetchAndSettle`, + // so `vendorFulfils()` drives both paths the same way. + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + mockSettleFromVendor.mockImplementation(async (order: GiftCardOrder) => + mockFetchAndSettle(order), + ) }) afterEach(() => { @@ -202,6 +219,7 @@ describe("reconcileGiftCardOrders", () => { refundRequired: 0, expired: 0, paymentSettled: 0, + finalPollFailed: 0, skipped: "lock-held", }) expect(repo.listByStatus).not.toHaveBeenCalled() @@ -249,42 +267,40 @@ describe("reconcileGiftCardOrders", () => { ) }) - it("INVOICE_ISSUED past expiresAt with no payment record -> EXPIRED", async () => { + it("INVOICE_ISSUED past expiresAt never consults IBEX: no path writes a ref without leaving the state", async () => { + // Every transition that captures an IBEX transaction id also leaves + // INVOICE_ISSUED in the same write, so an INVOICE_ISSUED row has nothing + // to re-read. The vendor is the only party who can vouch for it. repo.seed( makeOrder({ id: "i" as GiftCardOrderId, status: "INVOICE_ISSUED", + providerOrderId: "tbc-i" as GiftCardProviderOrderId, paymentHash: PAYMENT_HASH, - providerPaymentRef: "ibex-tx", expiresAt: at(-1), }), ) const res = await runAt(0) if (res instanceof Error) throw res + expect(mockGetTransactionDetails).not.toHaveBeenCalled() + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) expect(res.expired).toBe(1) - expect(mockGetTransactionDetails).toHaveBeenCalledWith("ibex-tx") expect(repo.store.get("i")?.status).toBe("EXPIRED") }) - it("INVOICE_ISSUED past expiresAt whose payment actually settled -> PAID, then polled", async () => { - mockGetTransactionDetails.mockResolvedValue(ibexTransaction(2)) + it("INVOICE_ISSUED past expiresAt with no vendor order id -> EXPIRED without a vendor call", async () => { repo.seed( makeOrder({ id: "i" as GiftCardOrderId, status: "INVOICE_ISSUED", - paymentHash: PAYMENT_HASH, - providerPaymentRef: "ibex-tx", - invoiceSats: 40_100 as Satoshis, + providerOrderId: null, expiresAt: at(-1), }), ) const res = await runAt(0) if (res instanceof Error) throw res - expect(res.expired).toBe(0) - expect(res.paymentSettled).toBe(1) - expect(repo.store.get("i")?.status).toBe("PAID") - expect(repo.store.get("i")?.paidSats).toBe(40_100) - expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(mockFetchVendorStatus).not.toHaveBeenCalled() + expect(res.expired).toBe(1) }) it("INVOICE_ISSUED past expiresAt with no ref asks the vendor; fulfilled -> FULFILLED, not EXPIRED", async () => { @@ -331,39 +347,74 @@ describe("reconcileGiftCardOrders", () => { expect(repo.store.get("i")?.status).toBe("EXPIRED") }) - it("INVOICE_ISSUED past expiresAt whose payment IBEX reports FAILED is expired without a vendor poll", async () => { - mockGetTransactionDetails.mockResolvedValue(ibexTransaction(3)) + it.each(["failed", "refunded"] as const)( + "INVOICE_ISSUED past expiresAt that the vendor reports %s -> FAILED via the vendor, not EXPIRED", + async (kind) => { + // The vendor's answer is handed to `settleOrderFromVendor`, which + // decides (INVOICE_ISSUED + vendor failed → FAILED, nothing was paid). + mockFetchVendorStatus.mockResolvedValue({ kind, reason: "cancelled" }) + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => { + const failed = { + ...o, + status: "FAILED" as const, + failureReason: `vendor-${kind}`, + } + repo.store.set(o.id, failed) + return failed + }) + const order = repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + providerOrderId: "tbc-i" as GiftCardProviderOrderId, + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockSettleFromVendor).toHaveBeenCalledWith(order, { + kind, + reason: "cancelled", + }) + expect(res.expired).toBe(0) + expect(repo.store.get("i")?.status).toBe("FAILED") + expect(repo.transition).not.toHaveBeenCalled() + }, + ) + + it("INVOICE_ISSUED past expiresAt with the vendor unreachable is expired (the vendor could not vouch for it)", async () => { + mockFetchVendorStatus.mockResolvedValue(new GiftCardVendorUnavailableError()) repo.seed( makeOrder({ id: "i" as GiftCardOrderId, status: "INVOICE_ISSUED", providerOrderId: "tbc-i" as GiftCardProviderOrderId, - paymentHash: PAYMENT_HASH, - providerPaymentRef: "ibex-tx", expiresAt: at(-1), }), ) const res = await runAt(0) if (res instanceof Error) throw res - expect(mockFetchAndSettle).not.toHaveBeenCalled() + expect(mockSettleFromVendor).not.toHaveBeenCalled() expect(res.expired).toBe(1) }) - it("INVOICE_ISSUED past expiresAt with an in-flight payment is not expired", async () => { - mockGetTransactionDetails.mockResolvedValue(ibexTransaction(1)) + it("EXPIRED orders are never listed or polled: their one exit is the purchase path's", async () => { repo.seed( makeOrder({ - id: "i" as GiftCardOrderId, - status: "INVOICE_ISSUED", - paymentHash: PAYMENT_HASH, - providerPaymentRef: "ibex-tx", - expiresAt: at(-1), + id: "x" as GiftCardOrderId, + status: "EXPIRED", + providerOrderId: "tbc-x" as GiftCardProviderOrderId, + expiresAt: at(-HOUR), }), ) const res = await runAt(0) if (res instanceof Error) throw res - expect(res.expired).toBe(0) - expect(repo.store.get("i")?.status).toBe("INVOICE_ISSUED") + expect(res.scanned).toBe(0) + expect(mockFetchVendorStatus).not.toHaveBeenCalled() + expect(mockFetchAndSettle).not.toHaveBeenCalled() + for (const call of repo.listByStatus.mock.calls) { + expect(call[0].statuses).not.toContain("EXPIRED") + } }) it("orders not yet expired are untouched", async () => { @@ -465,13 +516,148 @@ describe("reconcileGiftCardOrders", () => { }) it("a vendor error leaves the order pending for the next run", async () => { - mockFetchAndSettle.mockResolvedValue(new GiftCardVendorUnavailableError()) + mockFetchVendorStatus.mockResolvedValue(new GiftCardVendorUnavailableError()) noRef() const res = await runAt(0) if (res instanceof Error) throw res expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + expect(mockSettleFromVendor).not.toHaveBeenCalled() expect(mockLogger.error).not.toHaveBeenCalled() }) + + it.each(["failed", "refunded"] as const)( + "vendor says %s: left for the payment re-read, never PAYMENT_FAILED on the vendor's word", + async (kind) => { + // `settleOrderFromVendor` leaves a PAYMENT_PENDING order alone on + // failed/refunded (our send may still be in flight); the unresolved + // exit below needs the vendor's positive "awaiting payment", which + // this is not — even long past the grace period. + mockFetchVendorStatus.mockResolvedValue({ kind, reason: "cancelled" }) + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => o) + const order = noRef() + const res = await runAt(GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + 2 * HOUR) + if (res instanceof Error) throw res + expect(mockSettleFromVendor).toHaveBeenCalledWith(order, { + kind, + reason: "cancelled", + }) + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }, + ) + + describe("unresolved: vendor never saw a payment and the invoice is long expired", () => { + // No ref means IBEX never handed back an id, so only the vendor can + // ever resolve this row. Once the invoice has been expired a full day + // and the vendor still says unpaid, no send can settle it: the money + // did not leave. This is the only terminal exit for a ref-less + // PAYMENT_PENDING, which otherwise pins the front of listByStatus. + const awaitingPayment = () => + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) + const pastGrace = GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + 15 * MINUTE + 1 + + it("-> PAYMENT_FAILED with reason payment-unresolved-expired, and an ops event", async () => { + awaitingPayment() + noRef() // expiresAt = NOW + 15m + const res = await runAt(pastGrace) + if (res instanceof Error) throw res + const after = repo.store.get("noref") + expect(after?.status).toBe("PAYMENT_FAILED") + expect(after?.failureReason).toBe("payment-unresolved-expired") + expect(after?.statusHistory.at(-1)?.reason).toBe("payment-unresolved-expired") + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + flow: "giftcard", + phase: "order-failed", + status: "failed", + meta: expect.objectContaining({ + orderId: "noref", + reason: "payment-unresolved-expired", + vendorStatus: "awaitingPayment", + }), + }), + ) + }) + + it("not before expiresAt + 24h: stays PAYMENT_PENDING", async () => { + awaitingPayment() + noRef() + const res = await runAt( + GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + 15 * MINUTE - SECOND, + ) + if (res instanceof Error) throw res + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("not when the vendor says paid-pending-fulfillment: a card may still come", async () => { + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + noRef() + const res = await runAt(pastGrace) + if (res instanceof Error) throw res + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("not when the order HAS an IBEX ref: IBEX, not the vendor, owns that answer", async () => { + awaitingPayment() + repo.seed( + makeOrder({ + id: "ref" as GiftCardOrderId, + status: "PAYMENT_PENDING", + providerOrderId: "tbc-ref" as GiftCardProviderOrderId, + providerPaymentRef: "ibex-tx", + }), + ) + const res = await runAt(pastGrace) + if (res instanceof Error) throw res + expect(repo.store.get("ref")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }) + + it("not when the vendor could not be asked", async () => { + mockFetchVendorStatus.mockResolvedValue(new GiftCardVendorUnavailableError()) + noRef() + const res = await runAt(pastGrace) + if (res instanceof Error) throw res + expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") + }) + }) + }) + + it("a poll that leaves the status unchanged bumps updatedAt so the batch rotates", async () => { + // listByStatus is oldest-updatedAt first; a stuck row that is never + // touched would sit at the front of every run. + const order = pending() + await runAt(5 * SECOND) + expect(repo.touch).toHaveBeenCalledWith("p") + expect(repo.store.get("p")?.updatedAt.getTime()).toBe(NOW_MS + 5 * SECOND) + expect(repo.store.get("p")?.updatedAt.getTime()).toBeGreaterThan( + order.updatedAt.getTime(), + ) + expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") + expect(repo.store.get("p")?.statusHistory).toEqual(order.statusHistory) + }) + + it("a poll that moves the order does not touch it: the transition already moved updatedAt", async () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(3)) + pending() + await runAt(0) + expect(repo.touch).not.toHaveBeenCalled() + }) + + it("a touch failure is logged and does not fail the order", async () => { + repo.touch.mockResolvedValueOnce( + new (jest.requireActual("@domain/errors").RepositoryError)("mongo"), + ) + pending() + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "p" }), + expect.stringContaining("updatedAt"), + ) }) it("an IBEX lookup error falls back to the vendor and leaves the order pending", async () => { @@ -564,10 +750,28 @@ describe("reconcileGiftCardOrders", () => { if (res instanceof Error) throw res expect(res.refundRequired).toBe(1) }) + + it("a poll that leaves the order PAID bumps updatedAt; one that moves it does not", async () => { + const order = paidOrder("a", -10 * SECOND) + await runAt(0) + expect(repo.touch).toHaveBeenCalledWith("a") + expect(repo.store.get("a")?.updatedAt.getTime()).toBeGreaterThan( + order.updatedAt.getTime(), + ) + + repo.touch.mockClear() + mockFetchAndSettle.mockImplementation(async (o: GiftCardOrder) => { + const fulfilled = { ...o, status: "FULFILLED" as const } + repo.store.set(o.id, fulfilled) + return fulfilled + }) + await runAt(20 * SECOND) + expect(repo.touch).not.toHaveBeenCalled() + }) }) describe("24h escalation", () => { - it("PAID for 24h with nothing new at the vendor -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { + it("PAID for 24h with the vendor positively reporting no card -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { const order = paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) const res = await runAt(0) if (res instanceof Error) throw res @@ -625,21 +829,56 @@ describe("reconcileGiftCardOrders", () => { expect(repo.transition).not.toHaveBeenCalled() }) - it("a vendor error on the final poll still escalates, naming the error", async () => { - mockFetchAndSettle.mockResolvedValue(new GiftCardVendorUnavailableError()) - paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) - const res = await runAt(0) - if (res instanceof Error) throw res - expect(res.refundRequired).toBe(1) - expect(repo.store.get("old")?.status).toBe("REFUND_REQUIRED") - expect(mockNotifyOpsEvent).toHaveBeenCalledWith( - expect.objectContaining({ - phase: "refund-required", - meta: expect.objectContaining({ - lastVendorPoll: "GiftCardVendorUnavailableError", - }), - }), + describe("an error on the final poll says nothing about the card: stay PAID, retry next run", () => { + // REFUND_REQUIRED means "money left and no card is coming". A vendor 5xx, + // a claim-key fault (the card IS issued; only our storage failed) or a + // repository error cannot establish that, and escalating on one would + // refund a delivered card. + it.each([ + ["a vendor 5xx", () => new GiftCardVendorUnavailableError()], + ["a claim-key fault", () => new GiftCardClaimCryptoError("no key")], + [ + "a repository fault", + () => + new (jest.requireActual("@domain/errors").UnknownRepositoryError)("mongo"), + ], + ])( + "%s at 24h keeps the order PAID, counted and logged at warn", + async (_label, make) => { + const error = make() + mockFetchAndSettle.mockResolvedValue(error) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(res.refundRequired).toBe(0) + expect(res.finalPollFailed).toBe(1) + expect(repo.store.get("old")?.status).toBe("PAID") + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ phase: "refund-required" }), + ) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "old", error: error.constructor.name }), + expect.stringContaining("keeping PAID"), + ) + expect(mockLogger.error).not.toHaveBeenCalled() + // ...and the row is touched so it does not pin the front of the batch. + expect(repo.touch).toHaveBeenCalledWith("old") + }, ) + + it("retries on the next run and escalates once the vendor answers 'still PAID'", async () => { + mockFetchAndSettle.mockResolvedValueOnce(new GiftCardVendorUnavailableError()) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + const first = await runAt(0) + if (first instanceof Error) throw first + expect(repo.store.get("old")?.status).toBe("PAID") + + const second = await runAt(15 * MINUTE) + if (second instanceof Error) throw second + expect(second.refundRequired).toBe(1) + expect(repo.store.get("old")?.status).toBe("REFUND_REQUIRED") + }) }) it("one second short of 24h still polls", async () => { diff --git a/test/flash/unit/app/gift-cards/settle-order.spec.ts b/test/flash/unit/app/gift-cards/settle-order.spec.ts index 324bd8f00..cab3fe52f 100644 --- a/test/flash/unit/app/gift-cards/settle-order.spec.ts +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -43,7 +43,11 @@ jest.mock("@services/tracing", () => ({ recordExceptionInCurrentSpan: jest.fn(), })) -import { fetchAndSettle, settleOrderFromVendor } from "@app/gift-cards/settle-order" +import { + fetchAndSettle, + fetchVendorOrderStatus, + settleOrderFromVendor, +} from "@app/gift-cards/settle-order" import { allMockCallText, @@ -101,7 +105,8 @@ describe("settleOrderFromVendor", () => { expect(res.claimCiphertext).toBe("ENCRYPTED") expect(res.claimKeyId).toBe("k1") expect(res.fulfilledAt).toBeInstanceOf(Date) - expect(mockEncrypt).toHaveBeenCalledWith(CLAIM) + // Sealed to THIS order: the ciphertext will not open on any other row. + expect(mockEncrypt).toHaveBeenCalledWith(CLAIM, { orderId: order.id }) expect(opsPhases()).toEqual(["order-fulfilled"]) expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ @@ -189,15 +194,53 @@ describe("settleOrderFromVendor", () => { expect(res.paidSats).toBe(40_000) }) - it("a terminal non-paid order that the vendor says is fulfilled pages", async () => { - const order = repo.seed(makeOrder({ status: "PAYMENT_FAILED" })) + it("EXPIRED -> PAID -> FULFILLED when a send still in flight at expiry settled at the vendor", async () => { + // The worker expired the row while the pay was in flight at IBEX; the + // vendor then saw the payment and shipped. The money left: EXPIRED → PAID + // exists for exactly this, and the card goes to the customer. + const order = repo.seed( + makeOrder({ + status: "EXPIRED", + failureReason: "expired", + invoiceSats: 40_100 as Satoshis, + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + expiresAt: new Date(NOW_MS - 60_000), + }), + ) const res = await settleOrderFromVendor(order, FULFILLED) - expect(res).toBeInstanceOf(GiftCardOrderStateError) - expect(mockEncrypt).not.toHaveBeenCalled() - expect(opsPhases()).toEqual(["vendor-fulfilled-unexpected"]) - expect(mockNotifyOpsEvent.mock.calls[0][0].status).toBe("failed") + if (res instanceof Error) throw res + expect(res.status).toBe("FULFILLED") + expect(res.paidSats).toBe(40_100) + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "EXPIRED", + "PAID", + "FULFILLED", + ]) + expect(res.statusHistory[1].reason).toBe("vendor-reported-fulfilled") + expect(opsPhases()).toEqual(["order-paid", "order-fulfilled"]) + expect(mockSendFulfilledPush).toHaveBeenCalledTimes(1) }) + it.each(["FAILED", "PAYMENT_FAILED", "REFUND_REQUIRED"] as const)( + "a %s order that the vendor says is fulfilled pages and is not moved", + async (status) => { + // Someone paid for a card; our records say not us. The only terminal + // state with a way back is EXPIRED (above); these stay put and page. + const order = repo.seed(makeOrder({ status })) + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).toBeInstanceOf(GiftCardOrderStateError) + expect(repo.transition).not.toHaveBeenCalled() + expect(repo.store.get(order.id)?.status).toBe(status) + expect(mockEncrypt).not.toHaveBeenCalled() + expect(mockSendFulfilledPush).not.toHaveBeenCalled() + expect(opsPhases()).toEqual(["vendor-fulfilled-unexpected"]) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + status: "failed", + meta: expect.objectContaining({ orderStatus: status }), + }) + }, + ) + it("an encryption failure leaves the order PAID and pages", async () => { mockEncrypt.mockReturnValue(new GiftCardClaimCryptoError("no key")) const order = paidOrder() @@ -226,6 +269,39 @@ describe("settleOrderFromVendor", () => { expect(res.claimCiphertext).toBe("THEIRS") // The loser does not notify twice. expect(mockSendFulfilledPush).not.toHaveBeenCalled() + expect(opsPhases()).toEqual([]) + }) + + it("losing the FULFILLED race to anything but FULFILLED returns the state error", async () => { + // The re-read is only a winner if it says FULFILLED. A row another + // settler moved to REFUND_REQUIRED in the meantime is not "our" success + // and must not be handed back as the settled order. + const order = paidOrder() + const realTransition = repo.transition.getMockImplementation() + repo.transition.mockImplementationOnce(async (args) => { + await realTransition?.({ + ...args, + to: "REFUND_REQUIRED", + patch: { failureReason: "vendor-refunded: raced" }, + }) + return new GiftCardOrderStateError("raced") + }) + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).toBeInstanceOf(GiftCardOrderStateError) + expect(repo.store.get(order.id)?.status).toBe("REFUND_REQUIRED") + expect(mockSendFulfilledPush).not.toHaveBeenCalled() + expect(opsPhases()).toEqual([]) + }) + + it("returns the re-read fault when the race re-read itself fails", async () => { + const order = paidOrder() + repo.transition.mockResolvedValueOnce(new GiftCardOrderStateError("raced")) + repo.findById.mockResolvedValueOnce( + new (jest.requireActual("@domain/errors").UnknownRepositoryError)("mongo"), + ) + const res = await settleOrderFromVendor(order, FULFILLED) + expect(res).toBeInstanceOf(GiftCardOrderStateError) + expect(mockSendFulfilledPush).not.toHaveBeenCalled() }) }) @@ -263,19 +339,44 @@ describe("settleOrderFromVendor", () => { expect(opsPhases()).toEqual(["order-failed"]) }) - it("failed on PAYMENT_PENDING is left for the payment re-read", async () => { - const order = repo.seed(makeOrder({ status: "PAYMENT_PENDING" })) - const res = await settleOrderFromVendor(order, { kind: "failed", reason: "x" }) - expect(res).toBe(order) - expect(repo.transition).not.toHaveBeenCalled() - expect(mockLogger.warn).toHaveBeenCalled() + it.each(["failed", "refunded"] as const)( + "%s on PAYMENT_PENDING is left for the payment re-read", + async (kind) => { + // Our payment may still be in flight. Whether it settled decides + // PAID→REFUND_REQUIRED or PAYMENT_FAILED; that is the worker's IBEX + // re-read, not the vendor's word. + const order = repo.seed(makeOrder({ status: "PAYMENT_PENDING" })) + const res = await settleOrderFromVendor(order, { kind, reason: "x" }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ orderId: order.id, vendorStatus: kind }), + expect.stringContaining("payment is pending"), + ) + }, + ) + + it("refunded on CREATED -> FAILED (nothing was paid)", async () => { + const order = repo.seed(makeOrder({ status: "CREATED" })) + const res = await settleOrderFromVendor(order, { kind: "refunded", reason: "x" }) + if (res instanceof Error) throw res + expect(res.status).toBe("FAILED") + expect(res.failureReason).toBe("vendor-refunded: x") }) - it("refunded on an already-terminal order is a no-op", async () => { - const order = repo.seed(makeOrder({ status: "REFUND_REQUIRED" })) + it.each([ + "FULFILLED", + "FAILED", + "PAYMENT_FAILED", + "EXPIRED", + "REFUND_REQUIRED", + ] as const)("refunded on an already-terminal %s order is a no-op", async (status) => { + const order = repo.seed(makeOrder({ status })) const res = await settleOrderFromVendor(order, { kind: "refunded", reason: "x" }) expect(res).toBe(order) expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() }) }) @@ -292,16 +393,57 @@ describe("settleOrderFromVendor", () => { }) }) +describe("fetchVendorOrderStatus", () => { + it("asks the order's provider by providerOrderId + paymentRequest and returns the answer untouched", async () => { + const order = paidOrder() + const res = await fetchVendorOrderStatus(order) + expect(mockGetProvider).toHaveBeenCalledWith("bitcoinCompany") + expect(mockGetOrder).toHaveBeenCalledWith( + { providerOrderId: "tbc-123", paymentRequest: "lnbc1..." }, + undefined, + ) + expect(res).toBe(FULFILLED) + // Reading is not settling. + expect(repo.transition).not.toHaveBeenCalled() + expect(mockEncrypt).not.toHaveBeenCalled() + }) + + it("passes the retry option through to the adapter", async () => { + await fetchVendorOrderStatus(paidOrder(), { retry: false }) + expect(mockGetOrder).toHaveBeenCalledWith(expect.anything(), { retry: false }) + }) + + it("returns the provider lookup error", async () => { + mockGetOrder.mockResolvedValue(new GiftCardVendorUnavailableError()) + expect(await fetchVendorOrderStatus(paidOrder())).toBeInstanceOf( + GiftCardVendorUnavailableError, + ) + }) + + it("refuses an order with no provider order id", async () => { + const res = await fetchVendorOrderStatus(paidOrder({ providerOrderId: null })) + expect(res).toBeInstanceOf(GiftCardOrderStateError) + expect(mockGetOrder).not.toHaveBeenCalled() + }) +}) + describe("fetchAndSettle", () => { it("asks the order's provider by providerOrderId + paymentRequest and settles", async () => { const order = paidOrder() const res = await fetchAndSettle(order) if (res instanceof Error) throw res expect(mockGetProvider).toHaveBeenCalledWith("bitcoinCompany") - expect(mockGetOrder).toHaveBeenCalledWith({ - providerOrderId: "tbc-123", - paymentRequest: "lnbc1...", - }) + expect(mockGetOrder).toHaveBeenCalledWith( + { providerOrderId: "tbc-123", paymentRequest: "lnbc1..." }, + undefined, + ) + expect(res.status).toBe("FULFILLED") + }) + + it("hands the retry option to the adapter: the purchase path's inline poll asks once", async () => { + const res = await fetchAndSettle(paidOrder(), { retry: false }) + if (res instanceof Error) throw res + expect(mockGetOrder).toHaveBeenCalledWith(expect.anything(), { retry: false }) expect(res.status).toBe("FULFILLED") }) diff --git a/test/flash/unit/app/gift-cards/sync-catalog.spec.ts b/test/flash/unit/app/gift-cards/sync-catalog.spec.ts index b3072d0fa..5bd46854b 100644 --- a/test/flash/unit/app/gift-cards/sync-catalog.spec.ts +++ b/test/flash/unit/app/gift-cards/sync-catalog.spec.ts @@ -73,6 +73,8 @@ const product = ( termsUrl: null, rewardBps: 0, inStock: true, + maxQuantity: 1, + wholeUnitsOnly: false, }) const provider = ( @@ -172,6 +174,27 @@ describe("syncGiftCardCatalogForProvider", () => { ]) }) + it("treats an empty catalog as a failed sync: nothing written, failure event emitted", async () => { + // A vendor that answers 200 with zero rows is a failed pull, not a catalog. + // Writing it would blank the countries index and every listing until the + // next sync; the last good catalog must keep serving instead. + const tbc = provider("bitcoinCompany", jest.fn().mockResolvedValue([])) + + const res = await syncGiftCardCatalogForProvider(tbc) + + expect(res).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(mockWrite).not.toHaveBeenCalled() + expect(opsEvents()).toEqual([ + { + flow: "giftcard", + phase: "catalog-sync-failed", + status: "failed", + error: "GiftCardVendorUnavailableError", + meta: { providerId: "bitcoinCompany" }, + }, + ]) + }) + it("turns an adapter that THROWS into UnknownGiftCardError instead of propagating", async () => { // The port contract is return-not-throw; a vendor SDK bug that throws anyway // must still surface as one provider's failure, not as a crash of the run. @@ -394,6 +417,17 @@ describe("syncGiftCardCatalogs", () => { expect(mockRedisDel).toHaveBeenCalledWith(GIFT_CARD_CATALOG_SYNC_MARKER_KEY) }) + it("frees the marker when the only provider returned an empty catalog", async () => { + const tbc = provider("bitcoinCompany", jest.fn().mockResolvedValue([])) + mockEnabledProviders.mockReturnValue([tbc]) + + const summaries = await syncGiftCardCatalogs({ minIntervalSeconds: INTERVAL }) + + expect(summaries).toEqual([]) + expect(mockWrite).not.toHaveBeenCalled() + expect(mockRedisDel).toHaveBeenCalledWith(GIFT_CARD_CATALOG_SYNC_MARKER_KEY) + }) + it("does not touch the marker when the interval is not requested", async () => { mockEnabledProviders.mockReturnValue([]) diff --git a/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts b/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts index b4aa42320..e8c186fae 100644 --- a/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts +++ b/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts @@ -135,13 +135,37 @@ describe("payLnInvoiceViaIbex", () => { expect(mockPayInvoice).not.toHaveBeenCalled() }) - it("a replayed result never calls IBEX or onResponse", async () => { - mockWithPaymentIdempotency.mockResolvedValue({ value: "success" }) + it("a replayed result never calls IBEX or onResponse: both live inside execute", async () => { + // A wrapper that caches by key, like the real one: the first call runs + // `execute`, the second returns the cached result without it. If this + // module called IBEX or `onResponse` anywhere but inside `execute`, the + // second call would show a second IBEX call or a second onResponse. + const cache = new Map() + mockWithPaymentIdempotency.mockImplementation(async (args: WrapperArgs) => { + const cached = cache.get(args.idempotencyKey) + if (cached !== undefined) return cached + const authorized = await args.authorize() + if (authorized instanceof Error) return authorized + const result = await args.execute() + cache.set(args.idempotencyKey, result) + return result + }) const onResponse = jest.fn() - const res = await pay({ onResponse }) - expect(res).toEqual({ value: "success" }) - expect(mockPayInvoice).not.toHaveBeenCalled() - expect(onResponse).not.toHaveBeenCalled() + + const first = await pay({ onResponse }) + expect(first).toBe(PaymentSendStatus.Success) + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + expect(onResponse).toHaveBeenCalledTimes(1) + + const replay = await pay({ onResponse }) + expect(replay).toBe(PaymentSendStatus.Success) + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + expect(onResponse).toHaveBeenCalledTimes(1) + + // A different key is a different payment and does execute. + await pay({ onResponse, idempotencyKey: "giftcard:order-2" }) + expect(mockPayInvoice).toHaveBeenCalledTimes(2) + expect(onResponse).toHaveBeenCalledTimes(2) }) }) }) diff --git a/test/flash/unit/config/rate-limits.spec.ts b/test/flash/unit/config/rate-limits.spec.ts index bab8d9afe..fbaea4bfc 100644 --- a/test/flash/unit/config/rate-limits.spec.ts +++ b/test/flash/unit/config/rate-limits.spec.ts @@ -44,6 +44,36 @@ describe("getRequestCodeBlockedCountryPerIpLimits", () => { }) }) +describe("getGiftCardPurchaseAttemptLimits", () => { + // Hardcoded in src/config/yaml.ts, not in the yaml, so nothing else pins it: + // the purchase spec mocks the limiter and the error map does not care about + // the numbers. Charged once per mutation, before any vendor or store call, + // so it is the only thing that bounds a client looping on a refused request. + // + // points: 10/min — a customer retrying a timed-out call with the SAME key + // replays and does not spend a point (purchase-gift-card.ts), + // so 10 fresh attempts a minute is already far beyond honest + // use. Each fresh attempt is a live vendor quote + createOrder. + // blockDuration: 5 min — long enough to stop a looping client, short enough + // that a customer who hit it can still buy the card they want. + it("bounds fresh purchase attempts at 10/min with a 5 minute block", () => { + expect(getGiftCardPurchaseAttemptLimits()).toEqual({ + points: 10, + duration: 60, + blockDuration: 300, + }) + }) + + it("is wired into RateLimitConfig under its own prefix and error, with those numbers", () => { + expect(RateLimitConfig.giftCardPurchase).toEqual({ + key: "gift_card_purchase", + limits: { points: 10, duration: 60, blockDuration: 300 }, + error: GiftCardPurchaseRateLimiterExceededError, + }) + expect(RateLimitPrefix.giftCardPurchase).toBe("gift_card_purchase") + }) +}) + describe("getGiftCardQuoteAttemptLimits", () => { // Hardcoded like the purchase budget beside it — not in the yaml — so nothing // else in the suite pins these; the app spec mocks the whole registry. @@ -71,9 +101,11 @@ describe("getGiftCardQuoteAttemptLimits", () => { }) it("is wired into RateLimitConfig under its own prefix and error, apart from the purchase", () => { + // Literal numbers, not the getter: a config that restates itself would + // stay green if both sides drifted together. expect(RateLimitConfig.giftCardQuote).toEqual({ - key: RateLimitPrefix.giftCardQuote, - limits: getGiftCardQuoteAttemptLimits(), + key: "gift_card_quote", + limits: { points: 30, duration: 60, blockDuration: 300 }, error: GiftCardQuoteRateLimiterExceededError, }) expect(RateLimitPrefix.giftCardQuote).toBe("gift_card_quote") diff --git a/test/flash/unit/domain/gift-cards/primitives.spec.ts b/test/flash/unit/domain/gift-cards/primitives.spec.ts new file mode 100644 index 000000000..45186d921 --- /dev/null +++ b/test/flash/unit/domain/gift-cards/primitives.spec.ts @@ -0,0 +1,180 @@ +import { + GIFT_CARD_MAX_QUANTITY, + GiftCardInvalidValueError, + checkedGiftCardQuantity, + checkedGiftCardValue, +} from "@domain/gift-cards" + +const fixedProduct = (over: Partial = {}): GiftCardProduct => ({ + id: "bitcoinCompany:fixed" as GiftCardProductId, + providerId: "bitcoinCompany", + providerProductId: "fixed", + name: "Fixed Card", + brand: "Fixed", + countryCode: "US", + currency: "USD", + denominationType: "fixed", + denominations: [1000, 2500, 5000], + minValue: null, + maxValue: null, + isOpenLoop: false, + categories: [], + logoUrl: null, + termsUrl: null, + rewardBps: 0, + inStock: true, + maxQuantity: 1, + wholeUnitsOnly: false, + ...over, +}) + +const variableProduct = (over: Partial = {}): GiftCardProduct => + fixedProduct({ + id: "bitcoinCompany:variable" as GiftCardProductId, + providerProductId: "variable", + denominationType: "variable", + denominations: [], + minValue: 500, + maxValue: 50_000, + ...over, + }) + +const messageOf = (result: number | GiftCardInvalidValueError): string => + result instanceof Error ? result.message : `ok:${result}` + +describe("checkedGiftCardQuantity", () => { + it("caps Flash-wide at 10", () => { + expect(GIFT_CARD_MAX_QUANTITY).toBe(10) + }) + + it.each<[number, number]>([ + // [quantity, product.maxQuantity] + [1, 1], + [1, 10], + [10, 10], + [3, 3], + // Vendor cap above Flash's ceiling: Flash's ceiling is what is allowed. + [10, 50], + // A malformed vendor cap is treated as single-card, never trusted upward. + [1, 0], + [1, -5], + [1, 2.5], + [1, Number.NaN], + ])("accepts quantity %p with maxQuantity %p", (quantity, maxQuantity) => { + expect(checkedGiftCardQuantity(quantity, maxQuantity)).toBe(quantity) + }) + + it.each<[number, number, string]>([ + // [quantity, product.maxQuantity, error message] + [2, 1, "This card can only be bought one at a time"], + [11, 10, "Quantity must be between 1 and 10"], + [4, 3, "Quantity must be between 1 and 3"], + // Vendor cap above Flash's ceiling: Flash's ceiling wins. + [11, 50, "Quantity must be between 1 and 10"], + // Below 1, fractional, or not a number: never valid. + [0, 10, "Quantity must be between 1 and 10"], + [-1, 10, "Quantity must be between 1 and 10"], + [1.5, 10, "Quantity must be between 1 and 10"], + [Number.NaN, 10, "Quantity must be between 1 and 10"], + [Number.POSITIVE_INFINITY, 10, "Quantity must be between 1 and 10"], + // A malformed vendor cap is treated as single-card, never trusted upward. + [2, 0, "This card can only be bought one at a time"], + [2, 2.5, "This card can only be bought one at a time"], + ])("rejects quantity %p with maxQuantity %p: %p", (quantity, maxQuantity, message) => { + const result = checkedGiftCardQuantity(quantity, maxQuantity) + expect(result).toBeInstanceOf(GiftCardInvalidValueError) + expect(messageOf(result)).toBe(message) + }) +}) + +describe("checkedGiftCardValue", () => { + describe("whole-unit-only cards", () => { + const whole = variableProduct({ wholeUnitsOnly: true }) + + it.each([500, 1000, 2500, 50_000])( + "accepts %p minor (a whole unit)", + (valueMinor) => { + expect(checkedGiftCardValue(whole, valueMinor)).toBe(valueMinor) + }, + ) + + it.each([1050, 1001, 1099, 2599])("rejects %p minor (has cents)", (valueMinor) => { + const result = checkedGiftCardValue(whole, valueMinor) + expect(result).toBeInstanceOf(GiftCardInvalidValueError) + expect(messageOf(result)).toBe("This card only accepts whole-unit amounts") + }) + + it("still applies the range rules to whole-unit values", () => { + expect(messageOf(checkedGiftCardValue(whole, 400))).toBe( + "Gift card value is below the minimum for this card", + ) + expect(messageOf(checkedGiftCardValue(whole, 50_100))).toBe( + "Gift card value is above the maximum for this card", + ) + }) + + it("accepts cents on a card that is not whole-units-only", () => { + expect(checkedGiftCardValue(variableProduct(), 1050)).toBe(1050) + expect(checkedGiftCardValue(variableProduct(), 1001)).toBe(1001) + }) + }) + + describe("fixed denominations", () => { + it("accepts a listed denomination", () => { + expect(checkedGiftCardValue(fixedProduct(), 2500)).toBe(2500) + }) + + it("rejects a value that is not one of the denominations", () => { + expect(messageOf(checkedGiftCardValue(fixedProduct(), 2000))).toBe( + "Gift card value must be one of the listed denominations", + ) + }) + + it("rejects a denomination-shaped value with cents on a whole-units-only fixed card", () => { + // Mappers set false for fixed cards; if one ever set true, cents are still refused. + expect( + messageOf( + checkedGiftCardValue( + fixedProduct({ denominations: [1050], wholeUnitsOnly: true }), + 1050, + ), + ), + ).toBe("This card only accepts whole-unit amounts") + }) + }) + + describe("variable range", () => { + it("accepts the bounds and anything between", () => { + expect(checkedGiftCardValue(variableProduct(), 500)).toBe(500) + expect(checkedGiftCardValue(variableProduct(), 12_345)).toBe(12_345) + expect(checkedGiftCardValue(variableProduct(), 50_000)).toBe(50_000) + }) + + it("rejects below the minimum and above the maximum", () => { + expect(messageOf(checkedGiftCardValue(variableProduct(), 499))).toBe( + "Gift card value is below the minimum for this card", + ) + expect(messageOf(checkedGiftCardValue(variableProduct(), 50_001))).toBe( + "Gift card value is above the maximum for this card", + ) + }) + + it("treats a null bound as unbounded on that side", () => { + expect(checkedGiftCardValue(variableProduct({ minValue: null }), 1)).toBe(1) + expect(checkedGiftCardValue(variableProduct({ maxValue: null }), 10_000_000)).toBe( + 10_000_000, + ) + }) + }) + + it.each([0, -100, 12.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects %p before any product rule is consulted", + (valueMinor) => { + for (const product of [fixedProduct(), variableProduct({ wholeUnitsOnly: true })]) { + expect(messageOf(checkedGiftCardValue(product, valueMinor))).toBe( + "Gift card value must be a positive whole amount", + ) + } + }, + ) +}) diff --git a/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts index 247a22110..86a563359 100644 --- a/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts @@ -176,33 +176,52 @@ describe("giftCardPurchase resolver", () => { }) describe("master gate", () => { - it("returns GIFT_CARDS_DISABLED in the payload when the rail is off, without purchasing", async () => { + // The resolver does NOT run the deploy-level gate. purchaseGiftCard gates + // AFTER its same-key replay lookup, so a retry of a timed-out purchase finds + // its order even once the kill switch is off. A gate here would answer that + // retry with GIFT_CARDS_DISABLED and make the app layer's branch dead code. + it("is not run here: a same-key replay while the rail is off still returns the existing order", async () => { mockMasterGate.mockReturnValue({ ok: false, error: new GiftCardsDisabledError() }) + const existing = makeOrder({ + status: "PAID", + paidSats: 40_000 as Satoshis, + providerPaymentRef: "ibex-tx-1", + }) + mockPurchaseGiftCard.mockResolvedValue(existing) const result = await resolve() - expect(result.errors).toHaveLength(1) - expect(result.errors[0].code).toBe("GIFT_CARDS_DISABLED") - expect(result.order).toBeUndefined() - expect(mockPurchaseGiftCard).not.toHaveBeenCalled() + expect(mockMasterGate).not.toHaveBeenCalled() + expect(mockResolveCountry).not.toHaveBeenCalled() + expect(mockPurchaseGiftCard).toHaveBeenCalledTimes(1) + expect(result.errors).toEqual([]) + expect(result.order).toMatchObject({ + id: existing.id, + status: "PAID", + paidSats: 40_000, + claim: null, + }) }) - it("gates on the calling account's country", async () => { - await resolve() + it("relays the app layer's GIFT_CARDS_DISABLED for a FRESH purchase while the rail is off", async () => { + // Same switch, no existing order: the app layer's gate (which runs after + // the replay lookup) is the one that refuses, and it reaches the wire. + mockPurchaseGiftCard.mockResolvedValue(new GiftCardsDisabledError()) - expect(mockResolveCountry).toHaveBeenCalledWith(ctx.domainAccount) - expect(mockMasterGate).toHaveBeenCalledWith("JM") + const result = await resolve() + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe("GIFT_CARDS_DISABLED") + expect(result.order).toBeUndefined() }) - it("returns GIFT_CARD_PROVIDER_UNAVAILABLE when no provider serves the country", async () => { - mockMasterGate.mockReturnValue({ - ok: false, - error: new GiftCardProviderUnavailableError(), - }) + it("relays GIFT_CARD_PROVIDER_UNAVAILABLE from the app layer when no provider serves the country", async () => { + mockPurchaseGiftCard.mockResolvedValue(new GiftCardProviderUnavailableError()) const result = await resolve() expect(result.errors[0].code).toBe("GIFT_CARD_PROVIDER_UNAVAILABLE") + expect(result.order).toBeUndefined() }) }) @@ -252,9 +271,11 @@ describe("giftCardPurchase resolver", () => { }) describe("error mapping", () => { - // Every ApplicationError the use case can return, and the code the client - // must see for it. A new error class that is not in the error map would - // throw `assertUnreachable` here instead of reaching the customer as a 500. + // Every ApplicationError the use case can return BEFORE any payment, and the + // code the client must see for it. A new error class that is not in the + // error map would throw `assertUnreachable` here instead of reaching the + // customer as a 500. Once IBEX has answered, purchaseGiftCard never returns + // a bare error — see "after IBEX has answered" under success. const CASES: Array<[string, Error, string]> = [ ["disabled", new GiftCardsDisabledError(), "GIFT_CARDS_DISABLED"], [ @@ -333,6 +354,54 @@ describe("giftCardPurchase resolver", () => { }) describe("success", () => { + describe("after IBEX has answered, the order comes back — never a bare error", () => { + // Once the send reached IBEX, purchaseGiftCard returns the order in + // whatever state bookkeeping reached: PAID, or PAYMENT_PENDING with or + // without a transaction id. A lost PAID write is a Critical ops event in + // the app layer (`paid-not-recorded`), not an error, because an error + // payload would read as "nothing happened, buy again" for money that + // has already left the wallet. The payload carries the order, `claim` + // is null (nothing to decrypt yet), and `errors` is empty so the client + // polls giftCardOrder instead of retrying with a fresh key. + const ROWS: Array<[string, GiftCardOrder]> = [ + [ + "PAID with the IBEX transaction id", + makeOrder({ + status: "PAID", + paidSats: 40_000 as Satoshis, + providerPaymentRef: "ibex-tx-1", + }), + ], + [ + "PAYMENT_PENDING with the IBEX transaction id", + makeOrder({ status: "PAYMENT_PENDING", providerPaymentRef: "ibex-tx-1" }), + ], + [ + "PAYMENT_PENDING with no transaction id (send errored without a verdict)", + makeOrder({ status: "PAYMENT_PENDING", providerPaymentRef: null }), + ], + ] + + it.each(ROWS)( + "carries a %s order with claim null and no errors", + async (_label, order) => { + mockPurchaseGiftCard.mockResolvedValue(order) + + const result = await resolve() + + expect(result.errors).toEqual([]) + expect(result.order).toMatchObject({ + id: order.id, + status: order.status, + paidSats: order.paidSats, + claim: null, + }) + // Nothing to decrypt before FULFILLED: the owner-scoped read is skipped. + expect(mockGetGiftCardOrderForAccount).not.toHaveBeenCalled() + }, + ) + }) + it("returns the order, without a claim, while it is still in flight", async () => { const order = makeOrder({ status: "PAYMENT_PENDING" }) mockPurchaseGiftCard.mockResolvedValue(order) diff --git a/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts b/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts index a90d0f53c..6af7e5f49 100644 --- a/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts +++ b/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts @@ -1,6 +1,10 @@ // jest.mock calls are hoisted before imports import GlobalsQuery from "@graphql/public/root/query/globals" +import { + __resetGiftCardProvidersForTest, + registerGiftCardProvider, +} from "@services/gift-cards/registry" import { makeGiftCardsConfig } from "test/flash/unit/app/gift-cards/fixtures" @@ -39,13 +43,37 @@ const providersOff = () => { } } +// The flag is "registered AND enabled": config alone must not advertise a +// provider no adapter backs (every gift-card field would then fail). The +// registry is a module-level map, so a stub adapter is registered per test. +const stubProvider = (id: GiftCardProviderId): IGiftCardProvider => + ({ id }) as unknown as IGiftCardProvider + +beforeEach(() => { + __resetGiftCardProvidersForTest() + registerGiftCardProvider(stubProvider("bitcoinCompany")) +}) + +afterAll(() => { + __resetGiftCardProvidersForTest() +}) + describe("globals query — giftCardsEnabled", () => { - it("is true when the rail is on and at least one provider is enabled", async () => { + it("is true when the rail is on and at least one registered provider is enabled", async () => { mockGiftCardsConfig = makeGiftCardsConfig() // enabled, bitcoinCompany on expect((await resolveGlobals()).giftCardsEnabled).toBe(true) }) + it("is false when the enabled provider has no registered adapter", async () => { + // `bitrefill` has no adapter on disk; enabling it in config alone must not + // light the entry point up, or the catalog would answer PROVIDER_UNAVAILABLE. + __resetGiftCardProvidersForTest() + mockGiftCardsConfig = makeGiftCardsConfig() // enabled, bitcoinCompany on, unregistered + + expect((await resolveGlobals()).giftCardsEnabled).toBe(false) + }) + it("is false when the rail flag is off, even with a provider enabled", async () => { mockGiftCardsConfig = makeGiftCardsConfig({ enabled: false }) @@ -60,7 +88,8 @@ describe("globals query — giftCardsEnabled", () => { expect((await resolveGlobals()).giftCardsEnabled).toBe(false) }) - it("counts ANY enabled provider, not only the default route", async () => { + it("counts ANY registered and enabled provider, not only the default route", async () => { + registerGiftCardProvider(stubProvider("bitrefill")) const base = makeGiftCardsConfig() mockGiftCardsConfig = makeGiftCardsConfig({ providers: { diff --git a/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts b/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts index b2b8a1b3a..5904401f5 100644 --- a/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts +++ b/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts @@ -184,6 +184,8 @@ describe("GraphQL type shapes", () => { "logoUrl", "termsUrl", "rewardBps", + "maxQuantity", + "wholeUnitsOnly", ].sort(), ) }) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts index 2096c73e5..77a01a8ca 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts @@ -44,6 +44,7 @@ jest.mock("@services/redis", () => ({ import axios from "axios" import { + GiftCardInvalidValueError, GiftCardVendorRejectedOrderError, GiftCardVendorUnavailableError, } from "@domain/gift-cards" @@ -356,8 +357,9 @@ describe("BitcoinCompanyClient catalog", () => { const result = await makeClient().listProducts() if (result instanceof Error) throw result - expect(result).toHaveLength(855) - expect(new Set(result.map((p) => p.id)).size).toBe(855) + expect(result.products).toHaveLength(855) + expect(result.invalid).toBe(0) + expect(new Set(result.products.map((p) => p.id)).size).toBe(855) // A short page is not treated as the end; only the empty page at 855 is. const pages = callsTo(mockedAxios.get, "/giftcards") @@ -382,8 +384,8 @@ describe("BitcoinCompanyClient catalog", () => { const result = await makeClient().listProducts() if (result instanceof Error) throw result - expect(result).toHaveLength(255) - expect(new Set(result.map((p) => p.id)).size).toBe(255) + expect(result.products).toHaveLength(255) + expect(new Set(result.products.map((p) => p.id)).size).toBe(255) // Offsets advance by rows received, not by the 500 we asked for; the // trailing empty page at 255 is the stop signal. @@ -400,7 +402,7 @@ describe("BitcoinCompanyClient catalog", () => { const result = await makeClient().listProducts() - expect(result).toEqual([]) + expect(result).toEqual({ products: [], invalid: 0 }) expect(catalogOffsets(mockedAxios.get)).toEqual(["0"]) expect(mockedLogger.warn).not.toHaveBeenCalled() }) @@ -447,7 +449,7 @@ describe("BitcoinCompanyClient catalog", () => { const result = await makeClient().listProducts() if (result instanceof Error) throw result - expect(result).toHaveLength(CATALOG_PAGE_SIZE) + expect(result.products).toHaveLength(CATALOG_PAGE_SIZE) expect(callsTo(mockedAxios.get, "/giftcards")).toHaveLength(2) }) @@ -468,15 +470,27 @@ describe("BitcoinCompanyClient catalog", () => { const result = await makeClient().listProducts() if (result instanceof Error) throw result - expect(result.map((p) => p.id)).toEqual(["prod-amazon-us"]) + expect(result.products.map((p) => p.id)).toEqual(["prod-amazon-us"]) + // The adapter's received-vs-kept accounting needs the count, not just a log line. + expect(result.invalid).toBe(2) // Skipped rows still advance the offset: the next page starts at 3, not 1. expect(catalogOffsets(mockedAxios.get)).toEqual(["0", "3"]) expect(mockedLogger.warn).toHaveBeenCalledWith( - expect.objectContaining({ op: "listProducts", skipped: 2, kept: 1 }), + expect.objectContaining({ op: "listProducts", invalid: 2, kept: 1 }), expect.any(String), ) }) + it("reports every row as invalid when the whole page fails validation", async () => { + mockedAxios.get.mockImplementation( + routeGet({ "/giftcards": pagedCatalog([{ id: 1 }, { id: 2 }, { id: 3 }]) }), + ) + + const result = await makeClient().listProducts() + + expect(result).toEqual({ products: [], invalid: 3 }) + }) + it("retries a GET after a network error and then succeeds", async () => { const firstPage = failThen( [networkError("ECONNRESET")], @@ -492,7 +506,7 @@ describe("BitcoinCompanyClient catalog", () => { const result = await makeClient().listProducts() if (result instanceof Error) throw result - expect(result).toHaveLength(1) + expect(result.products).toHaveLength(1) // Failed attempt, successful retry, then the terminating empty page. expect(catalogOffsets(mockedAxios.get)).toEqual(["0", "0", "1"]) expect(mockedLogger.warn).toHaveBeenCalledWith( @@ -568,17 +582,32 @@ describe("BitcoinCompanyClient purchase", () => { expect(callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")).toHaveLength(1) }) - it("maps a 4xx with a vendor message to GiftCardVendorRejectedOrderError", async () => { + it("maps a 4xx with a vendor message to a fixed-message GiftCardVendorRejectedOrderError", async () => { mockedAxios.post.mockImplementation( routePost({ - "/giftcards/purchase/bitcoin": () => httpError(400, "Insufficient stock"), + "/giftcards/purchase/bitcoin": () => + httpError(400, "Insufficient stock for SKU 991 on reseller acct 77"), }), ) const result = await makeClient().purchase(PURCHASE_ARGS) expect(result).toBeInstanceOf(GiftCardVendorRejectedOrderError) - expect((result as Error).message).toBe("Insufficient stock") + // The vendor's text names SKUs and account state: operators read it in the + // log, customers (and the order's failureReason) get the fixed message. + expect((result as Error).message).toBe("The provider declined this order") + expect((result as Error).message).not.toContain("Insufficient stock") + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + op: "createOrder", + status: 400, + vendorError: "Insufficient stock for SKU 991 on reseller acct 77", + }), + expect.any(String), + ) + expect(mockedRecordException).toHaveBeenCalledWith( + expect.objectContaining({ level: "warn" }), + ) }) it("maps a 2xx envelope with a 4xx statusCode, null result and an error to a rejection", async () => { @@ -594,7 +623,40 @@ describe("BitcoinCompanyClient purchase", () => { const result = await makeClient().purchase(PURCHASE_ARGS) expect(result).toBeInstanceOf(GiftCardVendorRejectedOrderError) - expect((result as Error).message).toBe("Card value not allowed") + expect((result as Error).message).toBe("The provider declined this order") + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + op: "createOrder", + vendorError: "Card value not allowed", + }), + expect.any(String), + ) + }) + + it("accepts a purchase result without amount, orderId, or satsBack", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/purchase/bitcoin": () => + httpOk({ invoice: INVOICE, uuid: PURCHASE_RESULT.uuid }), + }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toEqual({ invoice: INVOICE, uuid: PURCHASE_RESULT.uuid }) + }) + + it.each([ + ["invoice", { ...PURCHASE_RESULT, invoice: undefined }], + ["uuid", { ...PURCHASE_RESULT, uuid: undefined }], + ])("still refuses a purchase result without %s", async (_field, data) => { + mockedAxios.post.mockImplementation( + routePost({ "/giftcards/purchase/bitcoin": () => httpOk(data) }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) }) it("maps a 4xx without a vendor message to GiftCardVendorUnavailableError", async () => { @@ -608,9 +670,135 @@ describe("BitcoinCompanyClient purchase", () => { }) }) +describe("BitcoinCompanyClient quote rejection", () => { + beforeEach(() => seedTokens(FRESH_TOKENS)) + + it("maps a 4xx with a vendor message to GiftCardInvalidValueError at warn, never critical", async () => { + // A deterministic rejection of our input is not an outage: the same + // request will fail the same way, so it must not page as "vendor down". + mockedAxios.post.mockImplementation( + routePost({ + "/svs/quote-card": () => httpError(400, "cardValue must be a multiple of 5"), + }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toBeInstanceOf(GiftCardInvalidValueError) + expect(result).not.toBeInstanceOf(GiftCardVendorUnavailableError) + expect((result as Error).message).toBe("The provider could not quote this amount") + expect((result as Error).message).not.toContain("multiple of 5") + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + op: "quote", + status: 400, + vendorError: "cardValue must be a multiple of 5", + }), + expect.any(String), + ) + expect(mockedRecordException).toHaveBeenCalledWith( + expect.objectContaining({ level: "warn" }), + ) + expect(mockedRecordException).not.toHaveBeenCalledWith( + expect.objectContaining({ level: "critical" }), + ) + expect(callsTo(mockedAxios.post, "/svs/quote-card")).toHaveLength(1) + }) + + it("maps a 2xx envelope with a 4xx statusCode and an error to GiftCardInvalidValueError", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/svs/quote-card": () => ({ + status: 200, + data: { statusCode: 422, result: null, error: "Amount below minimum" }, + }), + }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toBeInstanceOf(GiftCardInvalidValueError) + expect((result as Error).message).toBe("The provider could not quote this amount") + }) + + it("keeps a 4xx without a vendor message as vendor-unavailable", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/svs/quote-card": () => ({ status: 422, data: {} }) }), + ) + + expect(await makeClient().quoteCard(QUOTE_ARGS)).toBeInstanceOf( + GiftCardVendorUnavailableError, + ) + }) + + it("keeps a 5xx as vendor-unavailable without retrying the quote", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/svs/quote-card": () => httpError(503, "Service Unavailable") }), + ) + + expect(await makeClient().quoteCard(QUOTE_ARGS)).toBeInstanceOf( + GiftCardVendorUnavailableError, + ) + expect(callsTo(mockedAxios.post, "/svs/quote-card")).toHaveLength(1) + }) + + it("keeps a network failure as vendor-unavailable", async () => { + mockedAxios.post.mockRejectedValue(networkError("ECONNRESET")) + + expect(await makeClient().quoteCard(QUOTE_ARGS)).toBeInstanceOf( + GiftCardVendorUnavailableError, + ) + }) +}) + describe("BitcoinCompanyClient invoice status", () => { beforeEach(() => seedTokens(FRESH_TOKENS)) + it("retries a persistent 5xx up to the budget by default", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/giftcards/invoice-status": () => httpError(502, "Bad Gateway") }), + ) + + const result = await makeClient().invoiceStatus(INVOICE) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/giftcards/invoice-status")).toHaveLength( + RETRY_MAX + 1, + ) + }) + + it("makes exactly one attempt, with the normal timeout, when retry is false", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/giftcards/invoice-status": () => httpError(502, "Bad Gateway") }), + ) + + const result = await makeClient().invoiceStatus(INVOICE, { retry: false }) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + const calls = callsTo(mockedAxios.post, "/giftcards/invoice-status") + expect(calls).toHaveLength(1) + expect(calls[0][2]).toEqual( + expect.objectContaining({ timeout: bitcoinCompanyConfigFixture.timeoutMs }), + ) + }) + + it("still refreshes once on a 401 when retry is false: auth is not a retry", async () => { + mockedAxios.get.mockImplementation(routeGet(refreshRoute)) + mockedAxios.post.mockImplementation( + routePost({ + "/giftcards/invoice-status": sequence( + httpError(401, "Unauthorized"), + httpOk(FULFILLED_RESULT), + ), + }), + ) + + const result = await makeClient().invoiceStatus(INVOICE, { retry: false }) + + expect(result).toEqual(FULFILLED_RESULT) + expect(callsTo(mockedAxios.post, "/giftcards/invoice-status")).toHaveLength(2) + }) + it("sends the invoice and retries the status POST on a 5xx", async () => { mockedAxios.post.mockImplementation( routePost({ @@ -675,6 +863,47 @@ describe("BitcoinCompanyClient response validation", () => { ) }) + it("accepts a quote result without satsBack or bitcoinPrice, defaulting the reward to 0", async () => { + mockedAxios.post.mockImplementation( + routePost({ "/svs/quote-card": () => httpOk({ fiatCost: 25, satsCost: 39000 }) }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual({ fiatCost: 25, satsCost: 39000, satsBack: 0 }) + }) + + it("reads explicit nulls for satsBack and bitcoinPrice the same way", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/svs/quote-card": () => + httpOk({ fiatCost: 25, satsCost: 39000, satsBack: null, bitcoinPrice: null }), + }), + ) + + const result = await makeClient().quoteCard(QUOTE_ARGS) + + expect(result).toEqual({ + fiatCost: 25, + satsCost: 39000, + satsBack: 0, + bitcoinPrice: null, + }) + }) + + it.each([ + ["fiatCost", { satsCost: 39000, satsBack: 585, bitcoinPrice: 64102.56 }], + ["satsCost", { fiatCost: 25, satsBack: 585, bitcoinPrice: 64102.56 }], + ])("still refuses a quote result without %s", async (_field, data) => { + mockedAxios.post.mockImplementation( + routePost({ "/svs/quote-card": () => httpOk(data) }), + ) + + expect(await makeClient().quoteCard(QUOTE_ARGS)).toBeInstanceOf( + GiftCardVendorUnavailableError, + ) + }) + it("accepts numeric strings where the vendor sends numbers as text", async () => { mockedAxios.post.mockImplementation( routePost({ diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts index 29d66759b..f2c95d4f0 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts @@ -42,12 +42,15 @@ jest.mock("@services/redis", () => ({ import axios from "axios" import { + GiftCardInvalidValueError, GiftCardVendorUnavailableError, toGiftCardOrderId, toGiftCardProviderOrderId, } from "@domain/gift-cards" import { + BITCOIN_COMPANY_MAX_QUANTITY, BitcoinCompanyProvider, + RETRY_MAX, registerBitcoinCompanyProvider, } from "@services/gift-cards/bitcoin-company" import { @@ -65,12 +68,14 @@ import { LOGIN_RESULT, NOW, PURCHASE_RESULT, - QUOTE_RESULT, RouteHandler, callsTo, catalogPage, + httpError, httpOk, networkError, + purchaseRoute, + quoteCardRoute, routeGet, routePost, vendorProductFixture, @@ -102,13 +107,20 @@ const happyGet: Record = { ), } +// Quote and purchase price by the quantity in the request body, so the "price +// of one" assertions below would fail if the adapter ever sent more than one. const happyPost: Record = { "/auth/login": () => httpOk(LOGIN_RESULT), - "/svs/quote-card": () => httpOk(QUOTE_RESULT), - "/giftcards/purchase/bitcoin": () => httpOk(PURCHASE_RESULT), + "/svs/quote-card": quoteCardRoute, + "/giftcards/purchase/bitcoin": purchaseRoute, "/giftcards/invoice-status": () => httpOk(FULFILLED_RESULT), } +const ORDER_REF = { + providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), + paymentRequest: PURCHASE_RESULT.invoice, +} + const arrangeHappyPath = () => { jest.clearAllMocks() mockRedisStore.clear() @@ -136,7 +148,7 @@ runGiftCardProviderContract("bitcoinCompany", makeProvider, { describe("bitcoinCompany adapter specifics", () => { beforeEach(arrangeHappyPath) - it("maps the vendor catalog and skips rows without a country", async () => { + it("maps the vendor catalog and counts every skip reason in the sync log", async () => { const products = await makeProvider().listProducts() if (products instanceof Error) throw products @@ -144,29 +156,141 @@ describe("bitcoinCompany adapter specifics", () => { "bitcoinCompany:prod-amazon-us", "bitcoinCompany:prod-visa-us", ]) + // Single-card until a multi-card invoice-status fixture exists. + expect(products.every((p) => p.maxQuantity === 1)).toBe(true) + expect(BITCOIN_COMPANY_MAX_QUANTITY).toBe(1) expect(mockedLogger.info).toHaveBeenCalledWith( - expect.objectContaining({ + { + provider: "bitcoinCompany", op: "listProducts", received: 3, - mapped: 2, - skipped: { "no-country": 1 }, + kept: 2, + skipped: { + invalid: 0, + noCountry: 1, + physical: 0, + noLightning: 0, + unknownDenominationType: 0, + noDenominations: 0, + }, + flagged: { notResellable: 0 }, + }, + expect.any(String), + ) + }) + + it("skips physical and non-Lightning rows and counts rows that failed validation", async () => { + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => + catalogPage( + Number(ctx.query.get("offset")) === 0 + ? [ + vendorProductFixture(), + vendorProductFixture({ id: "prod-physical", isPhysical: true }), + vendorProductFixture({ id: "prod-onchain", paymentTypes: ["OnChain"] }), + { id: 42, name: "broken" }, + ] + : [], + ), + }), + ) + + const products = await makeProvider().listProducts() + if (products instanceof Error) throw products + + expect(products.map((p) => p.id)).toEqual(["bitcoinCompany:prod-amazon-us"]) + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + received: 4, + kept: 1, + skipped: expect.objectContaining({ invalid: 1, physical: 1, noLightning: 1 }), }), expect.any(String), ) }) + it("keeps resellingEnabled=false rows (pre-KYB catalog) and warns with a count", async () => { + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => + catalogPage( + Number(ctx.query.get("offset")) === 0 + ? [ + vendorProductFixture({ resellingEnabled: false }), + vendorVariableProductFixture({ resellingEnabled: false }), + ] + : [], + ), + }), + ) + + const products = await makeProvider().listProducts() + if (products instanceof Error) throw products + + expect(products).toHaveLength(2) + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ op: "listProducts", notResellable: 2 }), + expect.stringContaining("resellingEnabled=false"), + ) + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ kept: 2, flagged: { notResellable: 2 } }), + expect.any(String), + ) + }) + + it("fails the sync when rows were received but none survived mapping", async () => { + // Every row skipped is a vendor-side shape or policy change, not a + // catalog. Returning [] would let the sync blank the read model. + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => + catalogPage( + Number(ctx.query.get("offset")) === 0 + ? [ + vendorProductFixture({ id: "a", countries: [] }), + vendorProductFixture({ id: "b", isPhysical: true }), + ] + : [], + ), + }), + ) + + const result = await makeProvider().listProducts() + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + op: "listProducts", + received: 2, + kept: 0, + skipped: expect.objectContaining({ noCountry: 1, physical: 1 }), + }), + expect.stringContaining("kept none"), + ) + }) + + it("returns an empty list, not an error, when the vendor catalog itself is empty", async () => { + // The sync job treats this as a failed pull; the adapter's job is only to + // report what the vendor said. + mockedAxios.get.mockImplementation(routeGet({ "/giftcards": () => catalogPage([]) })) + + expect(await makeProvider().listProducts()).toEqual([]) + expect(mockedLogger.error).not.toHaveBeenCalled() + }) + it("sends major units, Lightning purchase type, and the order reference to the vendor", async () => { const provider = makeProvider() const products = await provider.listProducts() if (products instanceof Error) throw products const product = products[0] - const quote = await provider.quote({ product, valueMinor: 2500, quantity: 2 }) + const quote = await provider.quote({ product, valueMinor: 2500, quantity: 1 }) if (quote instanceof Error) throw quote const order = await provider.createOrder({ product, valueMinor: 2500, - quantity: 2, + quantity: 1, reference: REFERENCE, }) if (order instanceof Error) throw order @@ -174,17 +298,19 @@ describe("bitcoinCompany adapter specifics", () => { expect(callsTo(mockedAxios.post, "/svs/quote-card")[0][1]).toEqual({ productId: "prod-amazon-us", cardValue: 25, - quantity: 2, + quantity: 1, purchaseType: "Lightning", }) expect(callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")[0][1]).toEqual({ productId: "prod-amazon-us", cardValue: 25, - quantity: 2, + quantity: 1, useUsdBalance: false, useSatsBalance: false, label: REFERENCE, }) + // The price of ONE card: the route handlers scale by quantity, so this + // would fail if the adapter had sent more than one. expect(quote).toEqual( expect.objectContaining({ productId: product.id, @@ -204,11 +330,35 @@ describe("bitcoinCompany adapter specifics", () => { }) }) + it.each([ + [ + "quote", + (p: IGiftCardProvider, product: GiftCardProduct) => + p.quote({ product, valueMinor: 2500, quantity: 2 }), + ], + [ + "createOrder", + (p: IGiftCardProvider, product: GiftCardProduct) => + p.createOrder({ product, valueMinor: 2500, quantity: 2, reference: REFERENCE }), + ], + ])("%s refuses quantity 2 before calling the vendor", async (_op, call) => { + const provider = makeProvider() + const products = await provider.listProducts() + if (products instanceof Error) throw products + mockedAxios.post.mockClear() + + const result = await call(provider, products[0]) + + expect(result).toBeInstanceOf(GiftCardInvalidValueError) + expect((result as Error).message).toBe( + "Only one card per order is supported for this provider", + ) + // Not even a login: the guard sits in front of the client. + expect(mockedAxios.post).not.toHaveBeenCalled() + }) + it("maps a completed vendor order to fulfilled with its claim", async () => { - const status = await makeProvider().getOrder({ - providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), - paymentRequest: PURCHASE_RESULT.invoice, - }) + const status = await makeProvider().getOrder(ORDER_REF) expect(status).toEqual({ kind: "fulfilled", @@ -256,19 +406,51 @@ describe("bitcoinCompany adapter specifics", () => { }), ) - const status = await makeProvider().getOrder({ - providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), - paymentRequest: PURCHASE_RESULT.invoice, - }) + const status = await makeProvider().getOrder(ORDER_REF) expect(status).toEqual({ kind: "awaitingPayment" }) }) + it("holds a Disputed order as pending with a warning rather than calling it refunded", async () => { + mockedAxios.post.mockImplementation( + routePost({ + ...happyPost, + "/giftcards/invoice-status": () => + httpOk({ status: "Disputed", claimData: FULFILLED_RESULT.claimData }), + }), + ) + + const status = await makeProvider().getOrder(ORDER_REF) + + expect(status).toEqual({ kind: "paidPendingFulfillment" }) + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ op: "getOrder", vendorStatus: "Disputed" }), + expect.stringContaining("Disputed"), + ) + }) + + it("getOrder retries a 5xx by default and makes exactly one attempt with retry: false", async () => { + mockedAxios.post.mockImplementation( + routePost({ + ...happyPost, + "/giftcards/invoice-status": () => httpError(502, "Bad Gateway"), + }), + ) + const provider = makeProvider() + + const single = await provider.getOrder(ORDER_REF, { retry: false }) + expect(single).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/giftcards/invoice-status")).toHaveLength(1) + + const retried = await provider.getOrder(ORDER_REF) + expect(retried).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(callsTo(mockedAxios.post, "/giftcards/invoice-status")).toHaveLength( + 1 + RETRY_MAX + 1, + ) + }) + it("refuses a status lookup without a payment request and never calls the vendor", async () => { - const status = await makeProvider().getOrder({ - providerOrderId: toGiftCardProviderOrderId(PURCHASE_RESULT.uuid), - paymentRequest: null, - }) + const status = await makeProvider().getOrder({ ...ORDER_REF, paymentRequest: null }) expect(status).toBeInstanceOf(GiftCardVendorUnavailableError) expect((status as Error).message).toBe("status lookup requires payment request") diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts index fddbaa457..1904af385 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts @@ -293,3 +293,42 @@ export const catalogOffsets = (mock: jest.Mock): string[] => callsTo(mock, "/giftcards").map( ([url]) => new URL(url as string).searchParams.get("offset") ?? "", ) + +// ============ Quantity-aware vendor routes ============ + +/** `quantity` from a quote/purchase request body; 1 when the body carries none. */ +export const quantityOf = (body: unknown): number => { + if (!body || typeof body !== "object") return 1 + const { quantity } = body as { quantity?: unknown } + return typeof quantity === "number" && quantity >= 1 ? quantity : 1 +} + +/** `QUOTE_RESULT` for `quantity` cards: per-card money and sats scaled by the count. */ +export const quoteResultFor = (quantity: number) => ({ + ...QUOTE_RESULT, + fiatCost: QUOTE_RESULT.fiatCost * quantity, + satsCost: QUOTE_RESULT.satsCost * quantity, + satsBack: QUOTE_RESULT.satsBack * quantity, +}) + +/** `PURCHASE_RESULT` for `quantity` cards. */ +export const purchaseResultFor = (quantity: number) => ({ + ...PURCHASE_RESULT, + amount: PURCHASE_RESULT.amount * quantity, + satsBack: PURCHASE_RESULT.satsBack * quantity, +}) + +/** + * `/svs/quote-card` and `/giftcards/purchase/bitcoin` handlers that price by + * the requested quantity, so a test asserting "the price of one" fails if the + * adapter ever sends more than one. + * + * There is deliberately NO multi-card `/giftcards/invoice-status` fixture: the + * sandbox response for a quantity-2 order has not been captured (one entry? a + * per-card array?), and the adapter caps `maxQuantity` at 1 until it is. + */ +export const quoteCardRoute: RouteHandler = (ctx) => + httpOk(quoteResultFor(quantityOf(ctx.body))) + +export const purchaseRoute: RouteHandler = (ctx) => + httpOk(purchaseResultFor(quantityOf(ctx.body))) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts index 3979efb53..4e82890d3 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts @@ -1,7 +1,10 @@ import { + BITCOIN_COMPANY_MAX_QUANTITY, KNOWN_VENDOR_STATUSES, + PRODUCT_MAPPING_SKIP_REASONS, QUOTE_TTL_MS, isProductMappingSkip, + isResellingDisabled, mapDenominationType, mapVendorClaim, mapVendorOrderStatus, @@ -78,9 +81,17 @@ describe("mapVendorProduct", () => { termsUrl: null, rewardBps: 150, inStock: true, + maxQuantity: 1, + wholeUnitsOnly: false, }) }) + it("caps every product at one card per order until a multi-card status fixture exists", () => { + expect(BITCOIN_COMPANY_MAX_QUANTITY).toBe(1) + expect(mapped(vendorProductFixture()).maxQuantity).toBe(1) + expect(mapped(vendorVariableProductFixture()).maxQuantity).toBe(1) + }) + it("maps a variable product to a min/max range with no fixed denominations", () => { expect(mapped(vendorVariableProductFixture())).toEqual( expect.objectContaining({ @@ -92,19 +103,30 @@ describe("mapVendorProduct", () => { maxValue: 50000, isOpenLoop: true, rewardBps: 50, + wholeUnitsOnly: false, }), ) }) - it("treats VariableNoCents as variable", () => { + it("treats VariableNoCents as variable that only accepts whole units", () => { const product = mapped( vendorVariableProductFixture({ denominationType: "VariableNoCents" }), ) expect(product.denominationType).toBe("variable") expect(product.minValue).toBe(500) expect(product.maxValue).toBe(50000) + expect(product.wholeUnitsOnly).toBe(true) }) + it.each(["Fixed", "Variable"])( + "%p products are not whole-units-only", + (denominationType) => { + expect( + mapped(vendorVariableProductFixture({ denominationType })).wholeUnitsOnly, + ).toBe(false) + }, + ) + it("dedupes, sorts, and drops non-positive denominations", () => { const product = mapped( vendorProductFixture({ denominations: [50, 10, 10, 0, -5, 25] }), @@ -121,30 +143,79 @@ describe("mapVendorProduct", () => { }) it.each([ - ["no countries", vendorProductFixture({ countries: [] }), "no-country"], - ["blank countries", vendorProductFixture({ countries: ["", " "] }), "no-country"], + ["no countries", vendorProductFixture({ countries: [] }), "noCountry"], + ["blank countries", vendorProductFixture({ countries: ["", " "] }), "noCountry"], + ["a physical card", vendorProductFixture({ isPhysical: true }), "physical"], + [ + "payment types that exclude Lightning", + vendorProductFixture({ paymentTypes: ["OnChain", "Card"] }), + "noLightning", + ], + [ + "an empty payment types list", + vendorProductFixture({ paymentTypes: [] }), + "noLightning", + ], [ "an unknown denomination type", vendorProductFixture({ denominationType: "Tiered" }), - "unknown-denomination-type", + "unknownDenominationType", ], [ "a fixed product with no denominations", vendorProductFixture({ denominations: [] }), - "no-denominations", + "noDenominations", ], [ "a variable product with no denominations", vendorVariableProductFixture({ denominations: [] }), - "no-denominations", + "noDenominations", ], [ "only non-positive denominations", vendorProductFixture({ denominations: [0, -1] }), - "no-denominations", + "noDenominations", ], ])("skips a product with %s", (_label, json, reason) => { expect(skipReason(json)).toBe(reason) + // Every reason the mapper can produce is one the adapter zero-initialises + // in its sync log, so a new reason cannot silently go uncounted. + expect(PRODUCT_MAPPING_SKIP_REASONS).toContain(reason) + }) + + it.each([ + ["isPhysical absent", vendorProductFixture({ isPhysical: undefined })], + ["isPhysical null", vendorProductFixture({ isPhysical: null as unknown as boolean })], + ["isPhysical false", vendorProductFixture({ isPhysical: false })], + [ + "paymentTypes absent (vendor did not say)", + vendorProductFixture({ paymentTypes: undefined }), + ], + [ + "paymentTypes null", + vendorProductFixture({ paymentTypes: null as unknown as string[] }), + ], + [ + "Lightning among other rails", + vendorProductFixture({ paymentTypes: ["OnChain", "Lightning"] }), + ], + ["Lightning in another case", vendorProductFixture({ paymentTypes: ["lightning"] })], + ])("keeps a product with %s", (_label, json) => { + expect(skipReason(json)).toBeNull() + }) + + it("keeps resellingEnabled=false rows and exposes the flag for the adapter to count", () => { + // Every product shows false until the account is KYB'd; skipping would + // empty the catalog. TODO(ENG-586): flip to skip once KYB flips it. + const notResellable = vendorProductFixture({ resellingEnabled: false }) + expect(skipReason(notResellable)).toBeNull() + expect(isResellingDisabled(parsed(notResellable))).toBe(true) + expect( + isResellingDisabled(parsed(vendorProductFixture({ resellingEnabled: true }))), + ).toBe(false) + expect( + isResellingDisabled(parsed(vendorProductFixture({ resellingEnabled: undefined }))), + ).toBe(false) }) it("uses the first non-blank country, normalised", () => { @@ -270,6 +341,31 @@ describe("mapVendorQuote", () => { expect(quote.rewardSats).toBe(585) expect(quote.quantity).toBe(2) }) + + it("reports a null bitcoinPrice as null rather than 0, and a 0 satsBack as no reward", () => { + const quote = mapVendorQuote({ + product, + valueMinor: 2500, + quantity: 1, + vendor: { fiatCost: 25, satsCost: 39000, satsBack: 0, bitcoinPrice: null }, + now, + }) + expect(quote.bitcoinPriceMinor).toBeNull() + expect(quote.rewardSats).toBe(0) + expect(quote.satsCost).toBe(39000) + expect(quote.fiatCostMinor).toBe(2500) + }) + + it("reports an absent bitcoinPrice as null", () => { + const quote = mapVendorQuote({ + product, + valueMinor: 2500, + quantity: 1, + vendor: { fiatCost: 25, satsCost: 39000, satsBack: 585 }, + now, + }) + expect(quote.bitcoinPriceMinor).toBeNull() + }) }) describe("mapVendorPurchase", () => { @@ -291,6 +387,24 @@ describe("mapVendorPurchase", () => { expect(order.providerOrderId).toBe("42") expect(order.amountSats).toBe(101) }) + + it("maps a missing or null amount to 0 sats: the decoded BOLT11 amount governs what is paid", () => { + // `amountSats` is non-null on the port. 0 is safe because the purchase path + // pays max(invoiceSats, amountSats): the vendor figure can only raise the + // charge, never lower it below the invoice. + expect(mapVendorPurchase({ ...PURCHASE_RESULT, amount: null }).amountSats).toBe(0) + expect(mapVendorPurchase({ invoice: PURCHASE_RESULT.invoice, uuid: "u-1" })).toEqual({ + providerOrderId: "u-1", + paymentRequest: PURCHASE_RESULT.invoice, + amountSats: 0, + expiresAt: null, + }) + }) + + it("does not need orderId: uuid is the key we store and query by", () => { + const order = mapVendorPurchase({ ...PURCHASE_RESULT, orderId: null }) + expect(order.providerOrderId).toBe(PURCHASE_RESULT.uuid) + }) }) describe("mapVendorClaim", () => { @@ -335,7 +449,9 @@ describe("mapVendorOrderStatus", () => { claimLink: CLAIM_LINK, } - it.each([ + // Statuses the adapter settles without comment. Disputed is deliberately + // absent: it is held, with a warning, and tested on its own below. + const SETTLED: ReadonlyArray<[string, GiftCardProviderOrderStatus["kind"]]> = [ ["Unpaid", "awaitingPayment"], ["Underpaid", "awaitingPayment"], ["Confirming", "awaitingPayment"], @@ -348,18 +464,45 @@ describe("mapVendorOrderStatus", () => { ["Expired", "failed"], ["Cancelled", "failed"], ["Refunded", "refunded"], - ["Disputed", "refunded"], ["ClawedBack", "refunded"], - ])("maps vendor status %p to %p without a warning", (vendorStatus, kind) => { - const { status, warning } = mapVendorOrderStatus({ status: vendorStatus, claimData }) - expect(status.kind).toBe(kind) - expect(warning).toBeNull() - }) + ] + const HELD = ["Disputed"] + + it.each(SETTLED)( + "maps vendor status %p to %p without a warning", + (vendorStatus, kind) => { + const { status, warning } = mapVendorOrderStatus({ + status: vendorStatus, + claimData, + }) + expect(status.kind).toBe(kind) + expect(warning).toBeNull() + }, + ) - it("covers every known vendor status in the table test above", () => { - expect(KNOWN_VENDOR_STATUSES).toHaveLength(14) + it("the settled + held rows above are exactly the statuses the adapter knows", () => { + // Derived from the exported list, not a hard-coded count: adding a status + // to the adapter without a row here fails this test by name. + const covered = [...SETTLED.map(([s]) => s), ...HELD].map((s) => s.toLowerCase()) + expect(covered.sort()).toEqual([...KNOWN_VENDOR_STATUSES].sort()) }) + it.each(HELD)( + "holds %p as paidPendingFulfillment with a warning, even when claim data is present", + (vendorStatus) => { + // A dispute is not a refund: money may still come back as a card or as a + // refund. A later Refunded / ClawedBack / Completed poll, or the 24h + // timeout, decides; calling it terminal now would be a guess. + const { status, warning } = mapVendorOrderStatus({ + status: vendorStatus, + claimData, + }) + expect(status).toEqual({ kind: "paidPendingFulfillment" }) + expect(warning).toContain(vendorStatus) + expect(warning).toContain("holding as pending") + }, + ) + it("carries the vendor status as the reason for failed and refunded", () => { expect(mapVendorOrderStatus({ status: "Expired" }).status).toEqual({ kind: "failed", diff --git a/test/flash/unit/services/gift-cards/catalog-cache.spec.ts b/test/flash/unit/services/gift-cards/catalog-cache.spec.ts index 84273e7f6..815eedc11 100644 --- a/test/flash/unit/services/gift-cards/catalog-cache.spec.ts +++ b/test/flash/unit/services/gift-cards/catalog-cache.spec.ts @@ -66,8 +66,10 @@ import { GiftCardCatalogCache, giftCardCatalogKey, giftCardCountriesKey, + giftCardProductIdsKey, giftCardProductKey, } from "@services/gift-cards/catalog-cache" +import { baseLogger } from "@services/logger" const NOW_MS = 1_800_000_000_000 const TTL_MS = mockGiftCardsConfig.catalog.ttlSeconds * 1000 @@ -91,9 +93,21 @@ const product = (id: string, over: Partial = {}): GiftCardProdu termsUrl: null, rewardBps: 0, inStock: true, + maxQuantity: 1, + wholeUnitsOnly: false, ...over, }) +/** A product as the sync wrote it BEFORE `maxQuantity` / `wholeUnitsOnly` existed. */ +const legacyRow = (p: GiftCardProduct): Record => + Object.fromEntries( + Object.entries(p).filter( + ([key]) => key !== "maxQuantity" && key !== "wholeUnitsOnly", + ), + ) + +const seed = (key: string, value: unknown) => mockStore.set(key, { value, ttlSecs: 1 }) + const US_A = product("us-a") const US_B = product("us-b") // Lower-case on purpose: the vendor's casing must not leak into key names. @@ -162,7 +176,95 @@ describe("GiftCardCatalogCache.write", () => { }) expect(mockWriteOrder[mockWriteOrder.length - 1]).toBe(giftCardCountriesKey(PROVIDER)) - expect(mockWriteOrder).toHaveLength(6) // 2 catalogs + 3 products + 1 index + // The id set lands just before the index, after every product key it lists. + expect(mockWriteOrder[mockWriteOrder.length - 2]).toBe( + giftCardProductIdsKey(PROVIDER), + ) + expect(mockWriteOrder).toHaveLength(7) // 2 catalogs + 3 products + ids + index + }) + + it("records the ids it wrote under the provider's product id set", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_B, JM_C, US_A], + syncedAt: new Date(NOW_MS), + }) + + expect(mockStore.get(giftCardProductIdsKey(PROVIDER))?.value).toEqual( + [US_A.id, US_B.id, JM_C.id].sort(), + ) + }) + + it("deletes product keys the previous sync wrote and this one did not", async () => { + await cache.write({ + providerId: PROVIDER, + products: [US_A, US_B, JM_C], + syncedAt: new Date(NOW_MS), + }) + + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + + // Delisted: gone, so it can no longer be quoted or ordered. + expect(mockStore.has(giftCardProductKey(US_B.id))).toBe(false) + expect(mockStore.has(giftCardProductKey(JM_C.id))).toBe(false) + expect(mockStore.get(giftCardProductKey(US_A.id))?.value).toEqual(US_A) + expect(mockStore.get(giftCardProductIdsKey(PROVIDER))?.value).toEqual([US_A.id]) + expect(baseLogger.info).toHaveBeenCalledWith( + { providerId: PROVIDER, delisted: 2 }, + expect.stringContaining("delisted"), + ) + }) + + it("deletes nothing it did not write: keys outside the id set are left alone", async () => { + // No id set yet (first sync after deploy) and an unrelated product key. + seed(giftCardProductKey(US_B.id), US_B) + + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + + expect(mockStore.has(giftCardProductKey(US_B.id))).toBe(true) + expect(mockStore.get(giftCardProductIdsKey(PROVIDER))?.value).toEqual([US_A.id]) + }) + + it("still writes, and skips the diff with a warning, when the id set cannot be read", async () => { + mockFailures.get = true + + const res = await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + + expect(res).toEqual({ countries: ["US"] }) + expect(mockStore.get(giftCardProductKey(US_A.id))?.value).toEqual(US_A) + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ providerId: PROVIDER }), + expect.stringContaining("skipping stale-key cleanup"), + ) + }) + + it("ignores an id set of the wrong shape rather than deleting by it", async () => { + seed(giftCardProductIdsKey(PROVIDER), [{ nope: true }, 42]) + seed(giftCardProductKey(US_B.id), US_B) + + await cache.write({ + providerId: PROVIDER, + products: [US_A], + syncedAt: new Date(NOW_MS), + }) + + expect(mockStore.has(giftCardProductKey(US_B.id))).toBe(true) + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ providerId: PROVIDER }), + expect.stringContaining("unexpected shape"), + ) }) it("retains every key for staleAfterSeconds, not ttlSeconds", async () => { @@ -176,7 +278,7 @@ describe("GiftCardCatalogCache.write", () => { }) const ttls = [...mockStore.values()].map((entry) => entry.ttlSecs) - expect(ttls).toHaveLength(5) + expect(ttls).toHaveLength(6) // 2 catalogs + 2 products + ids + index expect(new Set(ttls)).toEqual( new Set([mockGiftCardsConfig.catalog.staleAfterSeconds]), ) @@ -204,6 +306,7 @@ describe("GiftCardCatalogCache.write", () => { expect(res).toEqual({ countries: [] }) expect(mockStore.get(giftCardCountriesKey(PROVIDER))?.value).toEqual([]) + expect(mockStore.get(giftCardProductIdsKey(PROVIDER))?.value).toEqual([]) }) }) @@ -273,6 +376,44 @@ describe("GiftCardCatalogCache.read", () => { expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) }) + it("defaults maxQuantity and wholeUnitsOnly for rows cached before they existed", async () => { + // The running cache must stay usable across the deploy that added the two + // fields; the next sync writes real values. + seed(giftCardCatalogKey(PROVIDER, "US"), { + syncedAt: new Date(NOW_MS).toISOString(), + products: [legacyRow(US_A), legacyRow(US_B)], + }) + + const res = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + if (res instanceof Error) throw res + + expect(res.products).toEqual([US_A, US_B]) + expect( + res.products.every((p) => p.maxQuantity === 1 && p.wholeUnitsOnly === false), + ).toBe(true) + }) + + it("drops rows of an unexpected shape from a catalog record and serves the rest", async () => { + seed(giftCardCatalogKey(PROVIDER, "US"), { + syncedAt: new Date(NOW_MS).toISOString(), + products: [US_A, { id: "bitcoinCompany:half-written" }, "garbage", US_B], + }) + + const res = await cache.read({ providerId: PROVIDER, countryCode: "US" }) + if (res instanceof Error) throw res + + expect(res.products).toEqual([US_A, US_B]) + expect(baseLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: PROVIDER, + countryCode: "US", + dropped: 2, + kept: 2, + }), + expect.stringContaining("dropped"), + ) + }) + it("refuses a record it cannot interpret rather than serving garbage", async () => { mockStore.set(giftCardCatalogKey(PROVIDER, "US"), { value: { products: "not-a-list" }, @@ -303,45 +444,67 @@ describe("GiftCardCatalogCache.readProduct", () => { expect(await cache.readProduct(JM_C.id)).toEqual(JM_C) }) - it("returns GiftCardProductNotFoundError on a miss", async () => { - const res = await cache.readProduct("bitcoinCompany:nope" as GiftCardProductId) - - expect(res).toBeInstanceOf(GiftCardProductNotFoundError) - }) - - it("returns GiftCardCatalogUnavailableError when Redis cannot be read", async () => { - mockFailures.get = true + it("defaults maxQuantity and wholeUnitsOnly for a product cached before they existed", async () => { + seed(giftCardProductKey(US_A.id), legacyRow(US_A)) const res = await cache.readProduct(US_A.id) - expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) - expect(res).not.toBeInstanceOf(GiftCardProductNotFoundError) + expect(res).toEqual(US_A) + expect(res).toEqual( + expect.objectContaining({ maxQuantity: 1, wholeUnitsOnly: false }), + ) }) -}) -describe("GiftCardCatalogCache.countries", () => { - it("returns the sorted index the last write produced", async () => { - await cache.write({ - providerId: PROVIDER, - products: [US_A, JM_C], - syncedAt: new Date(), + it("keeps the written maxQuantity and wholeUnitsOnly when present", async () => { + const whole = product("whole", { + denominationType: "variable", + denominations: [], + minValue: 500, + maxValue: 5000, + maxQuantity: 3, + wholeUnitsOnly: true, }) + await cache.write({ providerId: PROVIDER, products: [whole], syncedAt: new Date() }) - expect(await cache.countries(PROVIDER)).toEqual(["JM", "US"]) + expect(await cache.readProduct(whole.id)).toEqual(whole) }) - it("returns GiftCardCatalogUnavailableError when the index is missing", async () => { - expect(await cache.countries(PROVIDER)).toBeInstanceOf( - GiftCardCatalogUnavailableError, - ) + it.each([ + ["a non-object", "garbage"], + [ + "a half-written product", + { id: "bitcoinCompany:half", providerId: "bitcoinCompany" }, + ], + ["an unknown provider id", { ...US_A, providerId: "someoneElse" }], + ["an unknown denomination type", { ...US_A, denominationType: "tiered" }], + ["a maxQuantity below 1", { ...US_A, maxQuantity: 0 }], + ])( + "returns GiftCardProductNotFoundError for %s rather than serving it", + async (_label, raw) => { + seed(giftCardProductKey(US_A.id), raw) + + const res = await cache.readProduct(US_A.id) + + expect(res).toBeInstanceOf(GiftCardProductNotFoundError) + expect(baseLogger.error).toHaveBeenCalledWith( + { productId: US_A.id }, + expect.stringContaining("unexpected shape"), + ) + }, + ) + + it("returns GiftCardProductNotFoundError on a miss", async () => { + const res = await cache.readProduct("bitcoinCompany:nope" as GiftCardProductId) + + expect(res).toBeInstanceOf(GiftCardProductNotFoundError) }) it("returns GiftCardCatalogUnavailableError when Redis cannot be read", async () => { - await cache.write({ providerId: PROVIDER, products: [US_A], syncedAt: new Date() }) mockFailures.get = true - expect(await cache.countries(PROVIDER)).toBeInstanceOf( - GiftCardCatalogUnavailableError, - ) + const res = await cache.readProduct(US_A.id) + + expect(res).toBeInstanceOf(GiftCardCatalogUnavailableError) + expect(res).not.toBeInstanceOf(GiftCardProductNotFoundError) }) }) diff --git a/test/flash/unit/services/gift-cards/claim-crypto.spec.ts b/test/flash/unit/services/gift-cards/claim-crypto.spec.ts index d02757d89..8d71f2dcb 100644 --- a/test/flash/unit/services/gift-cards/claim-crypto.spec.ts +++ b/test/flash/unit/services/gift-cards/claim-crypto.spec.ts @@ -2,16 +2,19 @@ * Claim data is the bearer value of a gift card — whoever holds the code holds * the money. These specs pin the at-rest envelope: AES-256-GCM under the * configured key, `version | iv | tag | data`, key identified by a sha256 - * prefix so rotation can be detected rather than guessed. Every check here - * must be able to return "no": a tampered byte, a foreign key id, a short key - * and a missing key all have to be refused, and no refusal may echo the code - * or the key back in its message. + * prefix so rotation can be detected rather than guessed, and the order id + * bound into the authenticated data so a ciphertext only opens on the row it + * was sealed for. Every check here must be able to return "no": a tampered + * byte, a foreign key id, another order's id, a short key and a missing key + * all have to be refused, and no refusal may echo the code or the key back in + * its message. */ import { createHash, randomBytes } from "crypto" import { GiftCardsConfig } from "@config" import { GiftCardClaimCryptoError } from "@domain/gift-cards" import { + claimCryptoReady, decryptGiftCardClaim, encryptGiftCardClaim, giftCardClaimKeyId, @@ -37,10 +40,25 @@ const CLAIM: GiftCardClaim = { barcode: { chars: "600649310000123456", type: "CODE128" }, } +const ORDER_ID = "1f9d2c4e-8b7a-4c3d-9e2f-0a1b2c3d4e5f" as GiftCardOrderId +const OTHER_ORDER_ID = "7d2e1b9a-3c4f-4a5b-8c6d-9e0f1a2b3c4d" as GiftCardOrderId + const setKey = (key: string) => { config.claimDataEncryptionKey = key } +const seal = (claim: GiftCardClaim = CLAIM, orderId: GiftCardOrderId = ORDER_ID) => { + const sealed = encryptGiftCardClaim(claim, { orderId }) + if (sealed instanceof Error) throw sealed + return sealed +} + +/** Open under the order it was sealed for unless told otherwise. */ +const open = ( + sealed: { ciphertext: string; keyId: string }, + orderId: GiftCardOrderId = ORDER_ID, +) => decryptGiftCardClaim({ ...sealed, orderId }) + const flipByte = (ciphertext: string, offset: number): string => { const buf = Buffer.from(ciphertext, "base64") buf[offset] = buf[offset] ^ 0xff @@ -59,17 +77,15 @@ describe("gift card claim crypto", () => { describe("round trip", () => { it("decrypts to the claim it encrypted", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() - const opened = decryptGiftCardClaim(sealed) + const opened = open(sealed) expect(opened).toEqual(CLAIM) }) it("lays the envelope out as version | iv | tag | data and reports the key id", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() const packed = Buffer.from(sealed.ciphertext, "base64") expect(packed[0]).toBe(0x01) @@ -82,14 +98,12 @@ describe("gift card claim crypto", () => { }) it("uses a fresh iv per call, so equal claims never share a ciphertext", () => { - const a = encryptGiftCardClaim(CLAIM) - const b = encryptGiftCardClaim(CLAIM) - if (a instanceof Error) throw a - if (b instanceof Error) throw b + const a = seal() + const b = seal() expect(a.ciphertext).not.toBe(b.ciphertext) - expect(decryptGiftCardClaim(a)).toEqual(CLAIM) - expect(decryptGiftCardClaim(b)).toEqual(CLAIM) + expect(open(a)).toEqual(CLAIM) + expect(open(b)).toEqual(CLAIM) }) it("round-trips a minimal claim with nulls", () => { @@ -98,58 +112,85 @@ describe("gift card claim crypto", () => { claimLink: null, barcode: null, } - const sealed = encryptGiftCardClaim(minimal) - if (sealed instanceof Error) throw sealed + const sealed = seal(minimal) - expect(decryptGiftCardClaim(sealed)).toEqual(minimal) + expect(open(sealed)).toEqual(minimal) + }) + }) + + describe("order binding", () => { + it("refuses to open a ciphertext under a different order id", () => { + // Anyone with write access to the orders collection can copy one row's + // `claimCiphertext` onto another. The order id is authenticated data, so + // the tag does not verify there. + const sealed = seal(CLAIM, ORDER_ID) + + const result = open(sealed, OTHER_ORDER_ID) + + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expectNoLeak(result as Error) + expect((result as Error).message).not.toContain(ORDER_ID) + }) + + it("the order id does not appear in the ciphertext", () => { + const sealed = seal(CLAIM, ORDER_ID) + expect(Buffer.from(sealed.ciphertext, "base64").toString("utf8")).not.toContain( + ORDER_ID, + ) + }) + + it("two orders sealing the same claim each open only their own", () => { + const a = seal(CLAIM, ORDER_ID) + const b = seal(CLAIM, OTHER_ORDER_ID) + + expect(open(a, ORDER_ID)).toEqual(CLAIM) + expect(open(b, OTHER_ORDER_ID)).toEqual(CLAIM) + expect(open(a, OTHER_ORDER_ID)).toBeInstanceOf(GiftCardClaimCryptoError) + expect(open(b, ORDER_ID)).toBeInstanceOf(GiftCardClaimCryptoError) }) }) describe("tamper detection", () => { it("refuses a ciphertext with one payload byte flipped", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() // Last byte sits in the data region. const packedLength = Buffer.from(sealed.ciphertext, "base64").length const tampered = flipByte(sealed.ciphertext, packedLength - 1) - const result = decryptGiftCardClaim({ ...sealed, ciphertext: tampered }) + const result = open({ ...sealed, ciphertext: tampered }) expect(result).toBeInstanceOf(GiftCardClaimCryptoError) expectNoLeak(result as Error) }) it("refuses a ciphertext with one auth tag byte flipped", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() // Offsets 13..28 are the tag. const tampered = flipByte(sealed.ciphertext, 13) - expect(decryptGiftCardClaim({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + expect(open({ ...sealed, ciphertext: tampered })).toBeInstanceOf( GiftCardClaimCryptoError, ) }) it("refuses a ciphertext with one iv byte flipped", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() const tampered = flipByte(sealed.ciphertext, 1) - expect(decryptGiftCardClaim({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + expect(open({ ...sealed, ciphertext: tampered })).toBeInstanceOf( GiftCardClaimCryptoError, ) }) it("refuses an unknown envelope version", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() const tampered = flipByte(sealed.ciphertext, 0) - expect(decryptGiftCardClaim({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + expect(open({ ...sealed, ciphertext: tampered })).toBeInstanceOf( GiftCardClaimCryptoError, ) }) @@ -158,10 +199,35 @@ describe("gift card claim crypto", () => { const result = decryptGiftCardClaim({ ciphertext: Buffer.from([0x01, 0x02, 0x03]).toString("base64"), keyId: EXPECTED_KEY_ID, + orderId: ORDER_ID, }) expect(result).toBeInstanceOf(GiftCardClaimCryptoError) }) + + it.each([ + ["an empty string", ""], + ["characters outside the base64 alphabet", "AQID!@#$"], + ["a length that is not a whole number of quads", "AQIDB"], + ["padding in the middle", "AQ==ID"], + ["three padding characters", "A==="], + ["whitespace", "AQID AQID"], + ])( + "refuses malformed base64 (%s) instead of silently decoding what it can", + (_label, ciphertext) => { + // `Buffer.from(x, "base64")` never throws: it skips bad characters and + // decodes the rest, which is how a corrupt row turns into a confusing + // "tag mismatch" instead of a clear "malformed". Strict check first. + const result = decryptGiftCardClaim({ + ciphertext, + keyId: EXPECTED_KEY_ID, + orderId: ORDER_ID, + }) + + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expect((result as Error).message).toMatch(/malformed/) + }, + ) }) describe("key id", () => { @@ -170,12 +236,12 @@ describe("gift card claim crypto", () => { }) it("refuses to decrypt under a key id that is not the current key", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() const result = decryptGiftCardClaim({ ciphertext: sealed.ciphertext, keyId: "0123456789abcdef", + orderId: ORDER_ID, }) expect(result).toBeInstanceOf(GiftCardClaimCryptoError) @@ -184,12 +250,11 @@ describe("gift card claim crypto", () => { }) it("reports a mismatch when the configured key changes under a sealed order", () => { - const sealed = encryptGiftCardClaim(CLAIM) - if (sealed instanceof Error) throw sealed + const sealed = seal() setKey(randomBytes(32).toString("hex")) - const result = decryptGiftCardClaim(sealed) + const result = open(sealed) expect(result).toBeInstanceOf(GiftCardClaimCryptoError) expect((result as Error).message).toMatch(/key rotated/) }) @@ -203,14 +268,13 @@ describe("gift card claim crypto", () => { it("accepts a base64 key of 32 bytes and derives the same key id from the raw bytes", () => { setKey(HEX_KEY) - const sealedUnderHex = encryptGiftCardClaim(CLAIM) - if (sealedUnderHex instanceof Error) throw sealedUnderHex + const sealedUnderHex = seal() setKey(BASE64_KEY) expect(giftCardClaimKeyId()).toBe(EXPECTED_KEY_ID) // Same bytes, different encoding: the order sealed under the hex form // still opens. - expect(decryptGiftCardClaim(sealedUnderHex)).toEqual(CLAIM) + expect(open(sealedUnderHex)).toEqual(CLAIM) }) it("tolerates surrounding whitespace from a config override", () => { @@ -221,40 +285,91 @@ describe("gift card claim crypto", () => { it("refuses an empty key rather than silently no-op'ing", () => { setKey("") - const sealed = encryptGiftCardClaim(CLAIM) + const sealed = encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID }) expect(sealed).toBeInstanceOf(GiftCardClaimCryptoError) expectNoLeak(sealed as Error) expect(giftCardClaimKeyId()).toBeInstanceOf(GiftCardClaimCryptoError) expect( - decryptGiftCardClaim({ ciphertext: "AAAA", keyId: EXPECTED_KEY_ID }), + decryptGiftCardClaim({ + ciphertext: "AAAA", + keyId: EXPECTED_KEY_ID, + orderId: ORDER_ID, + }), ).toBeInstanceOf(GiftCardClaimCryptoError) }) it("refuses a whitespace-only key", () => { setKey(" ") - expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + expect(encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) }) it("refuses a 31-byte key in hex", () => { setKey(randomBytes(31).toString("hex")) - const result = encryptGiftCardClaim(CLAIM) + const result = encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID }) expect(result).toBeInstanceOf(GiftCardClaimCryptoError) expectNoLeak(result as Error) }) it("refuses a 31-byte key in base64", () => { setKey(randomBytes(31).toString("base64")) - expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + expect(encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) }) it("refuses a 33-byte key in base64", () => { setKey(randomBytes(33).toString("base64")) - expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + expect(encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) }) it("refuses a key that is neither hex nor base64", () => { setKey("not-a-key!@#$%^&*()") - expect(encryptGiftCardClaim(CLAIM)).toBeInstanceOf(GiftCardClaimCryptoError) + expect(encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + }) + + describe("claimCryptoReady", () => { + // The purchase path asks this before paying: a card we could not store the + // code for is a customer who paid for something they cannot see. + it("is true with a valid key and encrypts nothing", () => { + setKey(HEX_KEY) + expect(claimCryptoReady()).toBe(true) + }) + + it.each([ + ["missing", ""], + ["whitespace-only", " "], + ["31 bytes of hex", randomBytes(31).toString("hex")], + ["33 bytes of base64", randomBytes(33).toString("base64")], + ["garbage", "not-a-key!@#$%^&*()"], + ])("reports a %s key as not ready", (_label, key) => { + setKey(key) + expect(claimCryptoReady()).toBeInstanceOf(GiftCardClaimCryptoError) + }) + + it.each([ + ["31 bytes of hex", randomBytes(31).toString("hex")], + ["33 bytes of base64", randomBytes(33).toString("base64")], + ["garbage", "not-a-key!@#$%^&*()"], + ])("never echoes a rejected %s key in its message", (_label, key) => { + setKey(key) + const result = claimCryptoReady() + expect((result as Error).message).not.toContain(key) + }) + + it("agrees with encrypt: ready ⇔ encrypt succeeds", () => { + for (const key of [HEX_KEY, BASE64_KEY, "", randomBytes(31).toString("hex")]) { + setKey(key) + const ready = claimCryptoReady() + const sealed = encryptGiftCardClaim(CLAIM, { orderId: ORDER_ID }) + expect(ready === true).toBe(!(sealed instanceof Error)) + } }) }) }) diff --git a/test/flash/unit/services/gift-cards/provider-contract.ts b/test/flash/unit/services/gift-cards/provider-contract.ts index 948f8423f..d685f7b75 100644 --- a/test/flash/unit/services/gift-cards/provider-contract.ts +++ b/test/flash/unit/services/gift-cards/provider-contract.ts @@ -68,6 +68,8 @@ const contractProduct = (providerId: GiftCardProviderId): GiftCardProduct => ({ termsUrl: null, rewardBps: 0, inStock: true, + maxQuantity: 1, + wholeUnitsOnly: false, }) export const runGiftCardProviderContract = ( diff --git a/test/flash/unit/services/gift-cards/registry.spec.ts b/test/flash/unit/services/gift-cards/registry.spec.ts index f5f3ad151..531a1c606 100644 --- a/test/flash/unit/services/gift-cards/registry.spec.ts +++ b/test/flash/unit/services/gift-cards/registry.spec.ts @@ -14,6 +14,7 @@ import { getEnabledGiftCardProvider, getGiftCardProviderForCountry, getRegisteredGiftCardProviderOrError, + isGiftCardProviderEnabled, registerGiftCardProvider, resolveGiftCardProviderIdForCountry, } from "@services/gift-cards/registry" @@ -105,4 +106,61 @@ describe("routing", () => { GiftCardProviderUnavailableError, ) }) + + it("matches byCountry keys case-insensitively: { jm: bitrefill } routes JM", () => { + registerGiftCardProvider(fakeProvider("bitrefill")) + mockConfig = makeGiftCardsConfig({ + routing: { default: "bitcoinCompany", byCountry: { jm: "bitrefill" } }, + }) + mockConfig.providers.bitrefill.enabled = true + + expect(resolveGiftCardProviderIdForCountry("JM")).toBe("bitrefill") + expect(resolveGiftCardProviderIdForCountry("jm")).toBe("bitrefill") + expect(resolveGiftCardProviderIdForCountry(" Jm ")).toBe("bitrefill") + // Everything else still falls through to the default. + expect(resolveGiftCardProviderIdForCountry("US")).toBe("bitcoinCompany") + }) +}) + +describe("registration gate", () => { + // Config can name a provider no adapter registered under (a typo, or an + // adapter whose import was dropped). That id must read as NOT enabled, or + // the globals flag and the country gate would advertise gift cards while + // every quote, purchase, and catalog read failed. + it("an enabled id with no registered adapter is not enabled and does not route", () => { + mockConfig = makeGiftCardsConfig({ + routing: { default: "bitcoinCompany", byCountry: { JM: "bitrefill" } }, + }) + mockConfig.providers.bitrefill.enabled = true + + expect(isGiftCardProviderEnabled("bitrefill")).toBe(false) + expect(resolveGiftCardProviderIdForCountry("JM")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + expect(getGiftCardProviderForCountry("JM")).toBeInstanceOf( + GiftCardProviderUnavailableError, + ) + expect(enabledGiftCardProviders()).toEqual([tbc]) + }) + + it("the same id becomes enabled the moment an adapter registers under it", () => { + mockConfig = makeGiftCardsConfig({ + routing: { default: "bitcoinCompany", byCountry: { JM: "bitrefill" } }, + }) + mockConfig.providers.bitrefill.enabled = true + const bitrefill = fakeProvider("bitrefill") + + registerGiftCardProvider(bitrefill) + + expect(isGiftCardProviderEnabled("bitrefill")).toBe(true) + expect(resolveGiftCardProviderIdForCountry("JM")).toBe("bitrefill") + expect(getGiftCardProviderForCountry("JM")).toBe(bitrefill) + expect(enabledGiftCardProviders()).toEqual([tbc, bitrefill]) + }) + + it("a registered adapter whose config is off stays not enabled", () => { + expect(isGiftCardProviderEnabled("bitcoinCompany")).toBe(true) + mockConfig.providers.bitcoinCompany.enabled = false + expect(isGiftCardProviderEnabled("bitcoinCompany")).toBe(false) + }) }) diff --git a/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts index 1f9e4d4d3..806f869fa 100644 --- a/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts +++ b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts @@ -271,7 +271,10 @@ describe("checkGiftCardOrderTransition", () => { ).toBeInstanceOf(GiftCardOrderStateError) }) - it("never lets a terminal state move anywhere", () => { + it("never lets a terminal state move anywhere, except EXPIRED → PAID", () => { + // A send still in flight at IBEX when the worker expired the row, then + // settled: the money left, and the order must be able to say so. Nothing + // else terminal has a way back. const terminal = [ GiftCardOrderStatus.Fulfilled, GiftCardOrderStatus.Failed, @@ -279,13 +282,27 @@ describe("checkGiftCardOrderTransition", () => { GiftCardOrderStatus.Expired, GiftCardOrderStatus.RefundRequired, ] - for (const from of terminal) { - for (const to of allStatuses) { - expect(checkGiftCardOrderTransition([from], to)).toBeInstanceOf( - GiftCardOrderStateError, + const exits = terminal.flatMap((from) => + allStatuses + .filter((to) => checkGiftCardOrderTransition([from], to) === true) + .map((to) => `${from} → ${to}`), + ) + expect(exits).toEqual(["EXPIRED → PAID"]) + + const refusals = terminal.flatMap((from) => + allStatuses + .filter( + (to) => + !(from === GiftCardOrderStatus.Expired && to === GiftCardOrderStatus.Paid), ) - } - } + .map((to) => checkGiftCardOrderTransition([from], to)), + ) + expect(refusals).toHaveLength(terminal.length * allStatuses.length - 1) + expect(refusals.every((r) => r instanceof GiftCardOrderStateError)).toBe(true) + }) + + it("EXPIRED has exactly one exit, to PAID", () => { + expect(GIFT_CARD_TRANSITIONS.EXPIRED).toEqual([GiftCardOrderStatus.Paid]) }) }) diff --git a/test/flash/unit/services/mongoose/gift-card-orders.spec.ts b/test/flash/unit/services/mongoose/gift-card-orders.spec.ts index 1600dae71..9d6df2a6c 100644 --- a/test/flash/unit/services/mongoose/gift-card-orders.spec.ts +++ b/test/flash/unit/services/mongoose/gift-card-orders.spec.ts @@ -21,6 +21,7 @@ const create = jest.fn() const findOne = jest.fn() const findOneAndUpdate = jest.fn() const find = jest.fn() +const updateOne = jest.fn() jest.mock("@services/mongoose/schema", () => ({ GiftCardOrders: { @@ -28,6 +29,7 @@ jest.mock("@services/mongoose/schema", () => ({ findOne: (...args: unknown[]) => findOne(...args), findOneAndUpdate: (...args: unknown[]) => findOneAndUpdate(...args), find: (...args: unknown[]) => find(...args), + updateOne: (...args: unknown[]) => updateOne(...args), }, })) @@ -85,6 +87,7 @@ describe("GiftCardOrdersRepository", () => { findOne.mockReset() findOneAndUpdate.mockReset() find.mockReset() + updateOne.mockReset() }) describe("create", () => { @@ -265,6 +268,43 @@ describe("GiftCardOrdersRepository", () => { }) }) + describe("touch", () => { + // The reconcile worker bumps `updatedAt` after a poll that changed nothing, + // so `listByStatus` (oldest `updatedAt` first) rotates through a batch + // instead of pinning the same stuck rows at its front run after run. + it("sets only updatedAt, by id, and changes nothing else", async () => { + updateOne.mockResolvedValue({ matchedCount: 1, modifiedCount: 1 }) + + const result = await GiftCardOrdersRepository().touch(ORDER_ID) + + expect(result).toBe(true) + expect(updateOne).toHaveBeenCalledTimes(1) + const [filter, update] = updateOne.mock.calls[0] + expect(filter).toEqual({ id: { $eq: ORDER_ID } }) + expect(Object.keys(update)).toEqual(["$set"]) + expect(Object.keys(update.$set)).toEqual(["updatedAt"]) + expect(update.$set.updatedAt).toBeInstanceOf(Date) + // No status, no history entry: a touch is not a transition. + expect(findOneAndUpdate).not.toHaveBeenCalled() + }) + + it("reports not-found when no row matched", async () => { + updateOne.mockResolvedValue({ matchedCount: 0, modifiedCount: 0 }) + + const result = await GiftCardOrdersRepository().touch(ORDER_ID) + + expect(result).toBeInstanceOf(GiftCardOrderNotFoundError) + }) + + it("returns a repository error rather than throwing", async () => { + updateOne.mockRejectedValue(new Error("connection closed")) + + const result = await GiftCardOrdersRepository().touch(ORDER_ID) + + expect(result).toBeInstanceOf(UnknownRepositoryError) + }) + }) + describe("transition", () => { it("is one conditional findOneAndUpdate on the current status", async () => { findOneAndUpdate.mockResolvedValue( From b824da8d5ec3651784b3d3ea76b4a0014caa3ab1 Mon Sep 17 00:00:00 2001 From: Dread Date: Mon, 14 Sep 2026 15:10:24 -0700 Subject: [PATCH 07/14] =?UTF-8?q?chore(gift-cards):=20fix=20CI=20=E2=80=94?= =?UTF-8?q?=20typos=20words=20+=20prettier=203.6.2=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX --- docs/gift-cards/ARCHITECTURE.md | 2 +- src/domain/gift-cards/errors.ts | 2 +- src/services/alerts/ops-events.ts | 7 ++++++- src/services/gift-cards/catalog-cache.ts | 2 +- test/flash/unit/app/gift-cards/fixtures.ts | 5 ++++- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/gift-cards/ARCHITECTURE.md b/docs/gift-cards/ARCHITECTURE.md index 0b9b87fcd..c5a9bddba 100644 --- a/docs/gift-cards/ARCHITECTURE.md +++ b/docs/gift-cards/ARCHITECTURE.md @@ -42,7 +42,7 @@ the vendor's invoice. Flash orders, pays exactly once, and delivers the claim. Error names all carry the `GiftCard` prefix because `error-map.ts` switches on the constructor name across every error module; a collision would be a silent -mis-mapping. +mismatched mapping. ## The provider port diff --git a/src/domain/gift-cards/errors.ts b/src/domain/gift-cards/errors.ts index b1333aac3..273904f0f 100644 --- a/src/domain/gift-cards/errors.ts +++ b/src/domain/gift-cards/errors.ts @@ -4,7 +4,7 @@ import { DomainError, ErrorLevel } from "@domain/shared" * Gift card errors. All names carry the `GiftCard` prefix because * `ApplicationErrors` spreads every error module into one namespace and * `error-map.ts` switches on the constructor name — a collision there is a - * silent mis-mapping, not a compile error. + * silent mismatched mapping, not a compile error. */ export class GiftCardError extends DomainError { readonly level: ErrorLevel = ErrorLevel.Warn diff --git a/src/services/alerts/ops-events.ts b/src/services/alerts/ops-events.ts index da36156a3..05046fa06 100644 --- a/src/services/alerts/ops-events.ts +++ b/src/services/alerts/ops-events.ts @@ -23,7 +23,12 @@ import { */ export type OpsFlow = - "verification" | "upgrade" | "cashout" | "deposit" | "transfer" | "giftcard" + | "verification" + | "upgrade" + | "cashout" + | "deposit" + | "transfer" + | "giftcard" export type OpsStatus = "success" | "pending" | "failed" diff --git a/src/services/gift-cards/catalog-cache.ts b/src/services/gift-cards/catalog-cache.ts index 325b421d5..de8c70f29 100644 --- a/src/services/gift-cards/catalog-cache.ts +++ b/src/services/gift-cards/catalog-cache.ts @@ -299,7 +299,7 @@ export const GiftCardCatalogCache = () => { if (!syncedAt) { baseLogger.error( { providerId, countryCode: cc, syncedAt: stored.syncedAt }, - "gift card catalog record has an unparseable syncedAt", + "gift card catalog record has an unparsable syncedAt", ) return new GiftCardCatalogUnavailableError() } diff --git a/test/flash/unit/app/gift-cards/fixtures.ts b/test/flash/unit/app/gift-cards/fixtures.ts index c9e4bb891..de026536d 100644 --- a/test/flash/unit/app/gift-cards/fixtures.ts +++ b/test/flash/unit/app/gift-cards/fixtures.ts @@ -165,7 +165,10 @@ export const makeFakeOrdersRepo = () => { type Found = GiftCardOrder | GiftCardOrderNotFoundError | RepositoryError type Listed = GiftCardOrder[] | RepositoryError type Transitioned = - GiftCardOrder | GiftCardOrderStateError | GiftCardOrderNotFoundError | RepositoryError + | GiftCardOrder + | GiftCardOrderStateError + | GiftCardOrderNotFoundError + | RepositoryError const repo = { store, From f622de7d9003682b24d4626ebe7e4c89d2b326c7 Mon Sep 17 00:00:00 2001 From: Dread Date: Mon, 14 Sep 2026 17:52:37 -0700 Subject: [PATCH 08/14] fix(gift-cards): vendor paidPendingFulfillment vouches payment; expiry and pending exit holes (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/gift-cards/ARCHITECTURE.md | 14 +- src/app/gift-cards/reconcile-orders.ts | 99 +++++++++-- src/app/gift-cards/settle-order.ts | 40 ++++- .../app/gift-cards/reconcile-orders.spec.ts | 166 +++++++++++++++++- .../unit/app/gift-cards/settle-order.spec.ts | 129 +++++++++++++- 5 files changed, 412 insertions(+), 36 deletions(-) diff --git a/docs/gift-cards/ARCHITECTURE.md b/docs/gift-cards/ARCHITECTURE.md index c5a9bddba..024fa13e7 100644 --- a/docs/gift-cards/ARCHITECTURE.md +++ b/docs/gift-cards/ARCHITECTURE.md @@ -134,10 +134,10 @@ Who moves what: | `CREATED -> INVOICE_ISSUED` | `purchaseGiftCard`, after `provider.createOrder` and the tolerance check | | `CREATED/INVOICE_ISSUED -> FAILED` | `purchaseGiftCard` (`failUnpaidOrder`), `settleOrderFromVendor` on vendor `failed`/`refunded` before payment | | `INVOICE_ISSUED -> PAID / PAYMENT_PENDING / PAYMENT_FAILED` | `purchaseGiftCard` from the `PaymentSendStatus`, or from the send error's class: a proven refusal is `PAYMENT_FAILED`, anything else `PAYMENT_PENDING` (FLOWS 4a). Also entered by a same-key replay of an unpaid `INVOICE_ISSUED` order | -| `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX; also `PAYMENT_FAILED` (`payment-unresolved-expired`) when there is no IBEX ref, the vendor positively reports not paid, and now is past `expiresAt` + 24 h | -| `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment — including the reconcile worker's vendor fallback when IBEX cannot account for the send | +| `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX; also `PAYMENT_FAILED` (`payment-unresolved-expired`) when the vendor positively reports not paid and now is past `expiresAt` + 24 h (asked every run when there is no IBEX ref; past the 60-min warn horizon when IBEX still reports the send in flight) | +| `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment — including the reconcile worker's vendor fallback when IBEX cannot account for the send. For an INVOICE_ISSUED (or expired) row a vendor `paidPendingFulfillment` records payment the same way (`vendor-reported-payment`): it is the vendor vouching the payment settled | | `EXPIRED -> PAID` | `purchaseGiftCard` (`markPaidAndSettle`) on a late IBEX Success for an already-expired row, reason `payment-settled-after-expiry` | -| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`; an INVOICE_ISSUED row gets one vendor poll first (it never has an IBEX ref, so there is nothing to re-read at IBEX) | +| `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`; an INVOICE_ISSUED row gets one vendor poll first (it never has an IBEX ref, so there is nothing to re-read at IBEX) — a vendor error defers expiry to the next run, since EXPIRED is never revisited | | `PAID -> FULFILLED` | `settleOrderFromVendor` (`fulfil`) with the encrypted claim | | `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24 h in PAID **only** when the final vendor poll positively reports not fulfilled (`fulfillment-timeout`) — a vendor error keeps PAID and retries | @@ -249,6 +249,8 @@ transition; attempt timing is process memory, so the cron (a fresh process) polls each PAID order once per run and the trigger interval carries the real schedule. Horizons: PAID for 24 h becomes `REFUND_REQUIRED` only when the final vendor poll positively reports not fulfilled; PAYMENT_PENDING with no -resolvable IBEX status warns after 60 min and, with no ref, a vendor not-paid -and `expiresAt` + 24 h passed, becomes `PAYMENT_FAILED` -(`payment-unresolved-expired`). +resolvable IBEX status warns after 60 min and, with the vendor answering +not-paid and `expiresAt` + 24 h passed, becomes `PAYMENT_FAILED` +(`payment-unresolved-expired`) — with no ref the vendor is asked every run, and +with a ref still reported in flight by IBEX it is asked past that same 60-min +horizon so a send IBEX never closes out cannot pend forever. diff --git a/src/app/gift-cards/reconcile-orders.ts b/src/app/gift-cards/reconcile-orders.ts index 4446bf180..790e3743c 100644 --- a/src/app/gift-cards/reconcile-orders.ts +++ b/src/app/gift-cards/reconcile-orders.ts @@ -39,11 +39,16 @@ const logger = baseLogger.child({ module: "gift-cards.reconcile" }) * INVOICE_ISSUED row never carries an * IBEX ref — every path that writes one * leaves the state — so the vendor is - * the only one who can vouch for it) + * the only one who can vouch for it; + * `fulfilled` and `paidPendingFulfillment` + * both count as that vouch, and an + * unreachable vendor defers expiry) * PAYMENT_PENDING → PAID | PAYMENT_FAILED (payment re-read); * IBEX unknown / no ref → vendor poll; - * no ref + vendor "awaiting payment" + - * 24h past `expiresAt` → PAYMENT_FAILED + * IBEX in flight past the warn horizon + * → vendor poll too; + * vendor "awaiting payment" + 24h past + * `expiresAt` → PAYMENT_FAILED * PAID → poll vendor with backoff → FULFILLED * | REFUND_REQUIRED; 24h → final vendor * poll, then REFUND_REQUIRED only if the @@ -54,10 +59,11 @@ const logger = baseLogger.child({ module: "gift-cards.reconcile" }) * expiry comes back settled. * * The vendor is the arbiter of last resort: `settleOrderFromVendor` accepts a - * vendor `fulfilled` on an INVOICE_ISSUED, PAYMENT_PENDING or EXPIRED order as - * proof of payment, so an order whose send IBEX cannot account for (no - * transaction id, IBEX unreachable, an error after IBEX accepted the request) - * is never written off while the vendor says a card shipped. Conversely the + * vendor `fulfilled` or `paidPendingFulfillment` on an INVOICE_ISSUED or EXPIRED + * order as proof of payment (and `fulfilled` on a PAYMENT_PENDING one), so an + * order whose send IBEX cannot account for (no transaction id, IBEX + * unreachable, an error after IBEX accepted the request) is never written off + * while the vendor says the payment settled. Conversely the * 24h escalation to REFUND_REQUIRED needs the vendor's positive "not * fulfilled": a 5xx, a claim-key fault or a repository error says nothing about * the card, so the order stays PAID and the next run asks again. @@ -294,6 +300,10 @@ type VendorPoll = { moved: GiftCardOrder | null } +/** Vendor statuses that positively vouch the payment settled. */ +const isVendorPaymentVouch = (kind: GiftCardProviderOrderStatus["kind"]): boolean => + kind === "fulfilled" || kind === "paidPendingFulfillment" + /** * Ask the vendor about an order IBEX could not account for, and settle on the * answer. `moved` is null when the order stayed put (still awaiting payment, @@ -371,13 +381,30 @@ const processExpiry = async ( // errored before recording it. It never carries an IBEX transaction id // (every path that captures one leaves INVOICE_ISSUED in the same write), so // there is nothing to re-read at IBEX; the vendor gets the last word: a card - // that shipped is proof of payment, and `settleOrderFromVendor` records it - // as such. Only an invoice the vendor does not know as paid is expired — and - // if a send was still in flight at IBEX, a late Success revives it + // that shipped — or a payment the vendor reports settled and is fulfilling — + // is proof of payment, and `settleOrderFromVendor` records it as such. Only + // an invoice the vendor has answered and does not know as paid is expired — + // and if a send was still in flight at IBEX, a late Success revives it // (EXPIRED → PAID) from the purchase path. - if (order.status === GiftCardOrderStatus.InvoiceIssued) { - const { moved } = await pollVendor(order, summary) + // + // A vendor poll that could not be asked (unreachable, 5xx) is NOT an answer: + // EXPIRED is never revisited by this worker, so expiring on one transient + // outage would bury a possibly-paid order with no later chance for the + // vendor to vouch for it. Leave the row INVOICE_ISSUED for the next run — + // the invoice's own TTL bounds how long a payment could still land, and the + // vendor outage ends. + if (order.status === GiftCardOrderStatus.InvoiceIssued && order.providerOrderId) { + const { vendorStatus, moved } = await pollVendor(order, summary) if (moved) return + // Expire only on a vendor ANSWER that does not report payment. An + // unreachable vendor says nothing, and a payment vouch whose settlement + // did not record (a repository fault on the transition) is the opposite of + // a write-off — both leave the row INVOICE_ISSUED for the next run, + // touched so the batch keeps rotating. + if (vendorStatus === null || isVendorPaymentVouch(vendorStatus)) { + await touch(order) + return + } } const expired = await GiftCardOrdersRepository().transition({ @@ -458,7 +485,53 @@ const processPendingPayment = async ( break } case "pending": - // IBEX still reports the send in flight; its word beats a vendor poll. + // IBEX still reports the send in flight; its word beats a vendor poll — + // while the send is young. An IN_FLIGHT transaction record that never + // resolves (stuck routing, a payment record IBEX never closes) would + // otherwise pend forever: the escalation below requires a vendor answer, + // and this arm never asks for one. Past the warn horizon, ask the vendor + // too — it costs one poll per run on a state that is already abnormal — + // so a send the vendor has actually settled or shipped is not held + // hostage to IBEX's in-flight record. The vendor's word only ever moves + // the row forward (PAID via its vouch, or fulfilment); the terminal + // PAYMENT_FAILED exit still needs the vendor's positive "never paid", + // which an in-flight IBEX status contradicts only until the vendor + // itself says so. + if (order.providerPaymentRef) { + const pendingSinceIbex = lastTransitionAt( + order, + GiftCardOrderStatus.PaymentPending, + ) + if (now.getTime() - pendingSinceIbex.getTime() > PAYMENT_PENDING_WARN_MS) { + const { vendorStatus, moved } = await pollVendor(order, summary) + if (moved) return + if ( + vendorStatus === "awaitingPayment" && + now.getTime() > + order.expiresAt.getTime() + GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + ) { + const failed = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.PaymentFailed, + reason: "payment-unresolved-expired", + patch: { failureReason: "payment-unresolved-expired" }, + }) + if (failed instanceof Error) throw failed + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order: failed, + meta: { + reason: "payment-unresolved-expired", + vendorStatus, + ibex: "in-flight", + }, + }) + return + } + } + } break } diff --git a/src/app/gift-cards/settle-order.ts b/src/app/gift-cards/settle-order.ts index ca9c54b59..2d2aa32c7 100644 --- a/src/app/gift-cards/settle-order.ts +++ b/src/app/gift-cards/settle-order.ts @@ -22,6 +22,12 @@ import { sendGiftCardFulfilledNotificationBestEffort } from "./send-fulfilled-no * are decided by the repository's conditional transition — the loser gets a * `GiftCardOrderStateError`, re-reads, and returns whatever won. * + * The vendor is the arbiter of last resort on PAYMENT, not only fulfilment: a + * `fulfilled` (card shipped) or `paidPendingFulfillment` (payment settled, + * fulfilment under way) on an INVOICE_ISSUED or EXPIRED order records the + * payment we never did. Both are handled here, so every caller — purchase + * poll, reconcile worker, future webhook — gets the same answer. + * * Claim data enters here as plaintext from the adapter and leaves only as * ciphertext on the order. It is never logged, traced, or put in an ops event. */ @@ -38,8 +44,9 @@ export const settleOrderFromVendor = async ( switch (status.kind) { case "awaitingPayment": - case "paidPendingFulfillment": return order + case "paidPendingFulfillment": + return vendorVouchedPayment(order) case "fulfilled": return fulfil(order, status.claim) case "failed": @@ -48,6 +55,37 @@ export const settleOrderFromVendor = async ( } } +/** + * `paidPendingFulfillment` is the vendor positively vouching that the payment + * has SETTLED and fulfilment is under way (the adapter buckets its + * pending / senttofulfillment — and any unrecognised or dispute-held — status + * there). For an order whose payment we never recorded, that vouch is proof of + * payment just as much as a shipped card is: record it and let the normal PAID + * poll path finish fulfilment. A PAYMENT_PENDING row keeps its payment re-read + * as the authority; a PAID row is already past this. + */ +const vendorVouchedPayment = async ( + order: GiftCardOrder, +): Promise => { + if ( + order.status !== GiftCardOrderStatus.InvoiceIssued && + order.status !== GiftCardOrderStatus.Expired + ) { + return order + } + + const paid = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.Expired], + to: GiftCardOrderStatus.Paid, + reason: "vendor-reported-payment", + patch: { paidSats: order.invoiceSats ?? order.quoteSats }, + }) + if (paid instanceof Error) return paid + notifyGiftCardOpsEvent({ phase: "order-paid", status: "success", order: paid }) + return paid +} + const fulfil = async ( order: GiftCardOrder, claim: GiftCardClaim, diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index d56399251..bcf8688e3 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -156,6 +156,33 @@ const vendorFulfils = () => return fulfilled }) +/** + * The vendor reports the payment SETTLED but not yet fulfilled + * (`paidPendingFulfillment`): the real `settleOrderFromVendor` records it as + * `vendor-reported-payment` and lets the PAID poll path finish. Mirrors that + * behaviour for the unaccounted-payment path, the way `vendorFulfils` mirrors + * the fulfilled arm. + */ +const vendorVouchesPaymentImpl = (o: GiftCardOrder): GiftCardOrder => { + if (o.status !== "INVOICE_ISSUED" && o.status !== "EXPIRED") return o + const paid: GiftCardOrder = { + ...o, + status: "PAID", + paidSats: o.paidSats ?? o.invoiceSats ?? o.quoteSats, + statusHistory: [ + ...o.statusHistory, + { status: "PAID", at: new Date(clock), reason: "vendor-reported-payment" }, + ], + } + repo.store.set(o.id, paid) + return paid +} + +const vendorVouchesPayment = () => + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => + vendorVouchesPaymentImpl(o), + ) + beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers({ now: NOW_MS }) @@ -171,7 +198,8 @@ beforeEach(() => { // The unaccounted-payment path asks the vendor in two halves (it needs the // vendor's own answer, not just its effect on the order). By default the // vendor says "still working" and settling delegates to `mockFetchAndSettle`, - // so `vendorFulfils()` drives both paths the same way. + // so `vendorFulfils()` drives both paths the same way; `vendorVouchesPayment()` + // swaps in the payment-vouch settlement for the tests that need it. mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) mockSettleFromVendor.mockImplementation(async (order: GiftCardOrder) => mockFetchAndSettle(order), @@ -267,16 +295,21 @@ describe("reconcileGiftCardOrders", () => { ) }) - it("INVOICE_ISSUED past expiresAt never consults IBEX: no path writes a ref without leaving the state", async () => { + it("INVOICE_ISSUED past expiresAt never consults IBEX: the vendor's payment vouch settles it, and the PAID path finishes", async () => { // Every transition that captures an IBEX transaction id also leaves // INVOICE_ISSUED in the same write, so an INVOICE_ISSUED row has nothing - // to re-read. The vendor is the only party who can vouch for it. + // to re-read. The vendor is the only party who can vouch for it — and + // `paidPendingFulfillment` (the vendor reporting the payment SETTLED, card + // in fulfilment) is that vouch: it is recorded as vendor-reported-payment + // and the normal PAID poll path finishes fulfilment, never EXPIRED. + vendorVouchesPayment() repo.seed( makeOrder({ id: "i" as GiftCardOrderId, status: "INVOICE_ISSUED", providerOrderId: "tbc-i" as GiftCardProviderOrderId, paymentHash: PAYMENT_HASH, + invoiceSats: 40_100 as Satoshis, expiresAt: at(-1), }), ) @@ -284,8 +317,15 @@ describe("reconcileGiftCardOrders", () => { if (res instanceof Error) throw res expect(mockGetTransactionDetails).not.toHaveBeenCalled() expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) - expect(res.expired).toBe(1) - expect(repo.store.get("i")?.status).toBe("EXPIRED") + expect(res.expired).toBe(0) + expect(res.paymentSettled).toBe(1) + const after = repo.store.get("i") + expect(after?.status).toBe("PAID") + expect(after?.paidSats).toBe(40_100) + expect(after?.statusHistory.at(-1)).toMatchObject({ + status: "PAID", + reason: "vendor-reported-payment", + }) }) it("INVOICE_ISSUED past expiresAt with no vendor order id -> EXPIRED without a vendor call", async () => { @@ -329,7 +369,11 @@ describe("reconcileGiftCardOrders", () => { expect(repo.store.get("i")?.status).toBe("FULFILLED") }) - it("INVOICE_ISSUED past expiresAt with no ref and nothing at the vendor -> EXPIRED", async () => { + it("INVOICE_ISSUED past expiresAt with the vendor reporting awaitingPayment -> EXPIRED (no payment vouch, nothing was paid)", async () => { + // The write-off is safe only against a vendor ANSWER that does not + // vouch for payment: "we never saw the money" is that answer. + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => o) repo.seed( makeOrder({ id: "i" as GiftCardOrderId, @@ -342,7 +386,7 @@ describe("reconcileGiftCardOrders", () => { ) const res = await runAt(0) if (res instanceof Error) throw res - expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(mockSettleFromVendor).toHaveBeenCalledTimes(1) expect(res.expired).toBe(1) expect(repo.store.get("i")?.status).toBe("EXPIRED") }) @@ -382,7 +426,12 @@ describe("reconcileGiftCardOrders", () => { }, ) - it("INVOICE_ISSUED past expiresAt with the vendor unreachable is expired (the vendor could not vouch for it)", async () => { + it("INVOICE_ISSUED past expiresAt with the vendor unreachable stays INVOICE_ISSUED (a non-answer cannot write off a maybe-paid order)", async () => { + // EXPIRED is never revisited by the worker, so a single transient vendor + // outage at the expiry poll must not bury a possibly-paid order with no + // later chance for the vendor to vouch for it. The row defers expiry — + // touched, so the batch keeps rotating — and expires on the next run the + // vendor answers. mockFetchVendorStatus.mockResolvedValue(new GiftCardVendorUnavailableError()) repo.seed( makeOrder({ @@ -395,7 +444,17 @@ describe("reconcileGiftCardOrders", () => { const res = await runAt(0) if (res instanceof Error) throw res expect(mockSettleFromVendor).not.toHaveBeenCalled() - expect(res.expired).toBe(1) + expect(res.expired).toBe(0) + expect(repo.store.get("i")?.status).toBe("INVOICE_ISSUED") + expect(repo.touch).toHaveBeenCalledWith("i") + // ...and once the vendor answers "never paid", the expiry fires. + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => o) + // Second run, 30s later — the vendor now answers, so the expiry fires. + const second = await runAt(30 * SECOND) + if (second instanceof Error) throw second + expect(second.expired).toBe(1) + expect(repo.store.get("i")?.status).toBe("EXPIRED") }) it("EXPIRED orders are never listed or polled: their one exit is the purchase path's", async () => { @@ -473,6 +532,95 @@ describe("reconcileGiftCardOrders", () => { expect(mockFetchAndSettle).not.toHaveBeenCalled() }) + describe("in flight at IBEX past the warn horizon (a transaction record that never resolves)", () => { + // An IN_FLIGHT IBEX send with no terminal exit otherwise pends forever: + // the escalation needs a vendor answer, and the in-flight arm never + // asked for one. Past the warn horizon the vendor is polled too, so a + // payment the vendor actually settled is not held hostage to IBEX's + // stuck record. + const inFlight = () => { + mockGetTransactionDetails.mockResolvedValue(ibexTransaction(1)) + return repo.seed( + makeOrder({ + id: "stuck" as GiftCardOrderId, + status: "PAYMENT_PENDING", + paymentHash: PAYMENT_HASH, + providerPaymentRef: "ibex-tx", + invoiceSats: 40_100 as Satoshis, + providerOrderId: "tbc-stuck" as GiftCardProviderOrderId, + statusHistory: [ + { status: "CREATED", at: at(-2 * HOUR), reason: null }, + { status: "INVOICE_ISSUED", at: at(-1.5 * HOUR), reason: null }, + { status: "PAYMENT_PENDING", at: at(-2 * MINUTE), reason: null }, + ], + }), + ) + } + + it("inside the warn horizon the vendor is not asked", async () => { + inFlight() + const res = await runAt(30 * MINUTE) + if (res instanceof Error) throw res + expect(mockFetchVendorStatus).not.toHaveBeenCalled() + expect(repo.store.get("stuck")?.status).toBe("PAYMENT_PENDING") + }) + + it("past the warn horizon the vendor is asked; a vouch without fulfilment still defers to the payment re-read", async () => { + // `paidPendingFulfillment` does not settle a PAYMENT_PENDING row — the + // IBEX re-read owns that answer — but the poll happened, and the row + // was touched for rotation. + inFlight() + const res = await runAt(61 * MINUTE) + if (res instanceof Error) throw res + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) + expect(mockSettleFromVendor).toHaveBeenCalledTimes(1) + expect(res.paymentSettled).toBe(0) + expect(repo.store.get("stuck")?.status).toBe("PAYMENT_PENDING") + expect(repo.touch).toHaveBeenCalledWith("stuck") + }) + + it("past the warn horizon a vendor fulfilled settles and fulfils", async () => { + inFlight() + vendorFulfils() + const res = await runAt(61 * MINUTE) + if (res instanceof Error) throw res + expect(repo.store.get("stuck")?.status).toBe("FULFILLED") + expect(res.fulfilled).toBe(1) + }) + + it("past the warn horizon and past the grace, a vendor not-paid escalates to PAYMENT_FAILED with the in-flight status in the event", async () => { + inFlight() + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => o) + const res = await runAt(GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + 2 * HOUR) + if (res instanceof Error) throw res + const after = repo.store.get("stuck") + expect(after?.status).toBe("PAYMENT_FAILED") + expect(after?.failureReason).toBe("payment-unresolved-expired") + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "order-failed", + status: "failed", + meta: expect.objectContaining({ + orderId: "stuck", + reason: "payment-unresolved-expired", + vendorStatus: "awaitingPayment", + ibex: "in-flight", + }), + }), + ) + }) + + it("past the warn horizon a vendor error leaves the order pending", async () => { + inFlight() + mockFetchVendorStatus.mockResolvedValue(new GiftCardVendorUnavailableError()) + const res = await runAt(61 * MINUTE) + if (res instanceof Error) throw res + expect(repo.store.get("stuck")?.status).toBe("PAYMENT_PENDING") + expect(mockSettleFromVendor).not.toHaveBeenCalled() + }) + }) + it("unknown at IBEX asks the vendor and stays PAYMENT_PENDING when it has nothing new", async () => { pending() const res = await runAt(0) diff --git a/test/flash/unit/app/gift-cards/settle-order.spec.ts b/test/flash/unit/app/gift-cards/settle-order.spec.ts index cab3fe52f..23982207d 100644 --- a/test/flash/unit/app/gift-cards/settle-order.spec.ts +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -381,15 +381,130 @@ describe("settleOrderFromVendor", () => { }) describe("non-terminal vendor statuses", () => { - for (const kind of ["awaitingPayment", "paidPendingFulfillment"] as const) { - it(`${kind} returns the order untouched`, async () => { - const order = paidOrder() - const res = await settleOrderFromVendor(order, { kind }) - expect(res).toBe(order) - expect(repo.transition).not.toHaveBeenCalled() + it("awaitingPayment returns the order untouched", async () => { + const order = paidOrder() + const res = await settleOrderFromVendor(order, { kind: "awaitingPayment" }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + + it("paidPendingFulfillment on a PAID order returns the order untouched", async () => { + // Already past the vouch: the payment is recorded and the PAID poll path + // owns fulfilment. + const order = paidOrder() + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + + it("paidPendingFulfillment on a PAYMENT_PENDING order returns the order untouched: the payment re-read owns that answer", async () => { + // The reconcile worker's IBEX re-read decides PAID vs PAYMENT_FAILED for + // a row with a live send; the vendor's vouch must not race it. + const order = repo.seed( + makeOrder({ + status: "PAYMENT_PENDING", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + providerPaymentRef: "ibex-tx" as IbexTransactionId, + }), + ) + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }) + + describe("paidPendingFulfillment as vendor-reported payment", () => { + // `pending` / `senttofulfillment` at the vendor mean the payment has + // SETTLED and fulfilment is under way. For a row whose payment we never + // recorded, that is proof of payment — the same class of vouch as a + // shipped card — and EXPIRED (never polled again by the worker) must not + // be its resting state. + const invoiceIssued = () => + repo.seed( + makeOrder({ + status: "INVOICE_ISSUED", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + invoiceSats: 40_100 as Satoshis, + statusHistory: [ + { status: "CREATED", at: new Date(NOW_MS - 2000), reason: null }, + { status: "INVOICE_ISSUED", at: new Date(NOW_MS - 1000), reason: null }, + ], + }), + ) + + it("INVOICE_ISSUED -> PAID (reason vendor-reported-payment, paidSats from the invoice) and an order-paid ops event", async () => { + const order = invoiceIssued() + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.paidSats).toBe(40_100) + expect(res.statusHistory.at(-1)).toMatchObject({ + status: "PAID", + reason: "vendor-reported-payment", + }) + expect(opsPhases()).toEqual(["order-paid"]) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + flow: "giftcard", + status: "success", + }) + // No claim was involved; nothing fulfilment-shaped may have happened. + expect(mockEncrypt).not.toHaveBeenCalled() + expect(mockSendFulfilledPush).not.toHaveBeenCalled() + }) + + it("falls back to quoteSats when no invoice was recorded", async () => { + const order = repo.seed( + makeOrder({ + status: "INVOICE_ISSUED", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + }), + ) + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + if (res instanceof Error) throw res + expect(res.paidSats).toBe(40_000) + }) + + it("EXPIRED -> PAID: the vouch revives the write-off the same way a fulfilled vouch does", async () => { + const order = repo.seed( + makeOrder({ + status: "EXPIRED", + failureReason: "expired", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + invoiceSats: 40_100 as Satoshis, + expiresAt: new Date(NOW_MS - 60_000), + }), + ) + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.statusHistory.at(-1)?.reason).toBe("vendor-reported-payment") + expect(opsPhases()).toEqual(["order-paid"]) + }) + + it("a transition fault (lost race) returns the error", async () => { + const order = invoiceIssued() + repo.transition.mockResolvedValueOnce( + new GiftCardOrderStateError("raced into PAID"), + ) + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + expect(res).toBeInstanceOf(GiftCardOrderStateError) expect(mockNotifyOpsEvent).not.toHaveBeenCalled() }) - } + }) }) }) From 8aebdb89ac4bc4b36b9760a009fceb7dfb374ab4 Mon Sep 17 00:00:00 2001 From: Dread Date: Mon, 14 Sep 2026 18:26:22 -0700 Subject: [PATCH 09/14] fix(gift-cards): no-ref PAYMENT_PENDING defers to the vendor's vouches (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/gift-cards/ALERTING.md | 2 +- docs/gift-cards/FLOWS.md | 53 ++++++++--- src/app/gift-cards/settle-order.ts | 74 ++++++++++++--- .../app/gift-cards/reconcile-orders.spec.ts | 71 +++++++++++--- .../unit/app/gift-cards/settle-order.spec.ts | 95 ++++++++++++++++++- 5 files changed, 253 insertions(+), 42 deletions(-) diff --git a/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md index 5bda0b470..a379de159 100644 --- a/docs/gift-cards/ALERTING.md +++ b/docs/gift-cards/ALERTING.md @@ -24,7 +24,7 @@ event by construction: the helper takes an order, not a claim. | --- | --- | --- | --- | --- | | `order-created` | pending | `purchaseGiftCard` | Row written, vendor not yet called | none | | `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight, **or** returned a 200 naming no recognised status (`paymentSendStatusOrPending` converts it to Pending) — both `meta.reason` `payment-pending`; or the send errored without a verdict — socket reset, gateway 5xx, timeout after the request was accepted (`meta.reason` `payment-unconfirmed: `, `error` naming the class) | none; worker resolves against IBEX, then the vendor. Warn if the same order is still pending after 60 min (see log line below). A burst of `payment-unconfirmed` is an IBEX connectivity problem | -| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card. `statusHistory` reason is `payment-settled`, `vendor-reported-fulfilled`, `payment-settled-on-reconcile` or `payment-settled-after-expiry` (a late Success on an EXPIRED row) | none | +| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card. `statusHistory` reason is `payment-settled`, `vendor-reported-fulfilled`, `vendor-reported-payment` (a vendor `paidPendingFulfillment` vouch on an INVOICE_ISSUED/EXPIRED row, or a ref-less PAYMENT_PENDING one whose IBEX re-read could never answer), `payment-settled-on-reconcile` or `payment-settled-after-expiry` (a late Success on an EXPIRED row) | none | | `order-fulfilled` | success | `settleOrderFromVendor` | Claim stored; push sent | none | | `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment. `meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: ` (only for a send IBEX provably refused), `payment-failed`, `expired`, `payment-unresolved-expired` (pending with no IBEX ref, vendor says unpaid, 24 h past `expiresAt`). For a vendor cancel before payment the event's `error` is `vendor-failed` / `vendor-refunded` and `meta.reason` the raw vendor status; `vendor-failed: ` is the `statusHistory` reason | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | | `paid-not-recorded` | failed | `purchaseGiftCard` | IBEX reported Success but the `PAID` write failed (lost race or store fault). The order is still returned to the client; until the worker or an operator records it, the row shows unpaid for money that has left the wallet. `meta.providerPaymentRef` is the IBEX transaction id | **Page.** RUNBOOK (i) | diff --git a/docs/gift-cards/FLOWS.md b/docs/gift-cards/FLOWS.md index c3cbf1938..9f7199776 100644 --- a/docs/gift-cards/FLOWS.md +++ b/docs/gift-cards/FLOWS.md @@ -112,9 +112,18 @@ UC Repo IBEX Worker (30s) | PAID->FULFILLED, push | | failed -> PAYMENT_PENDING->PAYMENT_FAILED | [ops: order-failed] - | pending -> leave (IBEX's word beats a vendor poll) + | pending -> leave while young (IBEX's word); + | past the 60-min warn horizon the vendor is polled too: + | vouch (fulfilled / paidPendingFulfillment on a no-ref row) + | -> PAID (vendor-reported-payment) -> normal PAID path + | not paid AND now > expiresAt + 24h + | -> PAYMENT_FAILED (payment-unresolved-expired, + | meta carries ibex: in-flight) + | else leave; warn after 60 min | unknown / no ref -> fetchAndSettle ------------->| | fulfilled -> PAID -> FULFILLED + | paidPendingFulfillment (no ref) -> PAID + | (vendor-reported-payment); the PAID path finishes | not paid AND now > expiresAt + 24h | -> PAYMENT_FAILED (payment-unresolved-expired) | else leave; warn after 60 min @@ -123,11 +132,19 @@ UC Repo IBEX Worker (30s) An order with **no** `providerPaymentRef` (the send errored before IBEX handed back an id, or a crash between the IBEX call and the transition that writes it) answers "unknown" every run: there is no hash-based fallback on the IBEX -rail, so the worker asks the vendor; a vendor `fulfilled` is proof of payment -(below). While the vendor reports the invoice unpaid the order stays pending -(RUNBOOK c after 60 min) until 24 h past `expiresAt`, when it is closed as -`PAYMENT_FAILED` (`payment-unresolved-expired`). A vendor error leaves it -pending for the next run. +rail, so the worker asks the vendor, and the vendor is the only arbiter this +row will ever have. A vendor `fulfilled` is proof of payment, and so is a +vendor `paidPendingFulfillment` — the payment re-read it would normally defer +to is hardwired to "unknown" without a ref, so `settleOrderFromVendor` takes +the vouch and records `PAID` (reason `vendor-reported-payment`); the normal +PAID poll path finishes fulfilment, and if the vouch proves wrong the PAID +path's 24 h `REFUND_REQUIRED` timeout is the safety net. A vendor `refunded` +likewise proves money moved: `PAID` then `REFUND_REQUIRED` (which pages). +While the vendor reports the invoice unpaid (`awaitingPayment`, or a plain +`failed`) the order stays pending (RUNBOOK c after 60 min) until 24 h past +`expiresAt`, when it is closed as `PAYMENT_FAILED` +(`payment-unresolved-expired`). A vendor error leaves it pending for the next +run. ### 4a. A send error without a verdict @@ -155,7 +172,10 @@ A Success for a row the worker expired while the send was in flight is written If the vendor reports `fulfilled` while Flash still shows `INVOICE_ISSUED` or `PAYMENT_PENDING`, `settleOrderFromVendor` takes the vendor's word as proof of -payment: `PAID` (reason `vendor-reported-fulfilled`), then `FULFILLED`. +payment: `PAID` (reason `vendor-reported-fulfilled`), then `FULFILLED`. A +`paidPendingFulfillment` does the same for an `INVOICE_ISSUED`, `EXPIRED`, or +ref-less `PAYMENT_PENDING` row (`vendor-reported-payment`); a `PAYMENT_PENDING` +row **with** an IBEX ref keeps its re-read as the authority. ## 5. Paid, then the vendor cancels (REFUND_REQUIRED) @@ -191,7 +211,13 @@ Worker Repo Vendor | processExpiry: | | | INVOICE_ISSUED? fetchAndSettle (vendor) --------------------------------->| | fulfilled -> PAID (vendor-reported-fulfilled) -> FULFILLED - | anything else (not paid, vendor error) -> fall through | + | paidPendingFulfillment -> PAID (vendor-reported-payment); + | the normal PAID poll path finishes fulfilment + | vendor error -> fall through NOT taken: expiry is deferred + | to the next run (a non-answer must not write off a + | maybe-paid row EXPIRED never revisits) + | anything else (a vendor answer that does not vouch + | payment, e.g. awaitingPayment) -> fall through | | transition CREATED|INVOICE_ISSUED->EXPIRED (reason expired) | [ops: order-failed, reason expired] | ``` @@ -203,10 +229,13 @@ never carries a `providerPaymentRef` (the ref is written with the transition out of it), so IBEX could only answer "unknown". A row that crashed before the IBEX call has no payment and is correctly expired; one that crashed *after* the call but before the ref was written looks the same, so the worker asks the -vendor first: a card that shipped is proof of payment. Two safety nets remain -for a payment that surfaces later: a same-key replay before expiry resumes it -(flow 7), and a late IBEX Success on an `EXPIRED` row is written `EXPIRED -> -PAID` (flow 4a). The worker never polls `EXPIRED`. +vendor first: a card that shipped, or a payment the vendor reports settled and +is fulfilling, is proof of payment. A vendor error is not an answer: it defers +expiry, since EXPIRED is never revisited and one outage must not bury a +possibly-paid order. Two safety nets remain for a payment that surfaces later: +a same-key replay before expiry resumes it (flow 7), and a late IBEX Success on +an `EXPIRED` row is written `EXPIRED -> PAID` (flow 4a). The worker never polls +`EXPIRED`. ## 7. Idempotent replay / double tap diff --git a/src/app/gift-cards/settle-order.ts b/src/app/gift-cards/settle-order.ts index 2d2aa32c7..bc0558992 100644 --- a/src/app/gift-cards/settle-order.ts +++ b/src/app/gift-cards/settle-order.ts @@ -25,8 +25,11 @@ import { sendGiftCardFulfilledNotificationBestEffort } from "./send-fulfilled-no * The vendor is the arbiter of last resort on PAYMENT, not only fulfilment: a * `fulfilled` (card shipped) or `paidPendingFulfillment` (payment settled, * fulfilment under way) on an INVOICE_ISSUED or EXPIRED order records the - * payment we never did. Both are handled here, so every caller — purchase - * poll, reconcile worker, future webhook — gets the same answer. + * payment we never did, and `paidPendingFulfillment` does the same for a + * PAYMENT_PENDING order with no `providerPaymentRef` — its payment re-read is + * hardwired to "unknown", so the vendor is the only arbiter there is. All are + * handled here, so every caller — purchase poll, reconcile worker, future + * webhook — gets the same answer. * * Claim data enters here as plaintext from the adapter and leaves only as * ciphertext on the order. It is never logged, traced, or put in an ops event. @@ -61,22 +64,36 @@ export const settleOrderFromVendor = async ( * pending / senttofulfillment — and any unrecognised or dispute-held — status * there). For an order whose payment we never recorded, that vouch is proof of * payment just as much as a shipped card is: record it and let the normal PAID - * poll path finish fulfilment. A PAYMENT_PENDING row keeps its payment re-read - * as the authority; a PAID row is already past this. + * poll path finish fulfilment. A PAYMENT_PENDING row **with** an IBEX ref keeps + * its payment re-read as the authority and is left alone; a PAID row is already + * past this. A PAYMENT_PENDING row with NO ref is accepted: its re-read is + * hardwired to "unknown" (`lookupSentPaymentStatus` never asks IBEX without a + * ref), so the vendor is the only possible arbiter — the exclusion's usual + * rationale (the re-read owns the answer) has nothing to stand on. If the vouch + * proves wrong, the PAID path's 24 h fulfilment timeout and the vendor-positive + * REFUND_REQUIRED escalation are the safety net. A PAID row is already past + * this. */ const vendorVouchedPayment = async ( order: GiftCardOrder, ): Promise => { if ( order.status !== GiftCardOrderStatus.InvoiceIssued && - order.status !== GiftCardOrderStatus.Expired + order.status !== GiftCardOrderStatus.Expired && + !(order.status === GiftCardOrderStatus.PaymentPending && !order.providerPaymentRef) ) { return order } const paid = await GiftCardOrdersRepository().transition({ id: order.id, - from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.Expired], + from: [ + GiftCardOrderStatus.InvoiceIssued, + GiftCardOrderStatus.Expired, + ...(order.status === GiftCardOrderStatus.PaymentPending + ? [GiftCardOrderStatus.PaymentPending] + : []), + ], to: GiftCardOrderStatus.Paid, reason: "vendor-reported-payment", patch: { paidSats: order.invoiceSats ?? order.quoteSats }, @@ -238,15 +255,42 @@ const vendorFailed = async ( }) return failed } - case GiftCardOrderStatus.PaymentPending: - // Our payment may still be in flight. Whether it settled decides - // PAID→REFUND_REQUIRED or PAYMENT_FAILED; that is the reconcile worker's - // payment re-read, not the vendor's word. - baseLogger.warn( - { orderId: order.id, vendorStatus: status.kind }, - "Vendor reports failed/refunded while our payment is pending; leaving for payment re-read", - ) - return order + case GiftCardOrderStatus.PaymentPending: { + // With an IBEX ref our payment may still be in flight; whether it settled + // decides PAID→REFUND_REQUIRED or PAYMENT_FAILED, and that is the + // reconcile worker's payment re-read, not the vendor's word. + if (order.providerPaymentRef) { + baseLogger.warn( + { orderId: order.id, vendorStatus: status.kind }, + "Vendor reports failed/refunded while our payment is pending; leaving for payment re-read", + ) + return order + } + // No ref: the re-read is hardwired to "unknown", so the vendor is the + // only arbiter — but a plain "failed" still says nothing about money + // that may have left, so only a refund acts. "The vendor refunded it" + // positively means they received AND returned the payment: a terminal + // answer. Land on PAID (the only state that means money left Flash) and + // let the normal PAID path carry the vendor's failure to + // REFUND_REQUIRED, which pages. + if (status.kind !== "refunded") { + baseLogger.warn( + { orderId: order.id, vendorStatus: status.kind }, + "Vendor reports failed while our payment has no IBEX ref; leaving for the unresolved escalation", + ) + return order + } + const paid = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.Paid, + reason: "vendor-reported-payment", + patch: { paidSats: order.invoiceSats ?? order.quoteSats }, + }) + if (paid instanceof Error) return paid + notifyGiftCardOpsEvent({ phase: "order-paid", status: "success", order: paid }) + return vendorFailed(paid, status) + } default: return order } diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index bcf8688e3..e22a4d538 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -161,10 +161,15 @@ const vendorFulfils = () => * (`paidPendingFulfillment`): the real `settleOrderFromVendor` records it as * `vendor-reported-payment` and lets the PAID poll path finish. Mirrors that * behaviour for the unaccounted-payment path, the way `vendorFulfils` mirrors - * the fulfilled arm. + * the fulfilled arm — including the no-ref PAYMENT_PENDING case, whose IBEX + * re-read is hardwired to "unknown" so the vendor is its only arbiter. */ const vendorVouchesPaymentImpl = (o: GiftCardOrder): GiftCardOrder => { - if (o.status !== "INVOICE_ISSUED" && o.status !== "EXPIRED") return o + const vouched = + o.status === "INVOICE_ISSUED" || + o.status === "EXPIRED" || + (o.status === "PAYMENT_PENDING" && !o.providerPaymentRef) + if (!vouched) return o const paid: GiftCardOrder = { ...o, status: "PAID", @@ -673,13 +678,14 @@ describe("reconcileGiftCardOrders", () => { expect(mockLogger.error).not.toHaveBeenCalled() }) - it.each(["failed", "refunded"] as const)( - "vendor says %s: left for the payment re-read, never PAYMENT_FAILED on the vendor's word", + it.each(["failed"] as const)( + "vendor says %s: left pending, never PAYMENT_FAILED on the vendor's word", async (kind) => { - // `settleOrderFromVendor` leaves a PAYMENT_PENDING order alone on - // failed/refunded (our send may still be in flight); the unresolved - // exit below needs the vendor's positive "awaiting payment", which - // this is not — even long past the grace period. + // `settleOrderFromVendor` leaves a no-ref PAYMENT_PENDING order alone + // on a plain "failed" — it says nothing about money that may have + // left; the unresolved exit below needs the vendor's positive + // "awaiting payment", which this is not — even long past the grace + // period. mockFetchVendorStatus.mockResolvedValue({ kind, reason: "cancelled" }) mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => o) const order = noRef() @@ -694,6 +700,43 @@ describe("reconcileGiftCardOrders", () => { }, ) + it("vendor says refunded: settles PAID then REFUND_REQUIRED (a refund proves money moved)", async () => { + // A no-ref row has no IBEX re-read to defer to; "we refunded your + // payment" is the vendor positively confirming it received one. The + // worker's unresolved escalation must not close the row as + // PAYMENT_FAILED ("money never left") over an answer that says it did. + mockFetchVendorStatus.mockResolvedValue({ kind: "refunded", reason: "cancelled" }) + mockSettleFromVendor.mockImplementation(async (o: GiftCardOrder) => { + // Mirror the real two-step: PAID (vendor-reported-payment), then the + // same vendor failure carried onto the PAID row. + const paid = vendorVouchesPaymentImpl(o) + const refunded: GiftCardOrder = { + ...paid, + status: "REFUND_REQUIRED", + failureReason: "vendor-refunded: cancelled", + statusHistory: [ + ...paid.statusHistory, + { + status: "REFUND_REQUIRED", + at: new Date(clock), + reason: "vendor-refunded: cancelled", + }, + ], + } + repo.store.set(o.id, refunded) + return refunded + }) + noRef() + const res = await runAt(GIFT_CARD_PAYMENT_UNRESOLVED_GRACE_MS + 2 * HOUR) + if (res instanceof Error) throw res + const after = repo.store.get("noref") + expect(after?.status).toBe("REFUND_REQUIRED") + expect(after?.paidSats).toBe(40_100) + expect( + after?.statusHistory.map((h) => `${h.status}:${h.reason ?? ""}`), + ).toContain("PAID:vendor-reported-payment") + }) + describe("unresolved: vendor never saw a payment and the invoice is long expired", () => { // No ref means IBEX never handed back an id, so only the vendor can // ever resolve this row. Once the invoice has been expired a full day @@ -738,13 +781,19 @@ describe("reconcileGiftCardOrders", () => { expect(repo.transition).not.toHaveBeenCalled() }) - it("not when the vendor says paid-pending-fulfillment: a card may still come", async () => { + it("not when the vendor says paid-pending-fulfillment: the vouch settles it instead", async () => { + // A no-ref row's re-read is hardwired to "unknown", so the vendor's + // "the payment settled" vouch acts before any escalation could: the + // row goes PAID and the normal PAID path finishes fulfilment. mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + vendorVouchesPayment() noRef() const res = await runAt(pastGrace) if (res instanceof Error) throw res - expect(repo.store.get("noref")?.status).toBe("PAYMENT_PENDING") - expect(repo.transition).not.toHaveBeenCalled() + const after = repo.store.get("noref") + expect(after?.status).toBe("PAID") + expect(after?.statusHistory.at(-1)?.reason).toBe("vendor-reported-payment") + expect(res.paymentSettled).toBe(1) }) it("not when the order HAS an IBEX ref: IBEX, not the vendor, owns that answer", async () => { diff --git a/test/flash/unit/app/gift-cards/settle-order.spec.ts b/test/flash/unit/app/gift-cards/settle-order.spec.ts index 23982207d..b20b15200 100644 --- a/test/flash/unit/app/gift-cards/settle-order.spec.ts +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -340,12 +340,17 @@ describe("settleOrderFromVendor", () => { }) it.each(["failed", "refunded"] as const)( - "%s on PAYMENT_PENDING is left for the payment re-read", + "%s on PAYMENT_PENDING with an IBEX ref is left for the payment re-read", async (kind) => { // Our payment may still be in flight. Whether it settled decides // PAID→REFUND_REQUIRED or PAYMENT_FAILED; that is the worker's IBEX // re-read, not the vendor's word. - const order = repo.seed(makeOrder({ status: "PAYMENT_PENDING" })) + const order = repo.seed( + makeOrder({ + status: "PAYMENT_PENDING", + providerPaymentRef: "ibex-tx" as IbexTransactionId, + }), + ) const res = await settleOrderFromVendor(order, { kind, reason: "x" }) expect(res).toBe(order) expect(repo.transition).not.toHaveBeenCalled() @@ -357,6 +362,61 @@ describe("settleOrderFromVendor", () => { }, ) + it.each(["failed", "awaitingPayment" as const])( + "vendor %s on a no-ref PAYMENT_PENDING order is left alone (only the vouch arms act)", + async (kind) => { + // A no-ref row's re-read is hardwired to "unknown", so the vendor is + // the only arbiter — but only its positive answers act. A plain + // "failed" says nothing about money that may have left; the row stays + // pending and the worker's payment-unresolved escalation (which needs + // the vendor's "awaitingPayment") remains its terminal exit. + const order = repo.seed( + makeOrder({ + status: "PAYMENT_PENDING", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + providerPaymentRef: null, + }), + ) + const res = await settleOrderFromVendor( + order, + kind === "failed" ? { kind, reason: "x" } : { kind }, + ) + expect(res).toBe(order) + expect(repo.transition).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent).not.toHaveBeenCalled() + }, + ) + + it("refunded on a no-ref PAYMENT_PENDING order records the payment and escalates to REFUND_REQUIRED", async () => { + // No ref means the payment re-read can never answer ("unknown"), so the + // vendor's "we received and returned the payment" is terminal: land on + // PAID (money left Flash) and let the normal PAID path carry the vendor's + // failure to REFUND_REQUIRED, which pages. + const order = repo.seed( + makeOrder({ + status: "PAYMENT_PENDING", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + providerPaymentRef: null, + invoiceSats: 40_100 as Satoshis, + }), + ) + const res = await settleOrderFromVendor(order, { + kind: "refunded", + reason: "Vendor cancelled", + }) + if (res instanceof Error) throw res + expect(res.status).toBe("REFUND_REQUIRED") + expect(res.paidSats).toBe(40_100) + expect(res.failureReason).toBe("vendor-refunded: Vendor cancelled") + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "PAYMENT_PENDING", + "PAID", + "REFUND_REQUIRED", + ]) + expect(res.statusHistory[1].reason).toBe("vendor-reported-payment") + expect(opsPhases()).toEqual(["order-paid", "refund-required"]) + }) + it("refunded on CREATED -> FAILED (nothing was paid)", async () => { const order = repo.seed(makeOrder({ status: "CREATED" })) const res = await settleOrderFromVendor(order, { kind: "refunded", reason: "x" }) @@ -401,7 +461,7 @@ describe("settleOrderFromVendor", () => { expect(mockNotifyOpsEvent).not.toHaveBeenCalled() }) - it("paidPendingFulfillment on a PAYMENT_PENDING order returns the order untouched: the payment re-read owns that answer", async () => { + it("paidPendingFulfillment on a PAYMENT_PENDING order with an IBEX ref returns the order untouched: the payment re-read owns that answer", async () => { // The reconcile worker's IBEX re-read decides PAID vs PAYMENT_FAILED for // a row with a live send; the vendor's vouch must not race it. const order = repo.seed( @@ -419,6 +479,35 @@ describe("settleOrderFromVendor", () => { expect(mockNotifyOpsEvent).not.toHaveBeenCalled() }) + it("paidPendingFulfillment on a no-ref PAYMENT_PENDING order vouches the payment", async () => { + // The send errored without a verdict (socket reset / 5xx after IBEX may + // have accepted). With no ref the re-read is hardwired to "unknown" — it + // never asks IBEX — so there is no re-read authority for the vouch to + // contradict and the vendor is the only possible arbiter. Without this + // the row polls the vendor every run, gets paidPendingFulfillment back, + // records nothing and pends forever: never PAID, never the 24 h + // payment-unresolved escalation. + const order = repo.seed( + makeOrder({ + status: "PAYMENT_PENDING", + providerOrderId: "tbc-123" as GiftCardProviderOrderId, + providerPaymentRef: null, + invoiceSats: 40_100 as Satoshis, + }), + ) + const res = await settleOrderFromVendor(order, { + kind: "paidPendingFulfillment", + }) + if (res instanceof Error) throw res + expect(res.status).toBe("PAID") + expect(res.paidSats).toBe(40_100) + expect(res.statusHistory.at(-1)).toMatchObject({ + status: "PAID", + reason: "vendor-reported-payment", + }) + expect(opsPhases()).toEqual(["order-paid"]) + }) + describe("paidPendingFulfillment as vendor-reported payment", () => { // `pending` / `senttofulfillment` at the vendor mean the payment has // SETTLED and fulfilment is under way. For a row whose payment we never From 7ac5dda94ba660c2adccdcc4cfb4de27336035c9 Mon Sep 17 00:00:00 2001 From: Dread Date: Mon, 14 Sep 2026 18:30:43 -0700 Subject: [PATCH 10/14] fix(gift-cards): type the vendor-status union in the settle-order no-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 --- test/flash/unit/app/gift-cards/settle-order.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/flash/unit/app/gift-cards/settle-order.spec.ts b/test/flash/unit/app/gift-cards/settle-order.spec.ts index b20b15200..c32dcca3c 100644 --- a/test/flash/unit/app/gift-cards/settle-order.spec.ts +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -377,10 +377,9 @@ describe("settleOrderFromVendor", () => { providerPaymentRef: null, }), ) - const res = await settleOrderFromVendor( - order, - kind === "failed" ? { kind, reason: "x" } : { kind }, - ) + const status: GiftCardProviderOrderStatus = + kind === "failed" ? { kind, reason: "x" } : { kind: "awaitingPayment" } + const res = await settleOrderFromVendor(order, status) expect(res).toBe(order) expect(repo.transition).not.toHaveBeenCalled() expect(mockNotifyOpsEvent).not.toHaveBeenCalled() From 37fc8996675bcd859d1904758d3e096cea891e94 Mon Sep 17 00:00:00 2001 From: Dread Date: Mon, 14 Sep 2026 18:54:03 -0700 Subject: [PATCH 11/14] docs(gift-cards): fix doc drift on vouch semantics and refund path (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/gift-cards/ALERTING.md | 2 +- docs/gift-cards/FLOWS.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md index a379de159..3a12a927b 100644 --- a/docs/gift-cards/ALERTING.md +++ b/docs/gift-cards/ALERTING.md @@ -24,7 +24,7 @@ event by construction: the helper takes an order, not a claim. | --- | --- | --- | --- | --- | | `order-created` | pending | `purchaseGiftCard` | Row written, vendor not yet called | none | | `payment-pending` | pending | `purchaseGiftCard` | IBEX reported the send in flight, **or** returned a 200 naming no recognised status (`paymentSendStatusOrPending` converts it to Pending) — both `meta.reason` `payment-pending`; or the send errored without a verdict — socket reset, gateway 5xx, timeout after the request was accepted (`meta.reason` `payment-unconfirmed: `, `error` naming the class) | none; worker resolves against IBEX, then the vendor. Warn if the same order is still pending after 60 min (see log line below). A burst of `payment-unconfirmed` is an IBEX connectivity problem | -| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card. `statusHistory` reason is `payment-settled`, `vendor-reported-fulfilled`, `vendor-reported-payment` (a vendor `paidPendingFulfillment` vouch on an INVOICE_ISSUED/EXPIRED row, or a ref-less PAYMENT_PENDING one whose IBEX re-read could never answer), `payment-settled-on-reconcile` or `payment-settled-after-expiry` (a late Success on an EXPIRED row) | none | +| `order-paid` | success | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile `settleAsPaid` | Payment settled; awaiting the card. `statusHistory` reason is `payment-settled`, `vendor-reported-fulfilled`, `vendor-reported-payment` (a vendor `paidPendingFulfillment` vouch on an INVOICE_ISSUED/EXPIRED row, or a ref-less PAYMENT_PENDING one whose IBEX re-read could never answer — `paidPendingFulfillment` vouch, or the vendor's `refunded`, which lands PAID then REFUND_REQUIRED), `payment-settled-on-reconcile` or `payment-settled-after-expiry` (a late Success on an EXPIRED row) | none | | `order-fulfilled` | success | `settleOrderFromVendor` | Claim stored; push sent | none | | `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment. `meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: ` (only for a send IBEX provably refused), `payment-failed`, `expired`, `payment-unresolved-expired` (pending with no IBEX ref, vendor says unpaid, 24 h past `expiresAt`). For a vendor cancel before payment the event's `error` is `vendor-failed` / `vendor-refunded` and `meta.reason` the raw vendor status; `vendor-failed: ` is the `statusHistory` reason | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | | `paid-not-recorded` | failed | `purchaseGiftCard` | IBEX reported Success but the `PAID` write failed (lost race or store fault). The order is still returned to the client; until the worker or an operator records it, the row shows unpaid for money that has left the wallet. `meta.providerPaymentRef` is the IBEX transaction id | **Page.** RUNBOOK (i) | diff --git a/docs/gift-cards/FLOWS.md b/docs/gift-cards/FLOWS.md index 9f7199776..bbd41a052 100644 --- a/docs/gift-cards/FLOWS.md +++ b/docs/gift-cards/FLOWS.md @@ -114,7 +114,8 @@ UC Repo IBEX Worker (30s) | [ops: order-failed] | pending -> leave while young (IBEX's word); | past the 60-min warn horizon the vendor is polled too: - | vouch (fulfilled / paidPendingFulfillment on a no-ref row) + | vouch (fulfilled only; a paidPendingFulfillment + | answer is left for the re-read) | -> PAID (vendor-reported-payment) -> normal PAID path | not paid AND now > expiresAt + 24h | -> PAYMENT_FAILED (payment-unresolved-expired, From 7f837161e711d6ccbfb12292e5ec1c6ccccd7830 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 16 Sep 2026 12:14:54 -0700 Subject: [PATCH 12/14] fix(gift-cards): close the four review blockers on #508 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 Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX --- docs/gift-cards/ALERTING.md | 7 +- docs/gift-cards/API.md | 2 +- docs/gift-cards/ARCHITECTURE.md | 5 +- docs/gift-cards/CONFIG.md | 30 +++--- docs/gift-cards/FLOWS.md | 30 ++++-- docs/gift-cards/RUNBOOK.md | 15 ++- src/app/gift-cards/authorize-purchase.ts | 77 ++++++++++++---- src/app/gift-cards/ops.ts | 2 + src/app/gift-cards/purchase-gift-card.ts | 62 ++++++++++--- src/app/gift-cards/reconcile-orders.ts | 53 ++++++++++- src/domain/gift-cards/index.ts | 14 ++- .../gift-cards/bitcoin-company/client.ts | 19 +++- .../app/gift-cards/authorize-purchase.spec.ts | 77 +++++++++++++++- .../app/gift-cards/purchase-gift-card.spec.ts | 37 ++++++++ .../app/gift-cards/reconcile-orders.spec.ts | 91 +++++++++++++++++-- .../gift-cards/bitcoin-company/client.spec.ts | 64 ++++++++++++- .../mongoose/gift-card-orders.mapping.spec.ts | 20 ++-- 17 files changed, 513 insertions(+), 92 deletions(-) diff --git a/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md index 3a12a927b..776b842bb 100644 --- a/docs/gift-cards/ALERTING.md +++ b/docs/gift-cards/ALERTING.md @@ -28,11 +28,12 @@ event by construction: the helper takes an order, not a claim. | `order-fulfilled` | success | `settleOrderFromVendor` | Claim stored; push sent | none | | `order-failed` | failed | `purchaseGiftCard`, `settleOrderFromVendor`, reconcile | Refused or expired **before** payment. `meta.reason`: `vendor-create-failed: `, `vendor-invoice-undecodable`, `quote-mismatch: ...`, `payment-error: ` (only for a send IBEX provably refused), `payment-failed`, `expired`, `payment-unresolved-expired` (pending with no IBEX ref, vendor says unpaid, 24 h past `expiresAt`). For a vendor cancel before payment the event's `error` is `vendor-failed` / `vendor-refunded` and `meta.reason` the raw vendor status; `vendor-failed: ` is the `statusHistory` reason | Warn on rate. A burst of `quote-mismatch` means the vendor's pricing moved faster than tolerance; a burst of `vendor-create-failed` means the vendor is rejecting or down | | `paid-not-recorded` | failed | `purchaseGiftCard` | IBEX reported Success but the `PAID` write failed (lost race or store fault). The order is still returned to the client; until the worker or an operator records it, the row shows unpaid for money that has left the wallet. `meta.providerPaymentRef` is the IBEX transaction id | **Page.** RUNBOOK (i) | -| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` (24 h in PAID and the final vendor poll **positively** reported not fulfilled; `meta.lastVendorPoll` is `not-fulfilled`; a vendor error does not escalate) | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | +| `refund-required` | failed | `settleOrderFromVendor`, reconcile `processPaid` | **Money left Flash and no card came.** `error` is `vendor-failed`, `vendor-refunded`, or `fulfillment-timeout` (24 h in PAID and the final vendor poll answered `awaitingPayment`: the vendor never saw the payment; `meta.lastVendorPoll` is `awaiting-payment`. A vendor error, or a vendor still-in-progress answer, does not escalate) | **Page.** RUNBOOK (a). Every occurrence is a customer owed money | +| `fulfillment-stalled` | pending | reconcile `processPaid` | 24 h in PAID and the vendor still reports the order in progress (`meta.vendorStatus` `paidPendingFulfillment`: `held`, `senttofulfillment`, an unknown status, `fulfilled` without claim data). **Not** a refund: the card may still ship. Order stays PAID and keeps polling; paged once per order per process | **Page.** RUNBOOK (b) step 3: ask TBC about the order. It resolves itself to `order-fulfilled` or, if the vendor later answers `awaitingPayment`/`failed`/`refunded`, to `refund-required` | | `claim-encrypt-failed` | failed | `settleOrderFromVendor` | Card issued at the vendor; Flash could not encrypt the claim (key missing/invalid). Order stays `PAID` | **Page.** Fix `claimDataEncryptionKey`; the next poll retries | | `vendor-fulfilled-unexpected` | failed | `settleOrderFromVendor` | Vendor says fulfilled but our order is terminal-unpaid (`meta.orderStatus`). Someone paid; our records say not us | **Page.** Reconcile by hand | -| `would-reject` | pending | `authorizeGiftCardPurchase` (log-only) | A limits check would have refused; `meta.reason`, `meta.level`, `meta.productId`, `error` | Review weekly before flipping to enforce (RUNBOOK f). `reason: limits-unavailable` must be zero | -| `rejected` | failed | `authorizeGiftCardPurchase` (enforce) | A limits check refused | Warn on rate; investigate `limits-unavailable` immediately | +| `would-reject` | pending | `authorizeGiftCardPurchase` (log-only) | A cap or velocity check would have refused; `meta.reason`, `meta.level`, `meta.productId`, `error`. Never an eligibility reason (`level-not-eligible`, `account-too-new`, `open-loop-not-allowed`): those refuse in every mode | Review weekly before flipping to enforce (RUNBOOK f). `reason: limits-unavailable` must be zero | +| `rejected` | failed | `authorizeGiftCardPurchase` | A check refused: any eligibility reason in any mode, or a cap/velocity/`limits-unavailable` reason in enforce | Warn on rate; investigate `limits-unavailable` immediately | | `catalog-synced` | success | `syncGiftCardCatalogForProvider` | `meta.products`, `meta.countries`, `meta.durationMs` | none. Absence for > `syncIntervalSeconds` x 2 is the signal | | `catalog-sync-failed` | failed | `syncGiftCardCatalogForProvider` | Vendor pull or Redis write failed for one provider, **or the pull received zero rows / kept none after mapping** (a failure, not an empty catalog: the previous one keeps serving) (`error`, `meta.providerId`) | Warn. Two consecutive failures: RUNBOOK (g) | | `catalog-stale` | pending | `listGiftCardProducts` | A request was served a catalog older than `catalog.ttlSeconds`. Coalesced to one per provider per 10 min | Warn. The catalog will vanish at `staleAfterSeconds` | diff --git a/docs/gift-cards/API.md b/docs/gift-cards/API.md index 136b839f3..1f60f4cd2 100644 --- a/docs/gift-cards/API.md +++ b/docs/gift-cards/API.md @@ -199,7 +199,7 @@ Behaviour: | `FULFILLED` | Terminal. `claim` and `fulfilledAt` are set | | `FAILED` | Terminal, nothing paid: vendor rejected, price moved past tolerance, or unreadable invoice. Safe to retry with a new key | | `PAYMENT_FAILED` | Terminal, nothing paid: IBEX **provably** refused or failed the Lightning payment (insufficient balance, a corroborated payment failure, a send-guard rejection), or a pending send with no IBEX ref that the vendor reports unpaid 24 h past expiry (`payment-unresolved-expired`). Safe to retry with a new key | -| `EXPIRED` | Terminal, nothing paid: invoice not paid in time. (A late IBEX Success can still move it to `PAID`; keep showing the latest `status`) | +| `EXPIRED` | Terminal, nothing paid: invoice not paid in time. (A late IBEX answer for a send that was in flight can still move it to `PAID` or back to `PAYMENT_PENDING`; keep showing the latest `status`, and never treat `EXPIRED` as a reason to purchase again while a `giftCardPurchase` for it is still in flight) | | `REFUND_REQUIRED` | Terminal. The wallet paid, no card arrived; Flash has been paged. Do **not** tell the customer to buy again | ## Error codes diff --git a/docs/gift-cards/ARCHITECTURE.md b/docs/gift-cards/ARCHITECTURE.md index 024fa13e7..64924e144 100644 --- a/docs/gift-cards/ARCHITECTURE.md +++ b/docs/gift-cards/ARCHITECTURE.md @@ -107,7 +107,7 @@ CREATED -> INVOICE_ISSUED | FAILED | EXPIRED INVOICE_ISSUED -> PAYMENT_PENDING | PAID | PAYMENT_FAILED | EXPIRED | FAILED PAYMENT_PENDING -> PAID | PAYMENT_FAILED PAID -> FULFILLED | REFUND_REQUIRED -EXPIRED -> PAID (late settlement only; see below) +EXPIRED -> PAID | PAYMENT_PENDING (a send in flight at expiry, resolved; see below) FULFILLED -> (terminal) FAILED -> (terminal) PAYMENT_FAILED -> (terminal) @@ -137,9 +137,10 @@ Who moves what: | `PAYMENT_PENDING -> PAID / PAYMENT_FAILED` | `reconcileGiftCardOrders` (`processPendingPayment`) after re-reading IBEX; also `PAYMENT_FAILED` (`payment-unresolved-expired`) when the vendor positively reports not paid and now is past `expiresAt` + 24 h (asked every run when there is no IBEX ref; past the 60-min warn horizon when IBEX still reports the send in flight) | | `INVOICE_ISSUED/PAYMENT_PENDING -> PAID` | `settleOrderFromVendor` when the vendor reports fulfilled before we recorded payment — including the reconcile worker's vendor fallback when IBEX cannot account for the send. For an INVOICE_ISSUED (or expired) row a vendor `paidPendingFulfillment` records payment the same way (`vendor-reported-payment`): it is the vendor vouching the payment settled | | `EXPIRED -> PAID` | `purchaseGiftCard` (`markPaidAndSettle`) on a late IBEX Success for an already-expired row, reason `payment-settled-after-expiry` | +| `EXPIRED -> PAYMENT_PENDING` | `purchaseGiftCard` (`markPaymentPending`) when IBEX reports the send still in flight for a row the worker expired meanwhile, reason `payment-pending-after-expiry`; the IBEX ref is written so the worker can re-query it, and the row is open work again | | `CREATED/INVOICE_ISSUED -> EXPIRED` | reconcile (`processExpiry`) past `expiresAt`; an INVOICE_ISSUED row gets one vendor poll first (it never has an IBEX ref, so there is nothing to re-read at IBEX) — a vendor error defers expiry to the next run, since EXPIRED is never revisited | | `PAID -> FULFILLED` | `settleOrderFromVendor` (`fulfil`) with the encrypted claim | -| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24 h in PAID **only** when the final vendor poll positively reports not fulfilled (`fulfillment-timeout`) — a vendor error keeps PAID and retries | +| `PAID -> REFUND_REQUIRED` | `settleOrderFromVendor` on vendor `failed`/`refunded`; reconcile after 24 h in PAID **only** when the final vendor poll answers `awaitingPayment` (the vendor never saw the payment our rail settled: `fulfillment-timeout`). A vendor error keeps PAID and retries; a vendor still-in-progress answer (`paidPendingFulfillment`: `held`, `senttofulfillment`, unknown status, `fulfilled` without claim data) keeps PAID, keeps polling and pages `fulfillment-stalled` once — refunding there would credit a card the vendor may still ship | ## Decisive facts diff --git a/docs/gift-cards/CONFIG.md b/docs/gift-cards/CONFIG.md index e6e377578..aa081dc68 100644 --- a/docs/gift-cards/CONFIG.md +++ b/docs/gift-cards/CONFIG.md @@ -118,19 +118,25 @@ code (`{ JM: bitrefill }` once that adapter lands). `limits.mode` mirrors `sendGuard.mode` (`docs/send-guard.md`): -| Mode | Checks run | On failure | `reservationId` | +The mode governs **limits** (how much an account may buy). **Eligibility** +(who may buy at all) is outside it and enforced in every mode, `off` included: + +| Check | `off` | `log-only` | `enforce` | | --- | --- | --- | --- | -| `off` | none | n/a | `null` (nothing held) | -| `log-only` | all | `giftcard / would-reject` ops event (status pending) + span attributes, then **allowed** | set, or `null` if the hold write failed (logged, allowed) | -| `enforce` | all | `giftcard / rejected` ops event (status failed), purchase refused with the check's error | set; a hold write failure refuses with `GIFT_CARD_UNKNOWN` | - -Checks, in order (`evaluate` in `authorize-purchase.ts`): level, account age, -open-loop switch and level >= 2, per-card cap = `min(perLevel, vendor card cap)`, -velocity, daily cap = `min(perLevel.dailyCents, vendorDailyCapCents)` over -non-failed orders in the trailing 24 h plus live Redis holds. `REFUND_REQUIRED` -orders count toward the daily sum (money left); `FAILED`, `PAYMENT_FAILED`, -`EXPIRED` do not. A Mongo or Redis fault is `limits-unavailable`: allowed in -`log-only`, refused (`GIFT_CARD_UNKNOWN`, level Critical) in `enforce`. +| Eligibility: level 0 / `minAccountLevel`, account age, open-loop switch and level >= 2 (`checkEligibility`) | refused | refused | refused | +| Limits: per-card cap, velocity, daily cap (`evaluateLimits`) | not run | `giftcard / would-reject` ops event (status pending) + span attributes, then **allowed** | `giftcard / rejected` ops event (status failed), purchase refused with the check's error | + +An eligibility refusal emits `giftcard / rejected` (status failed) whatever +the mode. `reservationId`: `null` in `off` (nothing held); in `log-only` set, +or `null` if the hold write failed (logged, allowed); in `enforce` set, and a +hold write failure refuses with `GIFT_CARD_UNKNOWN`. + +Limits, in order: per-card cap = `min(perLevel, vendor card cap)`, velocity, +daily cap = `min(perLevel.dailyCents, vendorDailyCapCents)` over non-failed +orders in the trailing 24 h plus live Redis holds. `REFUND_REQUIRED` orders +count toward the daily sum (money left); `FAILED`, `PAYMENT_FAILED`, `EXPIRED` +do not. A Mongo or Redis fault is `limits-unavailable`: allowed in `log-only`, +refused (`GIFT_CARD_UNKNOWN`, level Critical) in `enforce`. Independent of mode: the 10/min purchase attempt limiter (5 min block; not charged on a same-key replay), the 30/min `giftCardQuote` attempt limiter diff --git a/docs/gift-cards/FLOWS.md b/docs/gift-cards/FLOWS.md index bbd41a052..651e4c7eb 100644 --- a/docs/gift-cards/FLOWS.md +++ b/docs/gift-cards/FLOWS.md @@ -169,7 +169,10 @@ write fails after a Success (lost race, store fault) the order is re-read and returned as it stands and a Critical `paid-not-recorded` event fires (RUNBOOK i): an error payload would read as "nothing happened" for money already gone. A Success for a row the worker expired while the send was in flight is written -`EXPIRED -> PAID` (`payment-settled-after-expiry`). +`EXPIRED -> PAID` (`payment-settled-after-expiry`); a Pending for such a row is +written `EXPIRED -> PAYMENT_PENDING` (`payment-pending-after-expiry`) with the +IBEX ref, so the worker re-queries the send and the client never reads a +possibly-paid order as "expired, buy again". If the vendor reports `fulfilled` while Flash still shows `INVOICE_ISSUED` or `PAYMENT_PENDING`, `settleOrderFromVendor` takes the vendor's word as proof of @@ -193,12 +196,17 @@ Worker Repo Vendor ``` After `GIFT_CARD_PAID_TIMEOUT_MS` (24 h) in `PAID`, `processPaid` makes one -final `fetchAndSettle` before escalating: a vendor `fulfilled` ends `FULFILLED` -(a worker gap over 24 h must not refund cards that shipped), `failed` / -`refunded` takes the path above, and **only** a positive not-fulfilled answer -(`awaitingPayment` / `paidPendingFulfillment`) writes `REFUND_REQUIRED` with -reason `fulfillment-timeout`. A vendor error keeps the order `PAID` (warned) -and the next run polls again; a page never rests on an unreachable vendor. +final vendor poll (`fetchVendorOrderStatus`, then `settleOrderFromVendor`) +before escalating: a vendor `fulfilled` ends `FULFILLED` (a worker gap over +24 h must not refund cards that shipped), `failed` / `refunded` takes the path +above, and **only** `awaitingPayment` — the vendor never saw the payment our +rail settled — writes `REFUND_REQUIRED` with reason `fulfillment-timeout`. A +`paidPendingFulfillment` (`held`, `senttofulfillment`, an unknown status, +`fulfilled` without claim data) is the vendor still working: the order stays +`PAID`, keeps polling on the normal schedule, and pages `fulfillment-stalled` +once — refunding there would have ops credit an order the vendor then ships. A +vendor error keeps the order `PAID` (warned) and the next run polls again; a +page never rests on an unreachable vendor. TBC's `disputed` is held as pending, not refunded, so it reaches this point only via the timeout. From here the order is an operator's problem: RUNBOOK (a). The customer must not be told to buy again. @@ -234,9 +242,11 @@ vendor first: a card that shipped, or a payment the vendor reports settled and is fulfilling, is proof of payment. A vendor error is not an answer: it defers expiry, since EXPIRED is never revisited and one outage must not bury a possibly-paid order. Two safety nets remain for a payment that surfaces later: -a same-key replay before expiry resumes it (flow 7), and a late IBEX Success on -an `EXPIRED` row is written `EXPIRED -> PAID` (flow 4a). The worker never polls -`EXPIRED`. +a same-key replay before expiry resumes it (flow 7), and a late IBEX answer on +an `EXPIRED` row re-opens it — Success as `EXPIRED -> PAID`, Pending as +`EXPIRED -> PAYMENT_PENDING` with the ref (flow 4a). The worker never polls +`EXPIRED`; a row it expired that IBEX then reports in flight comes back to it +as `PAYMENT_PENDING`. ## 7. Idempotent replay / double tap diff --git a/docs/gift-cards/RUNBOOK.md b/docs/gift-cards/RUNBOOK.md index 13457e338..9da0680fa 100644 --- a/docs/gift-cards/RUNBOOK.md +++ b/docs/gift-cards/RUNBOOK.md @@ -65,8 +65,13 @@ The only state meaning money left Flash and no card came. It pages Normal fulfilment is seconds. The worker polls at 5s/15s/60s/5m then every 15m, and escalates to `REFUND_REQUIRED` only at 24 h and only when the vendor -positively reports not fulfilled (a vendor error keeps PAID and retries). A -PAID order older than 15 min is a vendor delay or a worker problem. +answers `awaitingPayment` (it never saw the payment our rail settled). A vendor +error keeps PAID and retries. A vendor that still reports the order in +progress at 24 h (`held`, `senttofulfillment`, an unknown status, `completed` +without `claimData`) also keeps PAID, keeps polling, and pages +`fulfillment-stalled` once: the card may still ship, so it is never refunded +automatically — that is step 3 below, with TBC. A PAID order older than 15 min +is a vendor delay or a worker problem. 1. `Q='{"status":"PAID","updatedAt":{"$lt":{"$date":""}}}'`. Note the JSON date form; adjust the timestamp. @@ -84,8 +89,10 @@ PAID order older than 15 min is a vendor delay or a worker problem. `completed` without `claimData`, and `disputed`, are held as pending by design (a dispute may still fulfil or refund); ask TBC. 4. Do not move the order by hand. If the vendor confirms the card will not - come, the 24 h timeout writes `REFUND_REQUIRED`; to bring that forward, - engineering runs `settleOrderFromVendor(order, { kind: "failed", reason })`. + come, engineering runs `settleOrderFromVendor(order, { kind: "failed", reason })`, + which writes `REFUND_REQUIRED` and pages (a). The 24 h timeout does this on + its own only when the vendor answers `awaitingPayment`; a stalled order the + vendor still calls in-progress waits for that confirmation. ## (c) Order stuck in `PAYMENT_PENDING` with no `providerPaymentRef` diff --git a/src/app/gift-cards/authorize-purchase.ts b/src/app/gift-cards/authorize-purchase.ts index 766c2f832..3df332aa6 100644 --- a/src/app/gift-cards/authorize-purchase.ts +++ b/src/app/gift-cards/authorize-purchase.ts @@ -30,10 +30,17 @@ export { releaseGiftCardReservation } * reseller. Flash layers its own per-level caps underneath (perLevel.*), so * the effective cap is always `min(flash, vendor)`. * - * Modes (`giftCards.limits.mode`), same rollout discipline as the ENG-573 send - * guard: `off` skips every check; `log-only` runs them all, emits a - * `giftcard / would-reject` ops event on failure and AUTHORISES anyway; - * `enforce` refuses. A week of would-reject data is reviewed before the flip. + * Two kinds of check, deliberately not under the same switch: + * + * - Eligibility (level, account age, open-loop floor) is identity, not a + * limit. It is enforced in every mode, `off` included: a device-only + * level-0 account has no allowance to spend against, and no rollout + * setting makes it one. + * - Limits (per-card and daily caps, velocity) follow `giftCards.limits.mode` + * with the ENG-573 send-guard rollout discipline: `off` skips them; + * `log-only` runs them, emits a `giftcard / would-reject` ops event on + * failure and AUTHORISES anyway; `enforce` refuses. A week of would-reject + * data is reviewed before the flip. * * Rejection reasons have exactly one source — this const — so the specs and * the would-reject census reference the same strings. @@ -114,21 +121,16 @@ const limitsUnavailable = (cause: Error): Rejection => ({ }) /** - * Every check, in order, against the account's history and live holds. - * Returns the first failure, or `true`. A repository/Redis fault is a - * `limits-unavailable` rejection: what that means is decided by the mode. + * Who may buy at all. Pure: no store reads. Enforced in every limits mode. + * Returns the first failure, or `true`. */ -const evaluate = async ({ +const checkEligibility = ({ account, product, - valueMinor, - quantity, nowMs, -}: Required): Promise => { +}: Required): true | Rejection => { const limits = GiftCardsConfig.limits const level = effectiveAccountLevel(account.level) - const totalMinor = valueMinor * quantity - const currency = product.currency // 1. Level. Level 0 is refused regardless of config: a device-only account // has no identity and no allowance to spend against. @@ -167,6 +169,26 @@ const evaluate = async ({ } } + return true +} + +/** + * How much an eligible account may buy, against its history and live holds. + * Returns the first failure, or `true`. A repository/Redis fault is a + * `limits-unavailable` rejection: what that means is decided by the mode. + */ +const evaluateLimits = async ({ + account, + product, + valueMinor, + quantity, + nowMs, +}: Required): Promise => { + const limits = GiftCardsConfig.limits + const level = effectiveAccountLevel(account.level) + const totalMinor = valueMinor * quantity + const currency = product.currency + // 4. Per-card cap: min(Flash per-level, vendor open/closed-loop). const perLevel = levelLimits(level) const vendorCardCap = product.isOpenLoop @@ -240,12 +262,15 @@ const reportWouldReject = ({ totalMinor, rejection, mode, + enforced = mode === "enforce", }: { account: Pick product: Pick totalMinor: number rejection: Rejection mode: GiftCardLimitsMode + /** Whether the purchase was actually refused. Eligibility refusals are, whatever the mode. */ + enforced?: boolean }) => { const level = String(effectiveAccountLevel(account.level)) addAttributesToCurrentSpan({ @@ -255,8 +280,8 @@ const reportWouldReject = ({ }) notifyOpsEvent({ flow: "giftcard", - phase: mode === "enforce" ? "rejected" : "would-reject", - status: mode === "enforce" ? "failed" : "pending", + phase: enforced ? "rejected" : "would-reject", + status: enforced ? "failed" : "pending", accountId: account.id, amount: { value: (totalMinor / 100).toFixed(2), currency: product.currency }, error: rejection.error.constructor.name, @@ -275,12 +300,28 @@ export const authorizeGiftCardPurchase = async ( args: AuthorizeGiftCardPurchaseArgs, ): Promise => { const mode = GiftCardsConfig.limits.mode - if (mode === "off") return { authorized: true, reservationId: null } - const nowMs = args.nowMs ?? Date.now() const totalMinor = args.valueMinor * args.quantity - const outcome = await evaluate({ ...args, nowMs }) + // Eligibility first, and outside the mode switch: `log-only` exists to + // measure caps against real traffic, not to let a level-0 account through + // while the caps are being tuned. + const eligibility = checkEligibility({ ...args, nowMs }) + if (eligibility !== true) { + reportWouldReject({ + account: args.account, + product: args.product, + totalMinor, + rejection: eligibility, + mode, + enforced: true, + }) + return { authorized: false, ...eligibility } + } + + if (mode === "off") return { authorized: true, reservationId: null } + + const outcome = await evaluateLimits({ ...args, nowMs }) if (outcome !== true) { reportWouldReject({ diff --git a/src/app/gift-cards/ops.ts b/src/app/gift-cards/ops.ts index 0d857c1cf..c9678de44 100644 --- a/src/app/gift-cards/ops.ts +++ b/src/app/gift-cards/ops.ts @@ -16,6 +16,8 @@ export type GiftCardOpsPhase = | "order-fulfilled" | "order-failed" | "refund-required" + /** PAID past the fulfilment horizon with the vendor still saying "working on it". Not a refund. */ + | "fulfillment-stalled" | "claim-encrypt-failed" | "vendor-fulfilled-unexpected" /** IBEX answered Success/Pending but the order row could not record it. Money moved; the row lies. */ diff --git a/src/app/gift-cards/purchase-gift-card.ts b/src/app/gift-cards/purchase-gift-card.ts index 97ebd868a..90b401b3d 100644 --- a/src/app/gift-cards/purchase-gift-card.ts +++ b/src/app/gift-cards/purchase-gift-card.ts @@ -744,7 +744,7 @@ const markPaymentPending = async ( }: { reason: string; providerPaymentRef: string | null; error?: ApplicationError }, ): Promise => { const repo = GiftCardOrdersRepository() - const pending = await repo.transition({ + let pending = await repo.transition({ id: order.id, from: [GiftCardOrderStatus.InvoiceIssued], to: GiftCardOrderStatus.PaymentPending, @@ -752,10 +752,12 @@ const markPaymentPending = async ( patch: refPatch(providerPaymentRef), }) if (pending instanceof GiftCardOrderStateError) { - // A concurrent same-key attempt already moved the order on — to its own - // PAYMENT_PENDING, or to the PAID / FULFILLED its send resolved to. - // Whatever it wrote is the truth. A row in any other state contradicts an - // IBEX that says the send is in flight: page it. + // The row is no longer INVOICE_ISSUED. Either a concurrent same-key attempt + // already moved the order on — to its own PAYMENT_PENDING, or to the + // PAID / FULFILLED its send resolved to — and whatever it wrote is the + // truth; or the reconcile worker expired the row while this send was in + // flight at IBEX. A row in any other state contradicts an IBEX that says + // the send is in flight: page it. const latest = await repo.findById(order.id) if (latest instanceof Error) { return paidNotRecorded({ @@ -765,14 +767,48 @@ const markPaymentPending = async ( cause: latest, }) } - if (MONEY_MOVED_STATUSES.includes(latest.status)) return latest - return paidNotRecorded({ - order, - providerPaymentRef, - intended: GiftCardOrderStatus.PaymentPending, - cause: pending, - rowStatus: latest.status, - }) + if (latest.status === GiftCardOrderStatus.Expired) { + // Expired on our books, not at IBEX: the send is still in flight and the + // money may yet leave. Re-open the row as PAYMENT_PENDING, with the ref, + // so the worker re-queries IBEX and the client stops reading "expired, + // buy again". EXPIRED → PAYMENT_PENDING exists for exactly this. + pending = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.Expired], + to: GiftCardOrderStatus.PaymentPending, + reason: `${reason}-after-expiry`, + patch: refPatch(providerPaymentRef), + }) + if (pending instanceof GiftCardOrderStateError) { + const again = await repo.findById(order.id) + if (again instanceof Error) { + return paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: again, + }) + } + if (MONEY_MOVED_STATUSES.includes(again.status)) return again + return paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: pending, + rowStatus: again.status, + }) + } + } else if (MONEY_MOVED_STATUSES.includes(latest.status)) { + return latest + } else { + return paidNotRecorded({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: pending, + rowStatus: latest.status, + }) + } } if (pending instanceof Error) { return paidNotRecorded({ diff --git a/src/app/gift-cards/reconcile-orders.ts b/src/app/gift-cards/reconcile-orders.ts index 790e3743c..c4f193b95 100644 --- a/src/app/gift-cards/reconcile-orders.ts +++ b/src/app/gift-cards/reconcile-orders.ts @@ -94,6 +94,8 @@ export type GiftCardReconcileSummary = { paymentSettled: number /** PAID orders whose 24h final vendor poll errored and were left PAID for the next run. */ finalPollFailed: number + /** PAID past the 24h horizon with the vendor still reporting the order in progress. Paged once, kept PAID. */ + fulfillmentStalled: number /** Set when another worker held the lock and this run did nothing. */ skipped?: "lock-held" } @@ -143,8 +145,14 @@ export const nextGiftCardPollAt = ( const lastAttemptAt = new Map() +// Orders already paged as `fulfillment-stalled`. In-memory on purpose: a +// restart re-pages once, which is the right side to err on for an order a +// human is meant to be looking at. +const stalledReported = new Set() + export const __resetGiftCardReconcileStateForTest = (): void => { lastAttemptAt.clear() + stalledReported.clear() } /** Bump `updatedAt` after a poll that changed nothing, so the batch rotates. Best effort. */ @@ -561,9 +569,15 @@ const processPaid = async ( if (nowMs - paidAtMs >= GIFT_CARD_PAID_TIMEOUT_MS) { // One final vendor poll before escalating. A worker gap longer than 24h // (outage, kill switch, stuck lock) must not turn every order the vendor - // fulfilled in the meantime into a refund. + // fulfilled in the meantime into a refund. The two halves are used + // separately here because the decision below needs the vendor's own + // answer, not just its effect on the order. lastAttemptAt.set(order.id, nowMs) - const settled = await fetchAndSettle(order) + const vendorStatus = await fetchVendorOrderStatus(order) + const settled = + vendorStatus instanceof Error + ? vendorStatus + : await settleOrderFromVendor(order, vendorStatus) if (settled instanceof Error) { // A vendor 5xx, a claim-key fault (the card IS issued; only our storage // failed), a repository error — none of these say the card is not @@ -582,7 +596,36 @@ const processPaid = async ( return } - // The vendor answered and positively reports no card after 24h. + // Still PAID after settling, so the vendor said either `awaitingPayment` + // or `paidPendingFulfillment` (`failed`/`refunded` land on + // REFUND_REQUIRED via settle, `fulfilled` on FULFILLED). + // + // Only `awaitingPayment` is a definitive "no card is coming": our rail says + // the money left and the vendor says it never arrived. Anything the vendor + // buckets as still-in-progress — `held`, `senttofulfillment`, a status + // string we do not know, `fulfilled` without claim data — means the card + // may still ship. REFUND_REQUIRED on that would have ops refund an order + // the vendor then delivers: customer keeps both. Those stay PAID, keep + // polling on the normal schedule, and page a human once. + if (vendorStatus instanceof Error || vendorStatus.kind !== "awaitingPayment") { + summary.fulfillmentStalled += 1 + if (!stalledReported.has(order.id)) { + stalledReported.add(order.id) + notifyGiftCardOpsEvent({ + phase: "fulfillment-stalled", + status: "pending", + order, + meta: { + reason: "fulfillment-stalled", + vendorStatus: vendorStatus instanceof Error ? "unknown" : vendorStatus.kind, + paidSince: new Date(paidAtMs).toISOString(), + }, + }) + } + await touch(order) + return + } + const refund = await GiftCardOrdersRepository().transition({ id: order.id, from: [GiftCardOrderStatus.Paid], @@ -592,13 +635,14 @@ const processPaid = async ( }) if (refund instanceof Error) throw refund lastAttemptAt.delete(order.id) + stalledReported.delete(order.id) summary.refundRequired += 1 notifyGiftCardOpsEvent({ phase: "refund-required", status: "failed", order: refund, error: "fulfillment-timeout", - meta: { reason: "fulfillment-timeout", lastVendorPoll: "not-fulfilled" }, + meta: { reason: "fulfillment-timeout", lastVendorPoll: "awaiting-payment" }, }) return } @@ -649,6 +693,7 @@ export const reconcileGiftCardOrders = async ( expired: 0, paymentSettled: 0, finalPollFailed: 0, + fulfillmentStalled: 0, } let token: string | null diff --git a/src/domain/gift-cards/index.ts b/src/domain/gift-cards/index.ts index 273efa511..b53379cad 100644 --- a/src/domain/gift-cards/index.ts +++ b/src/domain/gift-cards/index.ts @@ -32,9 +32,11 @@ export const GiftCardOrderStatus = { /** * "Nothing left for the worker to do." EXPIRED is included even though it has - * a legal exit (→ PAID): that exit is only ever taken by the purchase path - * (see the lifecycle doc above), never by the worker, so an EXPIRED order is - * not open work. + * legal exits (→ PAID, → PAYMENT_PENDING): both are only ever taken by the + * purchase path when a send that was in flight at IBEX resolves after the + * worker expired the row (see the lifecycle doc above), never by the worker, + * so an EXPIRED order is not open work. Once it re-enters PAYMENT_PENDING it + * is open again and the worker owns it. */ export const GIFT_CARD_TERMINAL_STATUSES: readonly GiftCardOrderStatus[] = [ GiftCardOrderStatus.Fulfilled, @@ -55,8 +57,10 @@ export const GIFT_CARD_TRANSITIONS: Readonly< FULFILLED: [], FAILED: [], PAYMENT_FAILED: [], - // A pay still in flight when the worker expired the row, then settled. - EXPIRED: ["PAID"], + // A pay still in flight when the worker expired the row: settled (PAID), or + // reported by IBEX as still in flight (PAYMENT_PENDING, with the ref the + // worker needs to re-query it). Both exits belong to the purchase path. + EXPIRED: ["PAID", "PAYMENT_PENDING"], REFUND_REQUIRED: [], } diff --git a/src/services/gift-cards/bitcoin-company/client.ts b/src/services/gift-cards/bitcoin-company/client.ts index 7eb0bc82f..c970e61f6 100644 --- a/src/services/gift-cards/bitcoin-company/client.ts +++ b/src/services/gift-cards/bitcoin-company/client.ts @@ -244,10 +244,22 @@ type CallArgs = RequestSpec & { type ParseArgs = { op: string schema: z.ZodType - /** Auth responses are never previewed in logs, redaction or not. */ + /** + * Whether a response that fails validation may be previewed (redacted) in + * the warn log. Off for anything that can carry a token or a claim code: + * the branch that logs is precisely the one taken when the vendor renames a + * field, and key-name redaction cannot know the new name. + */ logBody: boolean } +/** + * The only ops whose responses are safe to preview on a shape failure. Catalog + * rows and quotes are prices and product copy. `createOrder` and `getOrder` + * (invoice-status) carry claim data once fulfilled; auth ops carry tokens. + */ +const PREVIEWABLE_OPS: ReadonlySet = new Set(["listProducts", "quote"]) + export type BitcoinCompanyClientDeps = { getConfig?: () => BitcoinCompanyConfig tokenStore?: TokenStore @@ -451,7 +463,10 @@ export class BitcoinCompanyClient { addAttributesToCurrentSpan({ "giftcard.provider": PROVIDER, "giftcard.op": args.op }) try { const data = await this.sendWithAuth(args) - return this.parseEnvelope({ op: args.op, schema: args.schema, logBody: true }, data) + return this.parseEnvelope( + { op: args.op, schema: args.schema, logBody: PREVIEWABLE_OPS.has(args.op) }, + data, + ) } catch (err) { return this.toGiftCardError(args.op, err) } diff --git a/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts b/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts index a3139ea53..90e9a642e 100644 --- a/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts +++ b/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts @@ -161,7 +161,10 @@ describe("authorizeGiftCardPurchase", () => { }) const wouldReject = c.expected !== "ok" - const rejected = wouldReject && mode === "enforce" + // Eligibility (who may buy) is refused in every mode; only the caps + // (how much) are let through in log-only. + const eligibility = c.expected === GiftCardRejectionReasons.levelNotEligible + const rejected = wouldReject && (mode === "enforce" || eligibility) expect(res.authorized).toBe(!rejected) expect(res.authorized ? null : res.reason).toBe(rejected ? c.expected : null) @@ -172,17 +175,17 @@ describe("authorizeGiftCardPurchase", () => { const reported = mockNotifyOpsEvent.mock.calls[0]?.[0] expect(reported?.flow ?? null).toBe(wouldReject ? "giftcard" : null) expect(reported?.phase ?? null).toBe( - wouldReject ? (mode === "enforce" ? "rejected" : "would-reject") : null, + wouldReject ? (rejected ? "rejected" : "would-reject") : null, ) expect(reported?.meta?.reason ?? null).toBe(wouldReject ? c.expected : null) }) } } - it("off: authorises without evaluating anything", async () => { + it("off: authorises an eligible account without evaluating any limit", async () => { withMode("off") const res = await authorize({ - account: makeAccount({ level: 0 }), + account: makeAccount({ level: 1 }), valueMinor: 9_999_999, }) expect(res).toEqual({ authorized: true, reservationId: null }) @@ -191,6 +194,72 @@ describe("authorizeGiftCardPurchase", () => { expect(mockNotifyOpsEvent).not.toHaveBeenCalled() }) + describe("eligibility is not a limit: refused in every mode, off included", () => { + // The shipped default is log-only. A rollout switch for cap tuning must + // never let a device-only account (no identity, no allowance) buy bearer + // codes, and the event it emits is a rejection, not a would-reject. + for (const mode of ["off", "log-only", "enforce"] as const) { + it(`${mode}: level 0 is refused`, async () => { + withMode(mode) + const res = await authorize({ account: makeAccount({ level: 0 }) }) + expectRejected( + res, + GiftCardRejectionReasons.levelNotEligible, + GiftCardLevelNotEligibleError, + ) + expect(mockZadd).not.toHaveBeenCalled() + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + phase: "rejected", + status: "failed", + meta: expect.objectContaining({ + reason: GiftCardRejectionReasons.levelNotEligible, + }), + }) + }) + + it(`${mode}: an account younger than minAccountAgeHours is refused`, async () => { + withMode(mode) + const res = await authorize({ + account: makeAccount({ createdAt: new Date(NOW_MS - 1 * HOUR_MS) }), + }) + expectRejected( + res, + GiftCardRejectionReasons.accountTooNew, + GiftCardLimitExceededError, + ) + expect(mockZadd).not.toHaveBeenCalled() + }) + + it(`${mode}: an open-loop card below the level floor is refused`, async () => { + withMode(mode) + const res = await authorize({ + account: makeAccount({ level: 1 }), + product: makeProduct({ isOpenLoop: true }), + }) + expectRejected( + res, + GiftCardRejectionReasons.openLoopNotAllowed, + GiftCardLevelNotEligibleError, + ) + expect(mockZadd).not.toHaveBeenCalled() + }) + } + + it("log-only: a cap breach is still let through (the thing log-only is for)", async () => { + withMode("log-only") + const res = await authorize({ + account: makeAccount({ level: 1 }), + product: makeProduct({ maxValue: null }), + valueMinor: 20_001, + }) + expect(res.authorized).toBe(true) + expect(mockNotifyOpsEvent.mock.calls[0][0]).toMatchObject({ + phase: "would-reject", + status: "pending", + }) + }) + }) + it("level 0 is rejected with the level error even when minAccountLevel is 0", async () => { mockConfig = makeGiftCardsConfig({ limits: { ...makeGiftCardsConfig().limits, minAccountLevel: 0 }, diff --git a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts index 6ff4f09eb..ab5a7372a 100644 --- a/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts +++ b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts @@ -762,6 +762,43 @@ describe("purchaseGiftCard", () => { expect(opsEvent("paid-not-recorded")).toBeUndefined() }) + it("a late Pending on a row the worker already EXPIRED re-opens it: EXPIRED -> PAYMENT_PENDING with the ref", async () => { + // Same race, but IBEX says the send is still in flight rather than + // settled. The row must not stay EXPIRED: the client would read that + // as "never paid, buy again" while the HTLC settles, and the worker + // never polls EXPIRED, so the ref it needs to re-query IBEX would be + // lost with it. + const existing = issued() + mockPayInvoice.mockImplementation( + async (args: { onResponse?: (r: unknown) => void }) => { + const expired = await repo.transition({ + id: existing.id, + from: ["INVOICE_ISSUED"], + to: "EXPIRED", + reason: "expired", + patch: { failureReason: "expired" }, + }) + if (expired instanceof Error) throw expired + args.onResponse?.({ transaction: { id: "ibex-tx-1" } }) + return PaymentSendStatus.Pending + }, + ) + const res = await purchase() + if (res instanceof Error) throw res + expect(res.status).toBe("PAYMENT_PENDING") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + expect(res.statusHistory.map((h) => h.status)).toEqual([ + "CREATED", + "INVOICE_ISSUED", + "EXPIRED", + "PAYMENT_PENDING", + ]) + expect(res.statusHistory[3].reason).toBe("payment-pending-after-expiry") + expect(opsPhases()).toEqual(["payment-pending"]) + expect(opsEvent("paid-not-recorded")).toBeUndefined() + expect(mockGetOrder).not.toHaveBeenCalled() + }) + it("does not resume while the kill switch is off: that is new money leaving", async () => { mockConfig = makeGiftCardsConfig({ enabled: false }) const existing = issued() diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index e22a4d538..b52123bd8 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -253,6 +253,7 @@ describe("reconcileGiftCardOrders", () => { expired: 0, paymentSettled: 0, finalPollFailed: 0, + fulfillmentStalled: 0, skipped: "lock-held", }) expect(repo.listByStatus).not.toHaveBeenCalled() @@ -968,17 +969,20 @@ describe("reconcileGiftCardOrders", () => { }) describe("24h escalation", () => { - it("PAID for 24h with the vendor positively reporting no card -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { + it("PAID for 24h with the vendor positively reporting no card (awaitingPayment) -> REFUND_REQUIRED with reason fulfillment-timeout, and pages", async () => { + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) const order = paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) const res = await runAt(0) if (res instanceof Error) throw res expect(res.refundRequired).toBe(1) + expect(res.fulfillmentStalled).toBe(0) const after = repo.store.get("old") expect(after?.status).toBe("REFUND_REQUIRED") expect(after?.failureReason).toBe("fulfillment-timeout") - // One final vendor poll precedes the escalation. - expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) - expect(mockFetchAndSettle).toHaveBeenCalledWith(order) + // One final vendor poll precedes the escalation, and it is settled first. + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) + expect(mockFetchVendorStatus).toHaveBeenCalledWith(order) + expect(mockSettleFromVendor).toHaveBeenCalledWith(order, { kind: "awaitingPayment" }) expect(mockNotifyOpsEvent).toHaveBeenCalledWith( expect.objectContaining({ flow: "giftcard", @@ -987,12 +991,84 @@ describe("reconcileGiftCardOrders", () => { error: "fulfillment-timeout", meta: expect.objectContaining({ orderId: "old", - lastVendorPoll: "not-fulfilled", + lastVendorPoll: "awaiting-payment", }), }), ) }) + describe("the vendor still reporting the order in progress is not 'no card is coming'", () => { + // `held`, `senttofulfillment`, an unknown status string, `fulfilled` + // without claim data all reach the worker as `paidPendingFulfillment`. + // Refunding on any of them has ops credit an order the vendor then + // ships: the customer keeps both. Stay PAID, page once, keep polling. + it("PAID for 24h with paidPendingFulfillment stays PAID and pages fulfillment-stalled once", async () => { + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + + const first = await runAt(0) + if (first instanceof Error) throw first + expect(first.refundRequired).toBe(0) + expect(first.fulfillmentStalled).toBe(1) + expect(repo.store.get("old")?.status).toBe("PAID") + expect(repo.transition).not.toHaveBeenCalled() + expect(repo.touch).toHaveBeenCalledWith("old") + expect(mockNotifyOpsEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ phase: "refund-required" }), + ) + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + flow: "giftcard", + phase: "fulfillment-stalled", + status: "pending", + meta: expect.objectContaining({ + orderId: "old", + vendorStatus: "paidPendingFulfillment", + }), + }), + ) + + // Next run: still stalled, still PAID, no second page. + const second = await runAt(15 * MINUTE) + if (second instanceof Error) throw second + expect(second.fulfillmentStalled).toBe(1) + expect(repo.store.get("old")?.status).toBe("PAID") + expect( + mockNotifyOpsEvent.mock.calls.filter( + ([e]) => e.phase === "fulfillment-stalled", + ), + ).toHaveLength(1) + }) + + it("a stalled order the vendor later fulfils ends FULFILLED", async () => { + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + const first = await runAt(0) + if (first instanceof Error) throw first + expect(repo.store.get("old")?.status).toBe("PAID") + + vendorFulfils() + const second = await runAt(15 * MINUTE) + if (second instanceof Error) throw second + expect(second.fulfilled).toBe(1) + expect(repo.store.get("old")?.status).toBe("FULFILLED") + }) + + it("a stalled order the vendor later reports unpaid is refunded then", async () => { + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + const first = await runAt(0) + if (first instanceof Error) throw first + expect(repo.store.get("old")?.status).toBe("PAID") + + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) + const second = await runAt(15 * MINUTE) + if (second instanceof Error) throw second + expect(second.refundRequired).toBe(1) + expect(repo.store.get("old")?.status).toBe("REFUND_REQUIRED") + }) + }) + it("PAID for 25h with the vendor returning fulfilled ends FULFILLED, not REFUND_REQUIRED", async () => { // A worker gap over 24h (outage, stuck lock) must not refund orders the // vendor fulfilled in the meantime. @@ -1064,8 +1140,9 @@ describe("reconcileGiftCardOrders", () => { }, ) - it("retries on the next run and escalates once the vendor answers 'still PAID'", async () => { + it("retries on the next run and escalates once the vendor answers 'never paid'", async () => { mockFetchAndSettle.mockResolvedValueOnce(new GiftCardVendorUnavailableError()) + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) const first = await runAt(0) if (first instanceof Error) throw first @@ -1083,6 +1160,7 @@ describe("reconcileGiftCardOrders", () => { const res = await runAt(0) if (res instanceof Error) throw res expect(res.refundRequired).toBe(0) + expect(res.fulfillmentStalled).toBe(0) expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) }) }) @@ -1180,6 +1258,7 @@ describe("reconcileGiftCardOrdersJob", () => { it("still escalates the 24h timeout while gift cards are disabled", async () => { mockConfig = makeGiftCardsConfig({ enabled: false }) + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) await reconcileGiftCardOrdersJob() expect(repo.store.get("old")?.status).toBe("REFUND_REQUIRED") diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts index 77a01a8ca..eb645518a 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts @@ -944,6 +944,8 @@ describe("BitcoinCompanyClient response validation", () => { }) }) +const QUOTE_TOKEN = "QUOTE-TOKEN-SHOULD-BE-REDACTED-71c2" + describe("BitcoinCompanyClient log hygiene", () => { it("never hands a token, credential, or claim value to the logger or the span", async () => { // Login + a status response that fails validation WITH claim data in it, @@ -960,15 +962,20 @@ describe("BitcoinCompanyClient log hygiene", () => { status: 123, claimData: { codes: [{ value: CLAIM_CODE }], claimLink: CLAIM_LINK }, }), + // A quote that fails validation WITH a token-named key: quote is a + // previewable op, so this is what proves the redaction path ran. + "/svs/quote-card": () => httpOk({ satsCost: "nope", token: QUOTE_TOKEN }), }), ) const client = makeClient() const status = await client.invoiceStatus(INVOICE) + const quote = await client.quoteCard(QUOTE_ARGS) mockRedisStore.clear() const relogin = await client.invoiceStatus(INVOICE) expect(status).toBeInstanceOf(GiftCardVendorUnavailableError) + expect(quote).toBeInstanceOf(GiftCardVendorUnavailableError) expect(relogin).toBeInstanceOf(GiftCardVendorUnavailableError) const logCalls = Object.values(mockedLogger).flatMap((mock) => mock.mock.calls) @@ -976,7 +983,7 @@ describe("BitcoinCompanyClient log hygiene", () => { const loggedText = JSON.stringify(logCalls) // Prove the redaction path ran, not that the body was merely dropped. expect(loggedText).toContain("[REDACTED]") - for (const secret of SECRET_STRINGS) { + for (const secret of [...SECRET_STRINGS, QUOTE_TOKEN]) { expect(loggedText).not.toContain(secret) } @@ -995,6 +1002,61 @@ describe("BitcoinCompanyClient log hygiene", () => { } }) + it("never previews an invoice-status body that fails validation, even under a key name redaction does not know", async () => { + // Key-name redaction only helps for names we predicted. A vendor rename + // (`claimData` → `claim`, `codes` → `code`) is exactly what makes the + // response fail validation, and this warn is the branch that then runs. + // So for anything that can carry a claim code, the body is never + // previewed at all; only the zod issue paths are logged. + const RENAMED_CODE = "RENAMED-CLAIM-CODE-9f3a" + mockedAxios.post.mockImplementation( + routePost({ + "/auth/login": () => httpOk(LOGIN_RESULT), + // `status: 123` is what fails validation; the renamed claim key is + // what must not reach the log because of it. + "/giftcards/invoice-status": () => + httpOk({ + status: 123, + claim: { code: RENAMED_CODE, link: "https://redeem.example/x" }, + }), + }), + ) + const client = makeClient() + + const status = await client.invoiceStatus(INVOICE) + expect(status).toBeInstanceOf(GiftCardVendorUnavailableError) + + const shapeWarn = mockedLogger.warn.mock.calls.find( + ([, msg]) => msg === "Bitcoin Company response failed validation", + ) + expect(shapeWarn).toBeDefined() + expect(shapeWarn?.[0]).toEqual(expect.objectContaining({ op: "getOrder" })) + expect(shapeWarn?.[0]).not.toHaveProperty("body") + + const loggedText = JSON.stringify( + Object.values(mockedLogger).flatMap((mock) => mock.mock.calls), + ) + expect(loggedText).not.toContain(RENAMED_CODE) + expect(loggedText).not.toContain("redeem.example") + }) + + it("still previews a catalog body that fails validation: no claim data can be in it", async () => { + mockedAxios.get.mockImplementation( + routeGet({ "/giftcards": () => httpOk({ products: "not-an-array" }) }), + ) + const client = makeClient() + + await client.listProducts() + + const shapeWarn = mockedLogger.warn.mock.calls.find( + ([, msg]) => msg === "Bitcoin Company response failed validation", + ) + expect(shapeWarn).toBeDefined() + expect(shapeWarn?.[0]).toEqual( + expect.objectContaining({ op: "listProducts", body: expect.any(String) }), + ) + }) + it("redactForLog masks sensitive keys at any depth and leaves the rest intact", () => { const redacted = redactForLog({ headers: { Authorization: "Bearer abc", Accept: "application/json" }, diff --git a/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts index 806f869fa..f08aa0e51 100644 --- a/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts +++ b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts @@ -271,7 +271,7 @@ describe("checkGiftCardOrderTransition", () => { ).toBeInstanceOf(GiftCardOrderStateError) }) - it("never lets a terminal state move anywhere, except EXPIRED → PAID", () => { + it("never lets a terminal state move anywhere, except EXPIRED → PAID / PAYMENT_PENDING", () => { // A send still in flight at IBEX when the worker expired the row, then // settled: the money left, and the order must be able to say so. Nothing // else terminal has a way back. @@ -287,22 +287,28 @@ describe("checkGiftCardOrderTransition", () => { .filter((to) => checkGiftCardOrderTransition([from], to) === true) .map((to) => `${from} → ${to}`), ) - expect(exits).toEqual(["EXPIRED → PAID"]) + expect(exits.sort()).toEqual(["EXPIRED → PAID", "EXPIRED → PAYMENT_PENDING"]) + const expiredExits: GiftCardOrderStatus[] = [ + GiftCardOrderStatus.Paid, + GiftCardOrderStatus.PaymentPending, + ] const refusals = terminal.flatMap((from) => allStatuses .filter( - (to) => - !(from === GiftCardOrderStatus.Expired && to === GiftCardOrderStatus.Paid), + (to) => !(from === GiftCardOrderStatus.Expired && expiredExits.includes(to)), ) .map((to) => checkGiftCardOrderTransition([from], to)), ) - expect(refusals).toHaveLength(terminal.length * allStatuses.length - 1) + expect(refusals).toHaveLength(terminal.length * allStatuses.length - 2) expect(refusals.every((r) => r instanceof GiftCardOrderStateError)).toBe(true) }) - it("EXPIRED has exactly one exit, to PAID", () => { - expect(GIFT_CARD_TRANSITIONS.EXPIRED).toEqual([GiftCardOrderStatus.Paid]) + it("EXPIRED exits only to PAID or PAYMENT_PENDING: a send in flight at expiry, resolved", () => { + expect(GIFT_CARD_TRANSITIONS.EXPIRED).toEqual([ + GiftCardOrderStatus.Paid, + GiftCardOrderStatus.PaymentPending, + ]) }) }) From 7fb378e9a8e73945692aac127f4bbda64aacaead Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 16 Sep 2026 12:22:34 -0700 Subject: [PATCH 13/14] fix(gift-cards): keep the poll cadence past the 24h horizon; re-review 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 Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX --- src/app/gift-cards/purchase-gift-card.ts | 2 +- src/app/gift-cards/reconcile-orders.ts | 41 +++++++++++-------- .../gift-cards/bitcoin-company/client.ts | 5 ++- .../app/gift-cards/reconcile-orders.spec.ts | 17 ++++++++ .../gift-cards/bitcoin-company/client.spec.ts | 10 ++--- 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/app/gift-cards/purchase-gift-card.ts b/src/app/gift-cards/purchase-gift-card.ts index 90b401b3d..ea5447dd2 100644 --- a/src/app/gift-cards/purchase-gift-card.ts +++ b/src/app/gift-cards/purchase-gift-card.ts @@ -776,7 +776,7 @@ const markPaymentPending = async ( id: order.id, from: [GiftCardOrderStatus.Expired], to: GiftCardOrderStatus.PaymentPending, - reason: `${reason}-after-expiry`, + reason: "payment-pending-after-expiry", patch: refPatch(providerPaymentRef), }) if (pending instanceof GiftCardOrderStateError) { diff --git a/src/app/gift-cards/reconcile-orders.ts b/src/app/gift-cards/reconcile-orders.ts index c4f193b95..57c4ecea8 100644 --- a/src/app/gift-cards/reconcile-orders.ts +++ b/src/app/gift-cards/reconcile-orders.ts @@ -285,6 +285,7 @@ const countVendorOutcome = ( if (!wasPaid) summary.paymentSettled += 1 summary.fulfilled += 1 lastAttemptAt.delete(after.id) + stalledReported.delete(after.id) return case GiftCardOrderStatus.Paid: // Vendor vouched for the payment but the claim could not be stored yet; @@ -295,6 +296,7 @@ const countVendorOutcome = ( case GiftCardOrderStatus.RefundRequired: summary.refundRequired += 1 lastAttemptAt.delete(after.id) + stalledReported.delete(after.id) return default: return @@ -566,31 +568,36 @@ const processPaid = async ( const paidAtMs = lastTransitionAt(order, GiftCardOrderStatus.Paid).getTime() const nowMs = now.getTime() + // The poll cadence (5s/15s/60s/5m, then 15m) applies on both sides of the + // 24h horizon. Past it, an order the vendor still calls in-progress is a + // steady state, and without this gate every 30s trigger tick would ask the + // vendor about it again: thousands of calls a day per stalled order. + if (nowMs < nextGiftCardPollAt(paidAtMs, lastAttemptAt.get(order.id))) return + lastAttemptAt.set(order.id, nowMs) + if (nowMs - paidAtMs >= GIFT_CARD_PAID_TIMEOUT_MS) { // One final vendor poll before escalating. A worker gap longer than 24h // (outage, kill switch, stuck lock) must not turn every order the vendor // fulfilled in the meantime into a refund. The two halves are used // separately here because the decision below needs the vendor's own // answer, not just its effect on the order. - lastAttemptAt.set(order.id, nowMs) - const vendorStatus = await fetchVendorOrderStatus(order) - const settled = - vendorStatus instanceof Error - ? vendorStatus - : await settleOrderFromVendor(order, vendorStatus) - if (settled instanceof Error) { - // A vendor 5xx, a claim-key fault (the card IS issued; only our storage - // failed), a repository error — none of these say the card is not - // coming. REFUND_REQUIRED on such an answer would refund a delivered - // card. Stay PAID; log the class; ask again next run. + // + // A vendor 5xx, a claim-key fault (the card IS issued; only our storage + // failed), a repository error — none of these say the card is not coming. + // REFUND_REQUIRED on such an answer would refund a delivered card. Stay + // PAID; log the class; ask again next run. + const keepPaidAfterFailedPoll = async (error: Error): Promise => { summary.finalPollFailed += 1 logger.warn( - { orderId: order.id, error: settled.constructor.name }, + { orderId: order.id, error: error.constructor.name }, "Final vendor poll before fulfilment timeout errored; keeping PAID for the next run", ) await touch(order) - return } + const vendorStatus = await fetchVendorOrderStatus(order) + if (vendorStatus instanceof Error) return keepPaidAfterFailedPoll(vendorStatus) + const settled = await settleOrderFromVendor(order, vendorStatus) + if (settled instanceof Error) return keepPaidAfterFailedPoll(settled) if (settled.status !== GiftCardOrderStatus.Paid) { countVendorOutcome(order, settled, summary) return @@ -607,7 +614,7 @@ const processPaid = async ( // may still ship. REFUND_REQUIRED on that would have ops refund an order // the vendor then delivers: customer keeps both. Those stay PAID, keep // polling on the normal schedule, and page a human once. - if (vendorStatus instanceof Error || vendorStatus.kind !== "awaitingPayment") { + if (vendorStatus.kind !== "awaitingPayment") { summary.fulfillmentStalled += 1 if (!stalledReported.has(order.id)) { stalledReported.add(order.id) @@ -617,7 +624,7 @@ const processPaid = async ( order, meta: { reason: "fulfillment-stalled", - vendorStatus: vendorStatus instanceof Error ? "unknown" : vendorStatus.kind, + vendorStatus: vendorStatus.kind, paidSince: new Date(paidAtMs).toISOString(), }, }) @@ -647,9 +654,6 @@ const processPaid = async ( return } - if (nowMs < nextGiftCardPollAt(paidAtMs, lastAttemptAt.get(order.id))) return - lastAttemptAt.set(order.id, nowMs) - const settled = await fetchAndSettle(order) if (settled instanceof Error) throw settled if (settled.status === GiftCardOrderStatus.Fulfilled) { @@ -757,6 +761,7 @@ export const reconcileGiftCardOrders = async ( "giftcard.reconcile.expired": summary.expired, "giftcard.reconcile.paymentSettled": summary.paymentSettled, "giftcard.reconcile.finalPollFailed": summary.finalPollFailed, + "giftcard.reconcile.fulfillmentStalled": summary.fulfillmentStalled, }) return summary } finally { diff --git a/src/services/gift-cards/bitcoin-company/client.ts b/src/services/gift-cards/bitcoin-company/client.ts index c970e61f6..e02118093 100644 --- a/src/services/gift-cards/bitcoin-company/client.ts +++ b/src/services/gift-cards/bitcoin-company/client.ts @@ -247,8 +247,9 @@ type ParseArgs = { /** * Whether a response that fails validation may be previewed (redacted) in * the warn log. Off for anything that can carry a token or a claim code: - * the branch that logs is precisely the one taken when the vendor renames a - * field, and key-name redaction cannot know the new name. + * the branch that logs is precisely the one taken for a response shape the + * schema did not predict, and key-name redaction only covers the names we + * did predict. */ logBody: boolean } diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index b52123bd8..77b53234d 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -1040,6 +1040,23 @@ describe("reconcileGiftCardOrders", () => { ).toHaveLength(1) }) + it("a stalled order keeps the 15-minute poll cadence, not one call per tick", async () => { + // Past the horizon the order is a steady state. Without the cadence + // gate every 30s trigger tick would ask the vendor again: thousands + // of calls per stalled order per day. + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) + + await runAt(0) + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) + await runAt(30 * SECOND) + await runAt(5 * MINUTE) + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) + await runAt(15 * MINUTE) + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(2) + expect(repo.store.get("old")?.status).toBe("PAID") + }) + it("a stalled order the vendor later fulfils ends FULFILLED", async () => { mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) paidOrder("old", -GIFT_CARD_PAID_TIMEOUT_MS) diff --git a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts index eb645518a..13b475195 100644 --- a/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts +++ b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts @@ -1003,11 +1003,11 @@ describe("BitcoinCompanyClient log hygiene", () => { }) it("never previews an invoice-status body that fails validation, even under a key name redaction does not know", async () => { - // Key-name redaction only helps for names we predicted. A vendor rename - // (`claimData` → `claim`, `codes` → `code`) is exactly what makes the - // response fail validation, and this warn is the branch that then runs. - // So for anything that can carry a claim code, the body is never - // previewed at all; only the zod issue paths are logged. + // Key-name redaction only helps for names we predicted. A response shape + // the schema does not accept is exactly when this warn runs, and such a + // response can carry claim data under keys we did not predict. So for + // anything that can carry a claim code, the body is never previewed at + // all; only the zod issue paths are logged. const RENAMED_CODE = "RENAMED-CLAIM-CODE-9f3a" mockedAxios.post.mockImplementation( routePost({ From 62977830eb1c8a11f079012c5a6c3bb95916239a Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 16 Sep 2026 12:24:24 -0700 Subject: [PATCH 14/14] style(gift-cards): format reconcile spec with prettier 3.6.2 (the version CI runs) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX --- test/flash/unit/app/gift-cards/reconcile-orders.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts index 77b53234d..45e710d4f 100644 --- a/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -982,7 +982,9 @@ describe("reconcileGiftCardOrders", () => { // One final vendor poll precedes the escalation, and it is settled first. expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) expect(mockFetchVendorStatus).toHaveBeenCalledWith(order) - expect(mockSettleFromVendor).toHaveBeenCalledWith(order, { kind: "awaitingPayment" }) + expect(mockSettleFromVendor).toHaveBeenCalledWith(order, { + kind: "awaitingPayment", + }) expect(mockNotifyOpsEvent).toHaveBeenCalledWith( expect.objectContaining({ flow: "giftcard",