diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 3772037c6..a94a1b010 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1329,6 +1329,378 @@ 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! + + """ + 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 + + """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. 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) + + """ + 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 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 + + """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 + + """ + 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.""" +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 +1728,15 @@ 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 the catalog, quote, and purchase fields + refuse. Order history and delivered codes stay readable regardless. + """ + giftCardsEnabled: Boolean! + """ The domain name for lightning addresses accepted by this Galoy instance """ @@ -1995,6 +2376,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 +2886,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. A FULFILLED order whose claim cannot currently be read is still returned, with `claim` null. + """ + 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/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/docs/gift-cards/ALERTING.md b/docs/gift-cards/ALERTING.md new file mode 100644 index 000000000..776b842bb --- /dev/null +++ b/docs/gift-cards/ALERTING.md @@ -0,0 +1,123 @@ +# 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, **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 — `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) | +| `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 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` | + +`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` | +| `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` 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` 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 + +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`. 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 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 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 + +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` | 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, `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` | +| `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); 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 new file mode 100644 index 000000000..1f60f4cd2 --- /dev/null +++ b/docs/gift-cards/API.md @@ -0,0 +1,238 @@ +# Gift Cards GraphQL API Reference + +All fields require an authenticated session (`GraphQLPublicContextAuth`). +`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`). + +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 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 + +### `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 wholeUnitsOnly + maxQuantity 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). +- `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`. 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` + +```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 steps, same +errors), so the client can surface problems before the confirm screen. Not a +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` + +```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). `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`, `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 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. + +### `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 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: + +- 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, 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`). 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. 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 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. 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), 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 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 + +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 (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. 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, 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 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 | + +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 +**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 new file mode 100644 index 000000000..64924e144 --- /dev/null +++ b/docs/gift-cards/ARCHITECTURE.md @@ -0,0 +1,257 @@ +# 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 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`, 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 (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` | +| 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 +mismatched 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. + +| 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`. 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` 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, 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`. +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 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 +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 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 + +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 +EXPIRED -> PAID | PAYMENT_PENDING (a send in flight at expiry, resolved; see below) +FULFILLED -> (terminal) +FAILED -> (terminal) +PAYMENT_FAILED -> (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: +`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 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`. + +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`, 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 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 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 + +**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 `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 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 +`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. 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 `stale: true`; past `staleAfterSeconds` the key is gone and reads become +`GiftCardCatalogUnavailableError`. + +**`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 + +| 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; 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` | +| `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`); 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 Redis user imports `@services/redis` lazily so unit tests never connect. + +## Mongo: `giftcardorders` + +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 | +| --- | --- | --- | --- | +| `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` 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. 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) +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 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/docs/gift-cards/CONFIG.md b/docs/gift-cards/CONFIG.md new file mode 100644 index 000000000..aa081dc68 --- /dev/null +++ b/docs/gift-cards/CONFIG.md @@ -0,0 +1,144 @@ +# 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 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 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 -> 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** | +| `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`. 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 | +| `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`). + +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. + +## 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`. 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); `byCountry` keys are normalised the same + way when read. +2. `routing.byCountry[cc]`, else `routing.default`. +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, 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 +code (`{ JM: bitrefill }` once that adapter lands). + +## Limits modes + +`limits.mode` mirrors `sendGuard.mode` (`docs/send-guard.md`): + +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` | +| --- | --- | --- | --- | +| 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 +(`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 new file mode 100644 index 000000000..651e4c7eb --- /dev/null +++ b/docs/gift-cards/FLOWS.md @@ -0,0 +1,293 @@ +# 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 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) | | | | + |------------->| (no gate here) | | | | + | |------------------>| wallet belongs to account | | | + | | | findByIdempotencyKey --> | (miss) | | + | | | consumeLimiter (10/min) | | | + | | | giftCardsMasterGate | | | + | | | giftCardClaimKeyId loads? | | | + | | | getGiftCardProduct (Redis)| | | + | | | checkedGiftCardValue/Quantity, country | | + | | | 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` 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 + +```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** 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 + +```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 expires unpaid on its side; the customer 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 -> leave while young (IBEX's word); + | past the 60-min warn horizon the vendor is polled too: + | 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, + | 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 +``` + +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, 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 + +`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`) says nothing about whether money +moved, so the order goes `INVOICE_ISSUED -> PAYMENT_PENDING` with reason +`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`); 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 +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) + +```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 | +``` + +After `GIFT_CARD_PAID_TIMEOUT_MS` (24 h) in `PAID`, `processPaid` makes one +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. + +## 6. Expiry + +```ascii +Worker Repo Vendor + | listByStatus(CREATED, INVOICE_ISSUED) | + | filter expiresAt < now | | + | processExpiry: | | + | INVOICE_ISSUED? fetchAndSettle (vendor) --------------------------------->| + | fulfilled -> PAID (vendor-reported-fulfilled) -> FULFILLED + | 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] | +``` + +`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. 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, 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 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 + +```ascii +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 + | | | ... EXCEPT INVOICE_ISSUED still inside + | | | expiresAt -> resume the pay step + | | | (giftcard.replay.resumedPayment=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, 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 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 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/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..9da0680fa --- /dev/null +++ b/docs/gift-cards/RUNBOOK.md @@ -0,0 +1,265 @@ +# Gift Cards Runbook + +Operator procedures. Mongo queries use the node + mongoose one-liner pattern +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): + +```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 `getGiftCardOrderForAccount`. + +## (a) A `REFUND_REQUIRED` order + +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`, + `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`): 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 + 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 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 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 and only when the vendor +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. +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`, 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, 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` + +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 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 (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 its in-flight orders keep + reconciling as above. +- Open-loop only: `giftCards.allowOpenLoop: false`. + +## (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 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()` (`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". 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 (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` / `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. +2. Write the adapter: `client.ts` (transport, auth, retries on idempotent + 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 new file mode 100644 index 000000000..229224d5f --- /dev/null +++ b/docs/gift-cards/TESTING.md @@ -0,0 +1,125 @@ +# 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/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 (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: 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 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, 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` +(`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 + +- `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. +- `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. +- 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/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..3df332aa6 --- /dev/null +++ b/src/app/gift-cards/authorize-purchase.ts @@ -0,0 +1,371 @@ +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)`. + * + * 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. + */ +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, +}) + +/** + * Who may buy at all. Pure: no store reads. Enforced in every limits mode. + * Returns the first failure, or `true`. + */ +const checkEligibility = ({ + account, + product, + nowMs, +}: Required): true | Rejection => { + const limits = GiftCardsConfig.limits + const level = effectiveAccountLevel(account.level) + + // 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, + } + } + } + + 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 + ? 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, + 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({ + "giftcard.limits.mode": mode, + "giftcard.limits.reason": rejection.reason, + "giftcard.limits.error": rejection.error.constructor.name, + }) + notifyOpsEvent({ + flow: "giftcard", + 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, + 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 + const nowMs = args.nowMs ?? Date.now() + const totalMinor = args.valueMinor * args.quantity + + // 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({ + 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..6428d5bb3 --- /dev/null +++ b/src/app/gift-cards/get-order.ts @@ -0,0 +1,72 @@ +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 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 +} + +/** + * 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. + * + * 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, + 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, 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 } + + return { order, claim, claimError: null } +} 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..155eb1f23 --- /dev/null +++ b/src/app/gift-cards/gift-cards-master-gate.ts @@ -0,0 +1,128 @@ +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, 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 + /** Normalised (trimmed, upper-cased) country the route was resolved for; `UNKNOWN_COUNTRY_CODE` when unknown. */ + countryCode: string + countryKnown: boolean + } + | { 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, + countryCode: cc, + countryKnown: cc !== UNKNOWN_COUNTRY_CODE, + } +} + +/** + * 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..c9678de44 --- /dev/null +++ b/src/app/gift-cards/ops.ts @@ -0,0 +1,65 @@ +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" + /** 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. */ + | "paid-not-recorded" + +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..ea5447dd2 --- /dev/null +++ b/src/app/gift-cards/purchase-gift-card.ts @@ -0,0 +1,950 @@ +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 { IdempotencyKeyReuseError, InvalidIdempotencyKeyError } from "@domain/errors" +import { + checkedGiftCardQuantity, + checkedGiftCardValue, + GIFT_CARD_QUOTE_TOLERANCE_BPS, + GiftCardIdempotencyKeyReuseError, + GiftCardOrderNotFoundError, + GiftCardOrderStateError, + GiftCardOrderStatus, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, + 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 { 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" +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. + * + * 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 — 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. 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 + 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. The vendor's BOLT11 is 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 + +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 () => { + const verdict = await judgeSend({ senderAccount, senderWalletId, paymentRequest }) + if (verdict instanceof Error) onReject() + return verdict + } + +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, + 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, + 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) + } + + 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 (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 + + 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 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) + 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() + } + // ...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, product.maxQuantity) + 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() + } + // 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 }) + + 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 + } + + // 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], + to: GiftCardOrderStatus.InvoiceIssued, + reason: "vendor-invoice-issued", + patch: { + providerOrderId: vendorOrder.providerOrderId, + paymentRequest: vendorOrder.paymentRequest, + invoiceSats, + paymentHash: decoded.paymentHash, + expiresAt: earliestDate( + created.expiresAt, + decoded.expiresAt, + vendorOrder.expiresAt, + ), + }, + }) + if (issued instanceof Error) { + // Nothing paid. The row stays CREATED and the reconcile worker expires it. + recordExceptionInCurrentSpan({ error: issued }) + return issued + } + + 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 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, + 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 => { + // 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, + } + const payment = await payLnInvoiceViaIbex({ + senderWalletId: walletId, + senderAccount: account, + paymentRequest, + idempotencyKey: `giftcard:${order.id}`, + requestFingerprint: `ln|${paymentRequest}|giftcard|${order.id}`, + authorize: buildSendGuardHook({ + senderAccount: account, + senderWalletId: walletId, + paymentRequest, + onReject: () => { + captured.guardRejected = true + }, + }), + 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) { + 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, + `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 + // as a fresh object, not the `PaymentSendStatus` constant. + switch (payment.value) { + case PaymentSendStatus.Success.value: + case PaymentSendStatus.AlreadyPaid.value: + return markPaidAndSettle(order, invoiceSats, providerPaymentRef) + case PaymentSendStatus.Pending.value: + return markPaymentPending(order, { reason: "payment-pending", providerPaymentRef }) + 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 + * 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() + let pending = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued], + to: GiftCardOrderStatus.PaymentPending, + reason, + patch: refPatch(providerPaymentRef), + }) + if (pending instanceof GiftCardOrderStateError) { + // 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({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: latest, + }) + } + 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: "payment-pending-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({ + order, + providerPaymentRef, + intended: GiftCardOrderStatus.PaymentPending, + cause: pending, + }) + } + notifyGiftCardOpsEvent({ + phase: "payment-pending", + status: "pending", + order: pending, + error: error?.constructor.name, + meta: { reason }, + }) + 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 repo = GiftCardOrdersRepository() + const failed = await repo.transition({ + id: order.id, + from: [GiftCardOrderStatus.InvoiceIssued, GiftCardOrderStatus.PaymentPending], + to: GiftCardOrderStatus.PaymentFailed, + reason, + 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", + ) + } + notifyGiftCardOpsEvent({ + phase: "order-failed", + status: "failed", + order, + error: error?.constructor.name ?? "PaymentSendStatusFailure", + meta: { reason }, + }) + return failed instanceof Error ? order : failed +} + +const markPaidAndSettle = async ( + order: GiftCardOrder, + invoiceSats: Satoshis, + providerPaymentRef: string | null, +): Promise => { + 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, + }) + 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. 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 }, + "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..894fe5c6c --- /dev/null +++ b/src/app/gift-cards/quote-gift-card.ts @@ -0,0 +1,120 @@ +import { + checkedGiftCardQuantity, + checkedGiftCardValue, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, + normalizeCountryCode, +} 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 { + giftCardsMasterGate, + resolveAccountCountryCodeOrUnknown, +} from "./gift-cards-master-gate" +import { getGiftCardProduct } from "./list-products" + +/** + * ENG-582 — a price for the storefront, BEFORE any order exists. + * + * 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 + * 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 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 + * 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, + }) + + // 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 + + 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() + } + // 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, product.maxQuantity) + 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..57c4ecea8 --- /dev/null +++ b/src/app/gift-cards/reconcile-orders.ts @@ -0,0 +1,826 @@ +import { randomUUID } from "crypto" + +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +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" +import { baseLogger } from "@services/logger" +import { GiftCardOrdersRepository } from "@services/mongoose" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@services/tracing" + +import { notifyGiftCardOpsEvent } from "./ops" +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. +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 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; + * `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; + * 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 + * 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` 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. + * + * 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, 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 + fulfilled: number + refundRequired: number + expired: number + 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" +} + +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 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( + 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, + * 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() + +// 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. */ +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 + } + 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 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 — + * 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", + ) + } +} + +/** + * 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) + stalledReported.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) + stalledReported.delete(after.id) + return + default: + return + } +} + +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 +} + +/** 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, + * 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 pollVendor = async ( + order: GiftCardOrder, + summary: GiftCardReconcileSummary, +): 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 { vendorStatus: status.kind, moved: null } + } + if (settled.status === order.status) return { vendorStatus: status.kind, moved: null } + countVendorOutcome(order, settled, summary) + return { vendorStatus: status.kind, moved: settled } +} + +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 or + // 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 — 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. + // + // 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({ + 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 + } + 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 — + // 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": + // 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 + } + + 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", + ) + } + await touch(order) +} + +const processPaid = async ( + order: GiftCardOrder, + now: Date, + summary: GiftCardReconcileSummary, +): Promise => { + 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. + // + // 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: error.constructor.name }, + "Final vendor poll before fulfilment timeout errored; keeping PAID for the next run", + ) + await touch(order) + } + 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 + } + + // 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.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.kind, + paidSince: new Date(paidAtMs).toISOString(), + }, + }) + } + await touch(order) + return + } + + 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) + stalledReported.delete(order.id) + summary.refundRequired += 1 + notifyGiftCardOpsEvent({ + phase: "refund-required", + status: "failed", + order: refund, + error: "fulfillment-timeout", + meta: { reason: "fulfillment-timeout", lastVendorPoll: "awaiting-payment" }, + }) + return + } + + 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) + } else { + await touch(order) + } +} + +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, + finalPollFailed: 0, + fulfillmentStalled: 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, + "giftcard.reconcile.finalPollFailed": summary.finalPollFailed, + "giftcard.reconcile.fulfillmentStalled": summary.fulfillmentStalled, + }) + return summary + } finally { + await releaseLock(token) + } +} + +/** + * 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 (!(await hasOpenGiftCardOrders())) 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. Runs + * regardless of `giftCards.enabled`; each tick first checks for open orders. + */ +export const startGiftCardReconcileInterval = ( + intervalMs: number = GIFT_CARD_RECONCILE_INTERVAL_MS, +): 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") + } + } 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..bc0558992 --- /dev/null +++ b/src/app/gift-cards/settle-order.ts @@ -0,0 +1,352 @@ +import { GiftCardOrderStateError, GiftCardOrderStatus } from "@domain/gift-cards" +import { encryptGiftCardClaim } from "@services/gift-cards/claim-crypto" +import { getRegisteredGiftCardProviderOrError } 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. + * + * 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, 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. + */ +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": + return order + case "paidPendingFulfillment": + return vendorVouchedPayment(order) + case "fulfilled": + return fulfil(order, status.claim) + case "failed": + case "refunded": + return vendorFailed(order, status) + } +} + +/** + * `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 **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.PaymentPending && !order.providerPaymentRef) + ) { + return order + } + + const paid = await GiftCardOrdersRepository().transition({ + id: order.id, + from: [ + GiftCardOrderStatus.InvoiceIssued, + GiftCardOrderStatus.Expired, + ...(order.status === GiftCardOrderStatus.PaymentPending + ? [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 paid +} + +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, 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.Expired + ) { + const paid = await repo.transition({ + id: current.id, + from: [ + GiftCardOrderStatus.InvoiceIssued, + GiftCardOrderStatus.PaymentPending, + GiftCardOrderStatus.Expired, + ], + 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 / 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 + } + + // 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 + // 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: { + // 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 + } +} + +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, 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 fetchVendorOrderStatus = async ( + order: GiftCardOrder, + opts?: FetchVendorOrderOptions, +): Promise => { + const provider = getRegisteredGiftCardProviderOrError(order.providerId) + if (provider instanceof Error) return provider + + if (!order.providerOrderId) { + return new GiftCardOrderStateError("Order has no provider order id to look up") + } + + 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 new file mode 100644 index 000000000..4da4bdd91 --- /dev/null +++ b/src/app/gift-cards/sync-catalog.ts @@ -0,0 +1,259 @@ +import { randomBytes } from "crypto" + +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" +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) + + // 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, + 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..7de50a164 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -318,6 +318,35 @@ export const getFygaroCheckoutCreateAttemptLimits = () => ({ blockDuration: toSeconds(300), // 5 minutes }) +export const getGiftCardPurchaseAttemptLimits = () => ({ + points: 10, + duration: toSeconds(60), // 1 minute + 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. * @@ -630,4 +659,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/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/domain/gift-cards/errors.ts b/src/domain/gift-cards/errors.ts new file mode 100644 index 000000000..273904f0f --- /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 mismatched 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..b53379cad --- /dev/null +++ b/src/domain/gift-cards/index.ts @@ -0,0 +1,76 @@ +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". + * + * 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", + InvoiceIssued: "INVOICE_ISSUED", + PaymentPending: "PAYMENT_PENDING", + Paid: "PAID", + Fulfilled: "FULFILLED", + Failed: "FAILED", + PaymentFailed: "PAYMENT_FAILED", + Expired: "EXPIRED", + RefundRequired: "REFUND_REQUIRED", +} as const + +/** + * "Nothing left for the worker to do." EXPIRED is included even though it has + * 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, + 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: [], + // 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: [], +} + +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..dc12bdaa2 --- /dev/null +++ b/src/domain/gift-cards/index.types.d.ts @@ -0,0 +1,170 @@ +// 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 + /** + * 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 = { + 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 + /** + * 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 = + | { 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, + 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 +} + +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..0d116c962 --- /dev/null +++ b/src/domain/gift-cards/primitives.ts @@ -0,0 +1,115 @@ +import { GiftCardInvalidValueError, GiftCardProductNotFoundError } from "./errors" + +export const GIFT_CARD_PROVIDER_IDS: readonly GiftCardProviderId[] = [ + "bitcoinCompany", + "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) + +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; 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" | "wholeUnitsOnly" + >, + valueMinor: number, +): number | GiftCardInvalidValueError => { + if (!Number.isSafeInteger(valueMinor) || valueMinor <= 0) { + return new GiftCardInvalidValueError( + "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 + : 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 +} + +/** + * 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, + 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/domain/rate-limit/errors.ts b/src/domain/rate-limit/errors.ts index 2e8cf1391..7d9975d71 100644 --- a/src/domain/rate-limit/errors.ts +++ b/src/domain/rate-limit/errors.ts @@ -21,6 +21,8 @@ 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 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 a6c78ae3f..f9228eb54 100644 --- a/src/domain/rate-limit/index.ts +++ b/src/domain/rate-limit/index.ts @@ -3,6 +3,8 @@ import { getFailedLoginAttemptPerIpLimits, getFailedLoginAttemptPerLoginIdentifierLimits, getFygaroCheckoutCreateAttemptLimits, + getGiftCardPurchaseAttemptLimits, + getGiftCardQuoteAttemptLimits, getFygaroTopupAllowanceAttemptLimits, getInviteCreateAttemptLimits, getInviteTargetAttemptLimits, @@ -19,6 +21,8 @@ import { import { ConsentLogIpRateLimiterExceededError, FygaroCheckoutCreateRateLimiterExceededError, + GiftCardPurchaseRateLimiterExceededError, + GiftCardQuoteRateLimiterExceededError, FygaroTopupAllowanceRateLimiterExceededError, InviteCreateRateLimiterExceededError, InviteTargetRateLimiterExceededError, @@ -45,6 +49,8 @@ export const RateLimitPrefix = { inviteCreate: "invite_daily", 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", @@ -114,6 +120,21 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = { limits: getFygaroCheckoutCreateAttemptLimits(), error: FygaroCheckoutCreateRateLimiterExceededError, }, + giftCardPurchase: { + key: RateLimitPrefix.giftCardPurchase, + 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 ae19fd02c..aa42ede01 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -1147,6 +1147,134 @@ 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 "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({ + code: "GIFT_CARD_UNKNOWN", + message, + }) + default: return assertUnreachable(errorName) } 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..688c74a3b --- /dev/null +++ b/src/graphql/public/root/gift-card-gate.ts @@ -0,0 +1,38 @@ +import { giftCardsMasterGate, resolveAccountCountryCodeOrUnknown } from "@app/gift-cards" + +/** + * 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. + * + * 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 } + | { + 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..a6aa3a9be --- /dev/null +++ b/src/graphql/public/root/mutation/gift-card-purchase.ts @@ -0,0 +1,121 @@ +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 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`, + }, + ], + } + } + + // 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: + // 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. 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, + }) + if (withClaim instanceof Error) { + return { + errors: [mapAndParseErrorForGqlResponse(withClaim)], + order: toGiftCardOrderSource(order), + } + } + if (withClaim.claimError) { + return { + errors: [mapAndParseErrorForGqlResponse(withClaim.claimError)], + order: toGiftCardOrderSource(withClaim.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..e554321c2 --- /dev/null +++ b/src/graphql/public/root/query/gift-card-order.ts @@ -0,0 +1,74 @@ +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 } + +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. A FULFILLED order whose claim cannot currently be read is " + + "still returned, with `claim` null.", + 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) + + if (result.claimError) { + // The order is real, paid for, and the caller's; the only thing wrong is + // that its claim will not decrypt right now (key rotated, or this pod has + // stale config). Throwing here would hide the order itself — the customer + // could not even see it exists. Return it with `claim` null and page on + // the fault: a FULFILLED order nobody can read is a Critical until fixed. + // The key id is a fingerprint, safe to log; the ciphertext never is. + recordExceptionInCurrentSpan({ + error: result.claimError, + level: ErrorLevel.Critical, + attributes: { + "giftcard.orderId": result.order.id, + "giftcard.claimKeyId": result.order.claimKeyId ?? undefined, + }, + }) + baseLogger.error( + { + orderId: result.order.id, + claimKeyId: result.order.claimKeyId, + error: result.claimError.constructor.name, + reason: result.claimError.message, + }, + "Gift card claim could not be read for a FULFILLED order", + ) + } + + 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..ccd3a8e70 --- /dev/null +++ b/src/graphql/public/root/query/gift-card-quote.ts @@ -0,0 +1,68 @@ +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({ + 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. " + + "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..962536674 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1090,6 +1090,348 @@ 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! + + """ + 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 + + """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. 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 + + """ + 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 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 + + """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 + + """ + 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.""" +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 +1457,15 @@ 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 the catalog, quote, and purchase fields + refuse. Order history and delivered codes stay readable regardless. + """ + giftCardsEnabled: Boolean! + """ The domain name for lightning addresses accepted by this Galoy instance """ @@ -1627,6 +1978,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 +2396,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. A FULFILLED order whose claim cannot currently be read is still returned, with `claim` null. + """ + 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..2fa8fe3d7 --- /dev/null +++ b/src/graphql/public/types/object/gift-card-order.ts @@ -0,0 +1,280 @@ +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. 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, + 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: + "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, + 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..ea0c44102 --- /dev/null +++ b/src/graphql/public/types/object/gift-card-product.ts @@ -0,0 +1,134 @@ +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.", + }, + 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.", + }, + }), +}) + +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..0d0995429 100644 --- a/src/graphql/public/types/object/globals.ts +++ b/src/graphql/public/types/object/globals.ts @@ -60,6 +60,14 @@ 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 the catalog, quote, and purchase fields + refuse. Order history and delivered codes stay readable regardless.`, + }, 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/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..92d242b71 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,33 @@ 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. +// 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() +} + +// 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 +173,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..48b2c8b24 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,12 @@ 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. 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() console.log("trigger server ready") diff --git a/src/services/alerts/ops-events.ts b/src/services/alerts/ops-events.ts index c25dd7ef2..05046fa06 100644 --- a/src/services/alerts/ops-events.ts +++ b/src/services/alerts/ops-events.ts @@ -22,7 +22,13 @@ 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 +59,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..e02118093 --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/client.ts @@ -0,0 +1,816 @@ +import { randomUUID } from "crypto" + +import axios, { isAxiosError } from "axios" +import { z } from "zod" + +import { GiftCardsConfig } from "@config" + +import { + GiftCardError, + GiftCardInvalidValueError, + 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 +/** 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 + +// ============ 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 + /** + * 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 for a response shape the + * schema did not predict, and key-name redaction only covers the names we + * did predict. + */ + 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 + /** 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)) + +/** + * 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 } + 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. + * + * 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 invalid = 0 + let offset = 0 + let pages = 0 + let reachedEnd = false + + while (pages < CATALOG_MAX_PAGES) { + 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 + pages++ + + if (result.svs.length === 0) { + reachedEnd = true + break + } + + for (const row of result.svs) { + const parsed = vendorProductSchema.safeParse(row) + if (parsed.success) { + products.push(parsed.data) + } else { + invalid++ + } + } + offset += result.svs.length + } + + 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 (invalid > 0) { + baseLogger.warn( + { provider: PROVIDER, op: "listProducts", invalid, kept: products.length }, + "Bitcoin Company catalog rows failed validation and were skipped", + ) + } + return { products, invalid } + } + + 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 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: opts.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: PREVIEWABLE_OPS.has(args.op) }, + 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", + ) + // 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() + } + + 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..25b536b3a --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/index.ts @@ -0,0 +1,223 @@ +import { + GiftCardError, + GiftCardInvalidValueError, + 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_MAX_QUANTITY, + BITCOIN_COMPANY_PROVIDER_ID, + PRODUCT_MAPPING_SKIP_REASONS, + ProductMappingSkipReason, + isProductMappingSkip, + isResellingDisabled, + 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, +}) + +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 => { + const client = deps.client ?? new BitcoinCompanyClient(deps.clientDeps) + const now = deps.now ?? (() => new Date()) + + const listProducts = async (): Promise => { + addAttributesToCurrentSpan(spanAttributes("listProducts")) + const catalog = await client.listProducts() + if (catalog instanceof Error) return catalog + + const products: GiftCardProduct[] = [] + 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] += 1 + continue + } + if (isResellingDisabled(row)) notResellable += 1 + products.push(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 + } + + const quote = async ({ + product, + valueMinor, + quantity, + }: { + product: GiftCardProduct + valueMinor: number + 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), + 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 tooMany = checkQuantity(quantity) + if (tooMany) return tooMany + const vendor = await client.purchase({ + productId: product.providerProductId, + cardValue: toMajorUnits(valueMinor), + quantity, + label: reference, + }) + if (vendor instanceof Error) return vendor + return mapVendorPurchase(vendor) + } + + 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, { + retry: opts.retry ?? true, + }) + 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..53e02e6d9 --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/mapping.ts @@ -0,0 +1,298 @@ +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 + +/** + * 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 + +// 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 + } +} + +/** + * 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 = + | "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 } + +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)), + ].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: "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: "unknownDenominationType" } + + const denominations = toMinorDenominations(product.denominations) + if (denominations.length === 0) return { skipped: "noDenominations" } + + 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, + 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", + } +} + +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: + vendor.bitcoinPrice === null || vendor.bitcoinPrice === undefined + ? null + : toMinorUnits(vendor.bitcoinPrice), + expiresAt: new Date(now.getTime() + QUOTE_TTL_MS), +}) + +/** + * 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. + * + * `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 ?? 0)), + expiresAt: null, +}) + +/** 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 } +} + +/** + * 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. +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", + // 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", +} + +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 "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) { + 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..a4e774763 --- /dev/null +++ b/src/services/gift-cards/bitcoin-company/schemas.ts @@ -0,0 +1,139 @@ +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). + * + * 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. */ +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, + // 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(), +}) +export type VendorQuote = z.infer + +export const purchaseResultSchema = z.object({ + invoice: z.string().min(1), + address: z.string().nullish(), + // 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(), +}) +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 + +/** + * 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(), +}) +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..de8c70f29 --- /dev/null +++ b/src/services/gift-cards/catalog-cache.ts @@ -0,0 +1,356 @@ +import { z } from "zod" + +import { GiftCardsConfig } from "@config" + +import { CacheUndefinedError } from "@domain/cache" +import { toSeconds } from "@domain/primitives" +import { + GiftCardCatalogUnavailableError, + GiftCardProductNotFoundError, + isGiftCardProviderId, + 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:products: -> GiftCardProductId[] written by the last sync + * 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. + * + * 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[] + syncedAt: Date + stale: boolean +} + +type StoredCatalog = { + syncedAt: string + products: unknown[] +} + +export const giftCardCatalogKey = (providerId: GiftCardProviderId, countryCode: string) => + `giftcards:catalog:${providerId}:${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}` + +const retentionSeconds = (): Seconds => + toSeconds(GiftCardsConfig.catalog.staleAfterSeconds) + +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 && + 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 +} + +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, + syncedAt, + }: { + providerId: GiftCardProviderId + products: GiftCardProduct[] + syncedAt: Date + }): Promise<{ countries: string[] } | GiftCardCatalogUnavailableError> => { + 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) + 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 = { 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) + } + } + + // 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), + 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 unparsable syncedAt", + ) + 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, + 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() + } + + const product = toCachedProduct(stored) + if (!product) { + baseLogger.error({ productId }, "gift card product record has an unexpected shape") + return new GiftCardProductNotFoundError() + } + return product + } + + return wrapAsyncFunctionsToRunInSpan({ + namespace: "services.gift-cards.catalog-cache", + fns: { write, read, readProduct }, + }) +} diff --git a/src/services/gift-cards/claim-crypto.ts b/src/services/gift-cards/claim-crypto.ts new file mode 100644 index 000000000..cfcf616ff --- /dev/null +++ b/src/services/gift-cards/claim-crypto.ts @@ -0,0 +1,203 @@ +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 + * + * 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 + * 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}$/ +// 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 + 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 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 && + 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) +} + +/** + * 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(aadFor(keyId, orderId)) + 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 } + } catch { + return new GiftCardClaimCryptoError("Could not encrypt gift card details") + } +} + +export const decryptGiftCardClaim = ({ + ciphertext, + keyId, + orderId, +}: { + ciphertext: string + keyId: string + orderId: GiftCardOrderId +}): GiftCardClaim | GiftCardClaimCryptoError => { + const key = loadKey() + if (key instanceof Error) return key + + if (keyId !== keyIdFor(key)) { + return new GiftCardClaimCryptoError("key rotated") + } + + 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") + } + 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(aadFor(keyId, orderId)) + 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..be0f42ada --- /dev/null +++ b/src/services/gift-cards/registry.ts @@ -0,0 +1,97 @@ +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` + * 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`. + */ +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) + +/** + * 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 +} + +/** 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 && + 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 routed = routedProviderId(normalizeCountryCode(countryCode)) + 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 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 => { + 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..ab84906e4 --- /dev/null +++ b/src/services/mongoose/gift-card-orders.ts @@ -0,0 +1,317 @@ +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 + > + /** + * 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 +} + +/** + * 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) + } + } + + 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, + findByIdempotencyKey, + findByProviderOrderId, + listByAccount, + listByStatus, + transition, + touch, + } +} 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/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/authorize-purchase.spec.ts b/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts new file mode 100644 index 000000000..90e9a642e --- /dev/null +++ b/test/flash/unit/app/gift-cards/authorize-purchase.spec.ts @@ -0,0 +1,605 @@ +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" + // 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) + // 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 ? (rejected ? "rejected" : "would-reject") : null, + ) + expect(reported?.meta?.reason ?? null).toBe(wouldReject ? c.expected : null) + }) + } + } + + it("off: authorises an eligible account without evaluating any limit", async () => { + withMode("off") + const res = await authorize({ + account: makeAccount({ level: 1 }), + valueMinor: 9_999_999, + }) + expect(res).toEqual({ authorized: true, reservationId: null }) + expect(mockListByAccount).not.toHaveBeenCalled() + expect(mockZadd).not.toHaveBeenCalled() + 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 }, + }) + 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..de026536d --- /dev/null +++ b/test/flash/unit/app/gift-cards/fixtures.ts @@ -0,0 +1,294 @@ +// 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 +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, + maxQuantity: 10, + wholeUnitsOnly: false, + ...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: + * 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() + let seq = 0 + + 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): 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): Promise => + store.get(id) ?? new GiftCardOrderNotFoundError(), + ), + findByIdempotencyKey: jest.fn( + async ({ + walletId, + idempotencyKey, + }: { + walletId: string + idempotencyKey: string + }): Promise => + [...store.values()].find( + (o) => o.walletId === walletId && o.idempotencyKey === idempotencyKey, + ) ?? new GiftCardOrderNotFoundError(), + ), + findByProviderOrderId: jest.fn( + async ({ providerOrderId }: { providerOrderId: string }): Promise => + [...store.values()].find((o) => o.providerOrderId === providerOrderId) ?? + new GiftCardOrderNotFoundError(), + ), + listByAccount: jest.fn( + async ({ + accountId, + limit, + before, + }: { + accountId: string + limit: number + before?: Date + }): Promise => + [...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 + }): Promise => + [...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 + }): 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)) { + 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 + }, + ), + 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 +} + +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/get-order.spec.ts b/test/flash/unit/app/gift-cards/get-order.spec.ts new file mode 100644 index 000000000..30f4377e4 --- /dev/null +++ b/test/flash/unit/app/gift-cards/get-order.spec.ts @@ -0,0 +1,179 @@ +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() + }) + }) + + describe("FULFILLED", () => { + 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) + // 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" + // 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/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..962778fa4 --- /dev/null +++ b/test/flash/unit/app/gift-cards/gift-cards-master-gate.spec.ts @@ -0,0 +1,191 @@ +import { CouldNotFindUserFromIdError } from "@domain/errors" +import { + GiftCardProviderUnavailableError, + GiftCardsDisabledError, +} from "@domain/gift-cards" + +import { + __resetGiftCardProvidersForTest, + registerGiftCardProvider, +} from "@services/gift-cards/registry" + +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) +} + +// 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 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", () => { + 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", + 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", () => { + 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 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, + }) + }) +}) + +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-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 new file mode 100644 index 000000000..141aad63b --- /dev/null +++ b/test/flash/unit/app/gift-cards/list-products.spec.ts @@ -0,0 +1,431 @@ +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, + maxQuantity: 1, + wholeUnitsOnly: false, + ...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..ab5a7372a --- /dev/null +++ b/test/flash/unit/app/gift-cards/purchase-gift-card.spec.ts @@ -0,0 +1,1423 @@ +import { + CouldNotFindWalletFromIdError, + IdempotencyKeyReuseError, + InvalidIdempotencyKeyError, + RepositoryError, + UnknownRepositoryError, +} from "@domain/errors" +import { + GiftCardClaimCryptoError, + GiftCardIdempotencyKeyReuseError, + GiftCardOrderNotFoundError, + GiftCardOrderStateError, + GiftCardInvalidValueError, + GiftCardLevelNotEligibleError, + GiftCardProductNotAvailableInCountryError, + GiftCardQuoteMismatchError, + GiftCardsDisabledError, + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, + UnknownGiftCardError, +} from "@domain/gift-cards" +import { ResourceAttemptsLockServiceError, UnknownLockServiceError } 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() +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 mockClaimCryptoReady = 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), + getRegisteredGiftCardProviderOrError: (...a: unknown[]) => mockGetProvider(...a), +})) +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[]) => + 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, +} + +// 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: null, +} + +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) +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() + repo = makeFakeOrdersRepo() + mockConfig = makeGiftCardsConfig() + mockSendGuardMode = "off" + + mockConsumeLimiter.mockResolvedValue(true) + mockFindAccountById.mockResolvedValue(makeAccount()) + mockFindWalletById.mockResolvedValue(makeWallet()) + mockResolveCountry.mockResolvedValue("US") + mockMasterGate.mockReturnValue({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "US", + countryKnown: true, + }) + 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" }) + mockClaimCryptoReady.mockReturnValue(true) + 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") + + // 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", + 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") + }) + + 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 + 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 answered with no transaction 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.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", () => { + 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("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() + 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) + }) + + 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") + }) + + 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"), + ) + }) + }) + + it("rejects a blank idempotency key", async () => { + const res = await purchase({ idempotencyKey: " " }) + 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("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("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() + 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", () => { + // 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: refused before the gate, the catalog or the vendor", async () => { + mockConsumeLimiter.mockResolvedValue(new GiftCardPurchaseRateLimiterExceededError()) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardPurchaseRateLimiterExceededError) + // 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 () => { + 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("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) + expect(mockQuote).not.toHaveBeenCalled() + }) + + it("invalid quantity", async () => { + const res = await purchase({ quantity: 11 }) + 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, + error: new GiftCardLevelNotEligibleError(), + reason: "level-not-eligible", + }) + const res = await purchase() + expect(res).toBeInstanceOf(GiftCardLevelNotEligibleError) + expect(mockQuote).toHaveBeenCalledTimes(1) + }) + }) + + 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( + 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 decoded BOLT11 expiry", async () => { + const soon = new Date(Date.now() + 2 * 60 * 1000) + 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", () => { + 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("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([ + [ + "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")), + ], + ])("%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 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") + expect(res.providerPaymentRef).toBe("ibex-tx-1") + }) + }) + + 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("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", () => { + 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..55a579170 --- /dev/null +++ b/test/flash/unit/app/gift-cards/quote-gift-card.spec.ts @@ -0,0 +1,310 @@ +import { CouldNotFindAccountFromIdError } from "@domain/errors" +import { + GiftCardInvalidValueError, + GiftCardProductNotAvailableInCountryError, + GiftCardProductNotFoundError, + GiftCardProviderUnavailableError, + GiftCardsDisabledError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" +import { GiftCardQuoteRateLimiterExceededError } from "@domain/rate-limit/errors" + +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() +const mockConsumeLimiter = jest.fn() +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: 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(), +})) +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 = { + 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() + mockConsumeLimiter.mockResolvedValue(true) + mockFindAccountById.mockResolvedValue(makeAccount()) + mockResolveCountry.mockResolvedValue("US") + mockMasterGate.mockReturnValue({ + ok: true, + providerId: "bitcoinCompany", + countryCode: "US", + countryKnown: true, + }) + mockGetGiftCardProduct.mockResolvedValue(PRODUCT) + mockGetProvider.mockReturnValue({ id: "bitcoinCompany", quote: mockQuote }) + mockQuote.mockResolvedValue(QUOTE) +}) + +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 }) + + 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() + }) + + 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 })) + + 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("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 () => { + 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..45e710d4f --- /dev/null +++ b/test/flash/unit/app/gift-cards/reconcile-orders.spec.ts @@ -0,0 +1,1339 @@ +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, + reconcileGiftCardOrders, + reconcileGiftCardOrdersJob, + startGiftCardReconcileInterval, +} 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 mockFetchVendorStatus = jest.fn() +const mockSettleFromVendor = 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), + fetchVendorOrderStatus: (...a: unknown[]) => mockFetchVendorStatus(...a), + settleOrderFromVendor: (...a: unknown[]) => mockSettleFromVendor(...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, + }, +}) + +/** 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 + }) + +/** + * 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 — 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 => { + 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", + 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 }) + 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) + // 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; `vendorVouchesPayment()` + // swaps in the payment-vouch settlement for the tests that need it. + mockFetchVendorStatus.mockResolvedValue({ kind: "paidPendingFulfillment" }) + mockSettleFromVendor.mockImplementation(async (order: GiftCardOrder) => + mockFetchAndSettle(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, + finalPollFailed: 0, + fulfillmentStalled: 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 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 — 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), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + expect(mockGetTransactionDetails).not.toHaveBeenCalled() + expect(mockFetchVendorStatus).toHaveBeenCalledTimes(1) + 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 () => { + repo.seed( + makeOrder({ + id: "i" as GiftCardOrderId, + status: "INVOICE_ISSUED", + providerOrderId: null, + expiresAt: at(-1), + }), + ) + const res = await runAt(0) + if (res instanceof Error) throw res + 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 () => { + // 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 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, + 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(mockSettleFromVendor).toHaveBeenCalledTimes(1) + expect(res.expired).toBe(1) + expect(repo.store.get("i")?.status).toBe("EXPIRED") + }) + + 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 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({ + 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).not.toHaveBeenCalled() + 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 () => { + repo.seed( + makeOrder({ + 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.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 () => { + 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("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() + }) + + 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) + if (res instanceof Error) throw res + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + expect(repo.store.get("p")?.status).toBe("PAYMENT_PENDING") + expect(repo.transition).not.toHaveBeenCalled() + }) + + 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 () => { + 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"] as const)( + "vendor says %s: left pending, never PAYMENT_FAILED on the vendor's word", + async (kind) => { + // `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() + 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() + }, + ) + + 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 + // 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: 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 + 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 () => { + 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 () => { + 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"), + ) + }) + + 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() + expect(mockFetchAndSettle).toHaveBeenCalledTimes(1) + }) + + 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) + }) + + 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 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, 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", + phase: "refund-required", + status: "failed", + error: "fulfillment-timeout", + meta: expect.objectContaining({ + orderId: "old", + 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 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) + 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. + 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() + }) + + 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 '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 + 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 () => { + 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(res.fulfillmentStalled).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("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("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 }) + mockFetchVendorStatus.mockResolvedValue({ kind: "awaitingPayment" }) + 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, + ) + }) + + 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: 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 new file mode 100644 index 000000000..c32dcca3c --- /dev/null +++ b/test/flash/unit/app/gift-cards/settle-order.spec.ts @@ -0,0 +1,682 @@ +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", () => ({ + getRegisteredGiftCardProviderOrError: (...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, + fetchVendorOrderStatus, + 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) + // 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({ + 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("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) + 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() + 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() + 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() + }) + }) + + 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.each(["failed", "refunded"] as const)( + "%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", + providerPaymentRef: "ibex-tx" as IbexTransactionId, + }), + ) + 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.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 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() + }, + ) + + 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" }) + if (res instanceof Error) throw res + expect(res.status).toBe("FAILED") + expect(res.failureReason).toBe("vendor-refunded: x") + }) + + 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() + }) + }) + + describe("non-terminal vendor statuses", () => { + 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 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( + 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() + }) + + 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 + // 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() + }) + }) + }) +}) + +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..." }, + 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") + }) + + 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("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) + 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..5bd46854b --- /dev/null +++ b/test/flash/unit/app/gift-cards/sync-catalog.spec.ts @@ -0,0 +1,442 @@ +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, + maxQuantity: 1, + wholeUnitsOnly: false, +}) + +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("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. + 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("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([]) + + 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..e8c186fae --- /dev/null +++ b/test/flash/unit/app/payments/pay-invoice-via-ibex.spec.ts @@ -0,0 +1,171 @@ +// 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: 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 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 ebb81abc4..fbaea4bfc 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,76 @@ describe("getRequestCodeBlockedCountryPerIpLimits", () => { expect(getRequestCodeBlockedCountryPerIpLimits().points).toBeGreaterThanOrEqual(5) }) }) + +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. + // + // 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", () => { + // Literal numbers, not the getter: a config that restates itself would + // stay green if both sides drifted together. + expect(RateLimitConfig.giftCardQuote).toEqual({ + key: "gift_card_quote", + limits: { points: 30, duration: 60, blockDuration: 300 }, + 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/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 new file mode 100644 index 000000000..86a563359 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/gift-card-purchase.spec.ts @@ -0,0 +1,512 @@ +// 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, + GiftCardOrderNotFoundError, + 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, + claimError: null, + }) + 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", () => { + // 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(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("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()) + + const result = await resolve() + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].code).toBe("GIFT_CARDS_DISABLED") + expect(result.order).toBeUndefined() + }) + + 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() + }) + }) + + 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 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"], + [ + "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", () => { + 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) + + 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`. 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() + + 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("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({ + 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..4e3a9ee01 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/gift-card-order.spec.ts @@ -0,0 +1,283 @@ +// jest.mock calls are hoisted before imports + +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), + resolveAccountCountryCodeOrUnknown: (...a: unknown[]) => mockResolveCountry(...a), + getGiftCardOrderForAccount: (...a: unknown[]) => mockGetGiftCardOrderForAccount(...a), +})) + +jest.mock("@services/logger", () => ({ + // 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 { 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 }, +} 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, + claimError: 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 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() + + expect(allMockCallText(mockRecordException, mockLoggerError)).not.toContain( + CIPHERTEXT, + ) + expect(JSON.stringify(result)).not.toContain(CIPHERTEXT) + expect(result).not.toHaveProperty("claimCiphertext") + }) + }) + + 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, + claimError: null, + }) + + expect((await resolve())?.claim).toBeNull() + }) + + 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({ + status: "FULFILLED", + paidSats: 40_100 as Satoshis, + fulfilledAt, + claimCiphertext: "enc:...", + claimKeyId: "k1", + }), + claim: CLAIM, + claimError: null, + }) + + 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) + expect(mockRecordException).not.toHaveBeenCalled() + expect(mockLoggerError).not.toHaveBeenCalled() + }) + }) + + 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, + claimError: null, + }) + + 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, + claimError: 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..ed2bb6180 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/gift-card-quote.spec.ts @@ -0,0 +1,197 @@ +// 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" +import { MAXIMUM_QUERY_COMPLEXITY } from "@servers/plugins/complexity" + +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" }, + }) + }) +}) + +// 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/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..6af7e5f49 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/globals-gift-cards.spec.ts @@ -0,0 +1,103 @@ +// 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" + +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 }, + } +} + +// 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 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 }) + + 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 registered and enabled provider, not only the default route", async () => { + registerGiftCardProvider(stubProvider("bitrefill")) + 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..5904401f5 --- /dev/null +++ b/test/flash/unit/graphql/public/types/object/gift-card-order.spec.ts @@ -0,0 +1,213 @@ +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", + "maxQuantity", + "wholeUnitsOnly", + ].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", + ]) + }) +}) 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..13b475195 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/client.spec.ts @@ -0,0 +1,1089 @@ +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 { + GiftCardInvalidValueError, + GiftCardVendorRejectedOrderError, + GiftCardVendorUnavailableError, +} from "@domain/gift-cards" +import { + BITCOIN_COMPANY_AUTH_CACHE_KEY, + BITCOIN_COMPANY_AUTH_LOCK_KEY, + BitcoinCompanyClient, + BitcoinCompanyClientDeps, + CATALOG_MAX_PAGES, + 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, + catalogOffsets, + catalogPage, + failThen, + httpError, + httpOk, + makeCatalog, + networkError, + pagedCatalog, + 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 an empty page (500 + 355 = 855)", async () => { + mockedAxios.get.mockImplementation( + routeGet({ "/giftcards": pagedCatalog(makeCatalog(855)) }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + 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") + 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.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. + 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({ products: [], invalid: 0 }) + 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 () => { + 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.products).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": pagedCatalog([ + 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.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", 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")], + catalogPage([vendorProductFixture()]), + ) + mockedAxios.get.mockImplementation( + routeGet({ + "/giftcards": (ctx) => + Number(ctx.query.get("offset")) === 0 ? firstPage(ctx) : catalogPage([]), + }), + ) + + const result = await makeClient().listProducts() + + if (result instanceof Error) throw result + 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( + 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 a fixed-message GiftCardVendorRejectedOrderError", async () => { + mockedAxios.post.mockImplementation( + routePost({ + "/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) + // 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 () => { + 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("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 () => { + mockedAxios.post.mockImplementation( + routePost({ "/giftcards/purchase/bitcoin": () => ({ status: 422, data: {} }) }), + ) + + const result = await makeClient().purchase(PURCHASE_ARGS) + + expect(result).toBeInstanceOf(GiftCardVendorUnavailableError) + }) +}) + +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({ + "/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 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({ + "/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 }), + ) + }) +}) + +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, + // 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 }, + }), + // 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) + 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, QUOTE_TOKEN]) { + 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("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 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({ + "/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" }, + 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..f2c95d4f0 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/contract.spec.ts @@ -0,0 +1,481 @@ +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 { + GiftCardInvalidValueError, + GiftCardVendorUnavailableError, + toGiftCardOrderId, + toGiftCardProviderOrderId, +} from "@domain/gift-cards" +import { + BITCOIN_COMPANY_MAX_QUANTITY, + BitcoinCompanyProvider, + RETRY_MAX, + 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, + RouteHandler, + callsTo, + catalogPage, + httpError, + httpOk, + networkError, + purchaseRoute, + quoteCardRoute, + 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: [] }), + ] + : [], + ), +} + +// 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": 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() + 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 counts every skip reason in the sync log", 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", + ]) + // 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( + { + provider: "bitcoinCompany", + op: "listProducts", + received: 3, + 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: 1 }) + if (quote instanceof Error) throw quote + const order = await provider.createOrder({ + product, + valueMinor: 2500, + quantity: 1, + 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: 1, + purchaseType: "Lightning", + }) + expect(callsTo(mockedAxios.post, "/giftcards/purchase/bitcoin")[0][1]).toEqual({ + productId: "prod-amazon-us", + cardValue: 25, + 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, + 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, + // TBC reports no order expiry; the BOLT11's own expiry governs. + expiresAt: null, + }) + }) + + 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(ORDER_REF) + + 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(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({ ...ORDER_REF, 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..1904af385 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/fixtures.ts @@ -0,0 +1,334 @@ +/** + * 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 + } +} + +// ============ 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") ?? "", + ) + +// ============ 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 new file mode 100644 index 000000000..4e82890d3 --- /dev/null +++ b/test/flash/unit/services/gift-cards/bitcoin-company/mapping.spec.ts @@ -0,0 +1,557 @@ +import { + BITCOIN_COMPANY_MAX_QUANTITY, + KNOWN_VENDOR_STATUSES, + PRODUCT_MAPPING_SKIP_REASONS, + QUOTE_TTL_MS, + isProductMappingSkip, + isResellingDisabled, + 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, + 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({ + id: "bitcoinCompany:prod-visa-us", + brand: "Visa", + denominationType: "variable", + denominations: [], + minValue: 500, + maxValue: 50000, + isOpenLoop: true, + rewardBps: 50, + wholeUnitsOnly: false, + }), + ) + }) + + 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] }), + ) + 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: [] }), "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" }), + "unknownDenominationType", + ], + [ + "a fixed product with no denominations", + vendorProductFixture({ denominations: [] }), + "noDenominations", + ], + [ + "a variable product with no denominations", + vendorVariableProductFixture({ denominations: [] }), + "noDenominations", + ], + [ + "only non-positive denominations", + vendorProductFixture({ denominations: [0, -1] }), + "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", () => { + 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) + }) + + 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", () => { + it("maps invoice, amount, and uuid into the provider order", () => { + expect(mapVendorPurchase(PURCHASE_RESULT)).toEqual({ + providerOrderId: PURCHASE_RESULT.uuid, + paymentRequest: PURCHASE_RESULT.invoice, + amountSats: 39000, + expiresAt: null, + }) + }) + + 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 }) + 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", () => { + 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, + } + + // 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"], + ["Pending", "paidPendingFulfillment"], + ["SentToFulfillment", "paidPendingFulfillment"], + ["Completed", "fulfilled"], + ["Sent", "fulfilled"], + ["Claimed", "fulfilled"], + ["Shipped", "fulfilled"], + ["Expired", "failed"], + ["Cancelled", "failed"], + ["Refunded", "refunded"], + ["ClawedBack", "refunded"], + ] + 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("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", + 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..815eedc11 --- /dev/null +++ b/test/flash/unit/services/gift-cards/catalog-cache.spec.ts @@ -0,0 +1,510 @@ +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, + 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 +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, + 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. +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)) + // 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 () => { + // 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(6) // 2 catalogs + 2 products + ids + index + 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([]) + expect(mockStore.get(giftCardProductIdsKey(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("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" }, + 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("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).toEqual(US_A) + expect(res).toEqual( + expect.objectContaining({ maxQuantity: 1, wholeUnitsOnly: false }), + ) + }) + + 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.readProduct(whole.id)).toEqual(whole) + }) + + 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 () => { + mockFailures.get = true + + 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 new file mode 100644 index 000000000..8d71f2dcb --- /dev/null +++ b/test/flash/unit/services/gift-cards/claim-crypto.spec.ts @@ -0,0 +1,375 @@ +/** + * 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, 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, +} 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 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 + 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 = seal() + + 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 = seal() + + 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 = seal() + const b = seal() + + expect(a.ciphertext).not.toBe(b.ciphertext) + expect(open(a)).toEqual(CLAIM) + expect(open(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 = seal(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 = 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 = open({ ...sealed, ciphertext: tampered }) + + expect(result).toBeInstanceOf(GiftCardClaimCryptoError) + expectNoLeak(result as Error) + }) + + it("refuses a ciphertext with one auth tag byte flipped", () => { + const sealed = seal() + + // Offsets 13..28 are the tag. + const tampered = flipByte(sealed.ciphertext, 13) + + expect(open({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + + it("refuses a ciphertext with one iv byte flipped", () => { + const sealed = seal() + + const tampered = flipByte(sealed.ciphertext, 1) + + expect(open({ ...sealed, ciphertext: tampered })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + + it("refuses an unknown envelope version", () => { + const sealed = seal() + + const tampered = flipByte(sealed.ciphertext, 0) + + expect(open({ ...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, + 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", () => { + 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 = seal() + + const result = decryptGiftCardClaim({ + ciphertext: sealed.ciphertext, + keyId: "0123456789abcdef", + orderId: ORDER_ID, + }) + + 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 = seal() + + setKey(randomBytes(32).toString("hex")) + + const result = open(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 = seal() + + setKey(BASE64_KEY) + expect(giftCardClaimKeyId()).toBe(EXPECTED_KEY_ID) + // Same bytes, different encoding: the order sealed under the hex form + // still opens. + expect(open(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, { orderId: ORDER_ID }) + expect(sealed).toBeInstanceOf(GiftCardClaimCryptoError) + expectNoLeak(sealed as Error) + expect(giftCardClaimKeyId()).toBeInstanceOf(GiftCardClaimCryptoError) + expect( + decryptGiftCardClaim({ + ciphertext: "AAAA", + keyId: EXPECTED_KEY_ID, + orderId: ORDER_ID, + }), + ).toBeInstanceOf(GiftCardClaimCryptoError) + }) + + it("refuses a whitespace-only key", () => { + setKey(" ") + 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, { 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, { orderId: ORDER_ID })).toBeInstanceOf( + GiftCardClaimCryptoError, + ) + }) + + it("refuses a 33-byte key in base64", () => { + setKey(randomBytes(33).toString("base64")) + 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, { 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 new file mode 100644 index 000000000..d685f7b75 --- /dev/null +++ b/test/flash/unit/services/gift-cards/provider-contract.ts @@ -0,0 +1,222 @@ +/* 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, + maxQuantity: 1, + wholeUnitsOnly: false, +}) + +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) + // 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({ + 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/gift-cards/registry.spec.ts b/test/flash/unit/services/gift-cards/registry.spec.ts new file mode 100644 index 000000000..531a1c606 --- /dev/null +++ b/test/flash/unit/services/gift-cards/registry.spec.ts @@ -0,0 +1,166 @@ +import { GiftCardProviderUnavailableError } from "@domain/gift-cards" + +let mockConfig = makeGiftCardsConfig() + +jest.mock("@config", () => ({ + get GiftCardsConfig() { + return mockConfig + }, +})) + +import { + __resetGiftCardProvidersForTest, + enabledGiftCardProviders, + getEnabledGiftCardProvider, + getGiftCardProviderForCountry, + getRegisteredGiftCardProviderOrError, + isGiftCardProviderEnabled, + 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, + ) + }) + + 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 new file mode 100644 index 000000000..f08aa0e51 --- /dev/null +++ b/test/flash/unit/services/mongoose/gift-card-orders.mapping.spec.ts @@ -0,0 +1,359 @@ +/** + * 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, 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. + const terminal = [ + GiftCardOrderStatus.Fulfilled, + GiftCardOrderStatus.Failed, + GiftCardOrderStatus.PaymentFailed, + GiftCardOrderStatus.Expired, + GiftCardOrderStatus.RefundRequired, + ] + const exits = terminal.flatMap((from) => + allStatuses + .filter((to) => checkGiftCardOrderTransition([from], to) === true) + .map((to) => `${from} → ${to}`), + ) + 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 && expiredExits.includes(to)), + ) + .map((to) => checkGiftCardOrderTransition([from], to)), + ) + expect(refusals).toHaveLength(terminal.length * allStatuses.length - 2) + expect(refusals.every((r) => r instanceof GiftCardOrderStateError)).toBe(true) + }) + + 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, + ]) + }) +}) + +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..9d6df2a6c --- /dev/null +++ b/test/flash/unit/services/mongoose/gift-card-orders.spec.ts @@ -0,0 +1,417 @@ +/** + * 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() +const updateOne = 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), + updateOne: (...args: unknown[]) => updateOne(...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() + updateOne.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("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( + 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) + }) + }) +})