From 8fd4d4c2e8d9de51cb08c431b5b6ceca5b51171e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:30:16 +0000 Subject: [PATCH] fix(wallet): a hold Stripe declined on our behalf is released If Stripe does not have our decision within 2 seconds it approves or declines by the account's timeout setting, and reports that only in issuing_authorization.created (request_history.reason webhook_timeout). handleAuthorizationRequest reserves the hold first and then calls the deprecated approve API, so a slow run had committed the hold when the approve call failed; the route ignored .created and nothing released it. The child's money stayed held for good. - The route handles issuing_authorization.created through the same path as .updated. That path now also releases, by its key, the hold of every request Stripe declined, whatever the authorization's status, so a declined increase no longer waits for the close. - When our approve call fails, the handler asks Stripe what it decided: an authorization that is over is settled and closed as its own event would be; a request declined for good (matched by its position and the amount it asked for) releases that request's hold only. Approved, undecided or unknown: the hold stays. - network_fallback (Stripe declined, the card network decided) is not acted on early: when the network approved, Stripe says it may still be captured, so its hold waits for the close, which releases it. - releaseRequestHold releases one request's hold by key and amount, and confirms its write (.select), as the unconfirmed-write ratchet requires. - docs/AGENT_HANDOFF.md step 4 subscribed the money endpoint to three events and left out issuing_authorization.updated, which releases a reversed or expired hold; LB-006 listed .created, which the route did not handle. Both now list exactly HANDLED_EVENT_TYPES, pinned by a test. Found by reading; red tests first. Two independent review rounds are addressed (ratchet, network_fallback without stranding holds, terminal retrieve, request identity by amount, per-request release on .updated, error never replaced, test gaps); 12 app mutants killed. Claim: #771 5981789351. App-only: no migration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS --- app/api/webhooks/money/route.ts | 8 + docs/AGENT_HANDOFF.md | 11 +- .../LB-006-provider-callback-smoke.md | 15 +- lib/stripe/webhook.ts | 92 +++- lib/wallet/server.ts | 23 + ...stripe-declined-for-us-is-released.test.ts | 413 ++++++++++++++++++ 6 files changed, 549 insertions(+), 13 deletions(-) create mode 100644 tests/a-hold-stripe-declined-for-us-is-released.test.ts diff --git a/app/api/webhooks/money/route.ts b/app/api/webhooks/money/route.ts index 4768a3292..b6fcb2fc9 100644 --- a/app/api/webhooks/money/route.ts +++ b/app/api/webhooks/money/route.ts @@ -40,6 +40,7 @@ const MAX_WEBHOOK_BODY_BYTES = 256_000; */ const HANDLED_EVENT_TYPES = new Set([ 'issuing_authorization.request', + 'issuing_authorization.created', 'issuing_authorization.updated', 'issuing_transaction.created', 'issuing_card.created', @@ -104,6 +105,13 @@ export async function POST(req: NextRequest) { case 'issuing_transaction.created': await handleTransactionCreated(supabase, event.data.object as Stripe.Issuing.Transaction); break; + // Stripe sends `.created` for every authorization. For one it decided + // itself (no answer from us inside 2 seconds, so it approved or declined + // by the account's timeout setting) it is the only notice: a purchase it + // declined that way arrives already closed and is released like any other + // closed authorization. Handling is idempotent, so `.created` and + // `.updated` may both arrive for the same authorization. + case 'issuing_authorization.created': case 'issuing_authorization.updated': await handleAuthorizationUpdated(supabase, event.data.object as Stripe.Issuing.Authorization); break; diff --git a/docs/AGENT_HANDOFF.md b/docs/AGENT_HANDOFF.md index 33fc87468..6d2f4859a 100644 --- a/docs/AGENT_HANDOFF.md +++ b/docs/AGENT_HANDOFF.md @@ -3198,8 +3198,15 @@ Last updated: 2026-06-26 — Session 3: Family Treasury, Send Money, frictionles > `STRIPE_MONEY_WEBHOOK_SECRET` (new — for the /api/webhooks/money endpoint; if unset it falls back > to `STRIPE_WEBHOOK_SECRET`). No client-exposed Stripe keys are needed for this layer. > 4. **Stripe webhook**: add endpoint `https://www.bubaly.com/api/webhooks/money` subscribed to -> `issuing_authorization.request`, `issuing_transaction.created`, `account.updated`. Copy its -> signing secret into `STRIPE_MONEY_WEBHOOK_SECRET`. (Real-time auth requires the .request event.) +> `issuing_authorization.request`, `issuing_authorization.created`, +> `issuing_authorization.updated`, `issuing_transaction.created`, `issuing_card.created`, +> `issuing_card.updated`, `account.updated` (every type the route's `HANDLED_EVENT_TYPES` +> lists; `tests/a-hold-stripe-declined-for-us-is-released.test.ts` keeps this line in step). +> Copy its signing secret into `STRIPE_MONEY_WEBHOOK_SECRET`. (Real-time auth requires the +> .request event; `.updated` and `.created` release the hold of an authorization that closes, +> is reversed or expires, and of each request Stripe declined, including one it declined +> itself when our answer missed its 2 seconds; one the card network overrode waits for the +> close. Corrected 2026-10-04: this step listed only three events.) > 5. **Flip feature flags** (service role, `feature_flags` table) in dependency order as capabilities > are approved: `stripe_connect_enabled` → `stripe_treasury_enabled` / `stripe_issuing_enabled` → > `physical_cards_enabled` / `custom_card_designs_enabled` / `stripe_payments_enabled`. Until flipped, diff --git a/docs/runbooks/LB-006-provider-callback-smoke.md b/docs/runbooks/LB-006-provider-callback-smoke.md index ada4a475d..2a6c9944a 100644 --- a/docs/runbooks/LB-006-provider-callback-smoke.md +++ b/docs/runbooks/LB-006-provider-callback-smoke.md @@ -42,11 +42,16 @@ event** (`stripe events resend `) → still 200 but **no double-apply** (ded ## Stripe Issuing (wallet) — `/api/webhooks/money` -Same pattern with `STRIPE_MONEY_WEBHOOK_SECRET`; trigger the issuing events the wallet relies on -(`issuing_authorization.request`, `.created`, `issuing_transaction.created`). **Verify:** an -authorization reserves/holds against the child's SPEND balance exactly once; capture reconciles the -hold; a replayed authorization does **not** double-reserve (this is the money-integrity path — pairs -with migrations 0155/0217). +Same pattern with `STRIPE_MONEY_WEBHOOK_SECRET`. The endpoint must be subscribed to every event the +route handles (`HANDLED_EVENT_TYPES` in `app/api/webhooks/money/route.ts`): +`issuing_authorization.request`, `issuing_authorization.created`, `issuing_authorization.updated`, +`issuing_transaction.created`, `issuing_card.created`, `issuing_card.updated`, `account.updated`. +Trigger the authorization and transaction ones. **Verify:** an authorization reserves/holds against +the child's SPEND balance exactly once; capture reconciles the hold; a replayed authorization does +**not** double-reserve (this is the money-integrity path — pairs with migrations 0155/0217); a +closed, reversed or expired authorization releases its hold; and so does each request Stripe +declined, including one it declined itself on timeout (a purchase declined that way arrives as a +closed `.created`); a decline the card network overrode waits for the close. ## Resend (email) — `/api/webhooks/resend` diff --git a/lib/stripe/webhook.ts b/lib/stripe/webhook.ts index 03af0c705..88a53fe96 100644 --- a/lib/stripe/webhook.ts +++ b/lib/stripe/webhook.ts @@ -14,7 +14,7 @@ import type { Database } from '@/lib/database.types'; import type Stripe from 'stripe'; import { randomUUID } from 'node:crypto'; import { getStripe } from '@/lib/stripe'; -import { cardHoldRef, reserveCardAuth, releaseCardHold, creditCardRefund, settleCardCapture, closeCardAuth } from '@/lib/wallet/server'; +import { cardHoldRef, reserveCardAuth, releaseCardHold, releaseRequestHold, creditCardRefund, settleCardCapture, closeCardAuth } from '@/lib/wallet/server'; type DB = SupabaseClient; const STALE_EVENT_MS = 10 * 60 * 1000; @@ -178,6 +178,9 @@ export async function handleAuthorizationRequest( const card = cardId ? await cardForAuthorization(supabase, cardId) : null; const amount = auth.pending_request?.amount ?? auth.amount ?? 0; + // Requests Stripe had already decided; this one is next in its history. + const priorRequests = auth.request_history?.length ?? 0; + const holdRef = cardHoldRef(auth.id, priorRequests); const merchantCategory = auth.merchant_data?.category ?? null; const merchantName = auth.merchant_data?.name ?? null; @@ -197,7 +200,7 @@ export async function handleAuthorizationRequest( // key of its own so it is balance-checked like the first (cardHoldRef). const reserved = await reserveCardAuth(supabase, { familyId: card.family_id, childWalletId: card.child_wallet_id, amountCents: amount, - holdRef: cardHoldRef(auth.id, auth.request_history?.length ?? 0), description: merchantName ?? 'Card hold', + holdRef, description: merchantName ?? 'Card hold', }); decision = reserved ? { approve: true, reason: 'approved' } : { approve: false, reason: 'insufficient_spend_balance' }; } @@ -210,6 +213,8 @@ export async function handleAuthorizationRequest( else await stripe.issuing.authorizations.decline(auth.id, {}, opts); } catch (e) { console.error('[money] authorization response failed', e); + // An approval that failed came after a hold was reserved for it. + if (decision.approve) await releaseIfStripeDeclined(supabase, stripe, auth, priorRequests, holdRef, amount, opts); throw new Error('Stripe authorization response failed'); } @@ -241,6 +246,65 @@ export async function handleAuthorizationRequest( } } +/** An authorization Stripe will not capture any further. */ +const TERMINAL_AUTHORIZATION = ['reversed', 'expired', 'closed']; + +/** + * Whether Stripe declined this request in a way that holds nothing that can + * still be captured. Not `network_fallback`: Stripe declined but the card + * network decided, and when it approved, Stripe's docs say to treat it as + * approved — it may still be captured. Such a hold stays while the + * authorization is open and is released by its close like any other, so the + * wait is bounded. + */ +function declinedForGood(entry: Stripe.Issuing.Authorization['request_history'][number]): boolean { + return entry.approved === false && entry.reason !== 'network_fallback'; +} + +/** + * Our approve call failed after this request's hold was reserved. Most often it + * came after Stripe's 2-second window, and Stripe decided by the account's + * timeout setting. Ask Stripe what it decided, because the authorization's own + * `.created` may already have been handled before the hold committed, so + * nothing else would release it: + * - the authorization is over (closed, reversed, expired): settle and release + * exactly as its own event does (handleAuthorizationUpdated); + * - this request (the next entry in its `request_history`, matched by the + * amount it asked for) was declined for good: release this request's hold + * only, so an increase never frees the purchase it raised. + * Approved by the timeout setting, undecided, not recognisably this request, or + * unknown because Stripe cannot be asked: the hold stays, for the + * authorization's own events to settle. Money held, never money freed. Nothing + * here may replace the caller's error. + */ +async function releaseIfStripeDeclined( + supabase: DB, stripe: ReturnType, auth: Stripe.Issuing.Authorization, + priorRequests: number, holdRef: string, amount: number, opts: Stripe.RequestOptions | undefined, +): Promise { + let current: Stripe.Issuing.Authorization; + try { + current = await stripe.issuing.authorizations.retrieve(auth.id, {}, opts); + } catch (e) { + console.error('[money] could not ask Stripe how it decided; the hold stays', { authorizationId: auth.id, holdRef }, e); + return; + } + try { + if (TERMINAL_AUTHORIZATION.includes(current.status)) { + await handleAuthorizationUpdated(supabase, current); + return; + } + const decided = current.request_history?.[priorRequests]; + if (!decided || !declinedForGood(decided) || decided.amount !== amount) return; + if (await releaseRequestHold(supabase, holdRef, amount) > 0) { + console.warn('[money] Stripe declined a request we had reserved for; its hold is released', + { authorizationId: auth.id, holdRef, reason: decided.reason }); + } + } catch (e) { + console.error('[money] Stripe declined a request we had reserved for, and its hold could not be released', + { authorizationId: auth.id, holdRef }, e); + } +} + /** * Handle issuing_transaction.created — a capture or a refund. A capture posts the * real debit (keyed by the transaction id, idempotent) and releases the @@ -368,8 +432,16 @@ export async function handleIssuingCardUpdated(supabase: DB, card: Stripe.Issuin } /** - * Handle issuing_authorization.updated — when an authorization is closed, - * reversed or expired, release what is left of its hold. + * Handle issuing_authorization.updated, and .created — when an authorization is + * closed, reversed or expired, release what is left of its hold. Stripe sends + * `.created` for every authorization; for one it decided itself (our answer + * came too late) it is the only notice, and one it declined arrives already + * closed and is released here like any other. + * + * First, whatever the status, the hold of each request Stripe declined for good + * is released by its key and amount (an increase declined while the + * authorization stays open). A `network_fallback` decline is not one of those: + * its hold waits for the close. * * Its captures are settled FIRST. Stripe closes an authorization when it is * captured and does not order this event before issuing_transaction.created, so @@ -387,11 +459,19 @@ export async function handleIssuingCardUpdated(supabase: DB, card: Stripe.Issuin export async function handleAuthorizationUpdated( supabase: DB, auth: Stripe.Issuing.Authorization, ): Promise { - if (!['reversed', 'expired', 'closed'].includes(auth.status)) return; + // A request Stripe declined holds nothing it can capture, whatever the + // authorization's status: release its hold by its key now. An increase + // declined on an authorization still pending (by Stripe's timeout, or after + // our approve call failed early) would otherwise wait for the close. + const history = auth.request_history ?? []; + for (let i = 0; i < history.length; i++) { + if (declinedForGood(history[i])) await releaseRequestHold(supabase, cardHoldRef(auth.id, i), history[i].amount); + } + if (!TERMINAL_AUTHORIZATION.includes(auth.status)) return; const cardId = typeof auth.card === 'string' ? auth.card : auth.card?.id; const card = cardId ? await cardForAuthorization(supabase, cardId) : null; // Each request it had (the first, and any increase) may hold money. - const requests = Math.max(auth.request_history?.length ?? 0, 1); + const requests = Math.max(history.length, 1); // No card mirror means no hold of ours to settle against; release each request's hold by its key. if (!card) { await releaseCardHold(supabase, auth.id, requests); return; } for (const txn of auth.transactions ?? []) { diff --git a/lib/wallet/server.ts b/lib/wallet/server.ts index 2abc9c8dd..b64b90f24 100644 --- a/lib/wallet/server.ts +++ b/lib/wallet/server.ts @@ -214,6 +214,29 @@ export async function releaseCardHold(supabase: DB, authId: string, requests = 1 if (error) throw new Error(walletFailure(error, 'Could not release the card authorization hold.')); } +/** + * Release ONE request's hold, by its key (cardHoldRef), and nothing else of the + * authorization: for a request Stripe declined after we had reserved for it. + * An increase declined that way must not free the purchase it was raising. + * Idempotent: only a `processing` hold is touched. Answers how many holds it + * released, so a caller reports a release only when one happened (zero: an + * earlier event released it, or a capture's settle folded it into a remainder). + */ +export async function releaseRequestHold(supabase: DB, holdRef: string, amountCents: number): Promise { + // The amount is what the request asked for: Stripe's record of the decline + // and our hold must agree on it, as well as on the key, before money moves. + const { data, error } = await supabase + .from('wallet_transactions') + .update({ status: 'cancelled' }) + .eq('stripe_ref', holdRef) + .eq('type', 'card_spend') + .eq('status', 'processing') + .eq('amount_cents', Math.max(0, Math.trunc(amountCents))) + .select('id'); + if (error) throw new Error(walletFailure(error, 'Could not release the card authorization hold.')); + return data?.length ?? 0; +} + // Said once per process, so an operator can see the fix is not in effect yet // without a line per card purchase. const warnedPreHoldSettlement = new Set(); diff --git a/tests/a-hold-stripe-declined-for-us-is-released.test.ts b/tests/a-hold-stripe-declined-for-us-is-released.test.ts new file mode 100644 index 000000000..b7c517b86 --- /dev/null +++ b/tests/a-hold-stripe-declined-for-us-is-released.test.ts @@ -0,0 +1,413 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Stripe from 'stripe'; +import { createInMemorySupabase, type InMemorySupabase, type Row } from './helpers/in-memory-supabase'; + +/** + * A hold for a purchase Stripe declined on our behalf is released. + * + * If Stripe does not have our decision within 2 seconds, it approves or declines + * the authorization by the account's timeout setting and says so only in + * `issuing_authorization.created`, with `request_history.reason` = + * `webhook_timeout` (Stripe docs, "Issuing real-time authorizations"). + * handleAuthorizationRequest reserves the hold FIRST and then calls Stripe's + * approve API, so a slow run had already committed the hold when the approve + * call failed. The route ignored `.created`, and nothing else released it: the + * child's money stayed held for good. + * + * Handling `.created` alone would not do, because it can be handled before the + * late hold commits. So when the approve call fails the handler also asks Stripe + * what it decided for THIS request, and releases this request's hold only if + * Stripe declined it. Approved by the timeout setting, or unknown, it stays: + * money held, never money freed. + * + * Signed events go through the real /api/webhooks/money route; Stripe's API is + * faked, and wallet_reserve_card_auth is emulated statement by statement from 0155. + */ + +const SECRET = 'whsec_test_stripe_declined_for_us'; +const FAMILY = 'family-1'; +const WALLET = 'wallet-a'; +const harness = vi.hoisted(() => ({ + db: null as unknown, + calls: [] as string[], + approveError: null as Error | null, + declineError: null as Error | null, + retrieved: null as unknown, + retrieveError: null as Error | null, +})); + +vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => harness.db, createServer: async () => harness.db })); +vi.mock('@/lib/i18n/server', async () => { + const { SOURCE_MESSAGES, translate } = await import('@/lib/i18n/messages'); + return { getTranslations: async () => (key: string, params?: Record) => translate(SOURCE_MESSAGES, key, params) }; +}); +vi.mock('@/lib/stripe', async (importOriginal) => ({ + ...(await importOriginal()), + getStripe: () => ({ + issuing: { + authorizations: { + approve: async (id: string) => { harness.calls.push(`approve ${id}`); if (harness.approveError) throw harness.approveError; return { id }; }, + decline: async (id: string) => { harness.calls.push(`decline ${id}`); if (harness.declineError) throw harness.declineError; return { id }; }, + retrieve: async (id: string) => { harness.calls.push(`retrieve ${id}`); if (harness.retrieveError) throw harness.retrieveError; return harness.retrieved; }, + }, + }, + }), +})); + +const { POST } = await import('@/app/api/webhooks/money/route'); +const { handleAuthorizationRequest } = await import('@/lib/stripe/webhook'); + +let db: InMemorySupabase; +let events = 0; +type RpcHandler = (args: Record, db: InMemorySupabase) => unknown; + +/** wallet_reserve_card_auth as 0155 writes it, statement for statement. */ +const reserve: RpcHandler = (args, mem) => { + const amount = Number(args.p_amount); + if (!(amount > 0)) return true; + const bucket = (mem.table('wallet_buckets') as Row[]) + .find((b) => b.family_id === args.p_family && b.child_wallet_id === args.p_child_wallet && b.kind === 'spend'); + if (!bucket) return false; + const rows = mem.table('wallet_transactions') as Row[]; + if (rows.some((r) => r.stripe_ref === args.p_auth_id && r.type === 'card_spend' && r.status === 'processing')) return true; + const spendable = rows + .filter((r) => r.family_id === args.p_family && r.bucket_id === bucket.id && (r.status === 'completed' || r.status === 'processing')) + .reduce((sum, r) => sum + (r.direction === 'credit' ? Number(r.amount_cents) : -Number(r.amount_cents)), 0); + if (amount > spendable) return false; + rows.push({ + id: `hold-${String(args.p_auth_id)}`, family_id: args.p_family, child_wallet_id: args.p_child_wallet, bucket_id: bucket.id, + type: 'card_spend', status: 'processing', direction: 'debit', amount_cents: amount, + description: args.p_description, stripe_ref: args.p_auth_id, metadata: { source: 'issuing', kind: 'hold' }, + }); + return true; +}; + +function store(rpc: Record = {}) { + db = createInMemorySupabase({ + uniques: { stripe_webhook_events: [['stripe_event_id']], stripe_authorizations: [['stripe_authorization_id']] }, + rpc: { wallet_reserve_card_auth: reserve, ...rpc }, + }); + harness.db = db; + db.seed('stripe_issuing_cards', [ + { id: 'card-row-1', family_id: FAMILY, child_wallet_id: WALLET, stripe_card_id: 'ic_child', is_frozen: false, blocked_categories: [], status: 'active' }, + ]); + db.seed('wallet_buckets', [{ id: 'bucket-spend', family_id: FAMILY, child_wallet_id: WALLET, kind: 'spend' }]); + db.seed('wallet_transactions', [ + { id: 'txn-topup', family_id: FAMILY, child_wallet_id: WALLET, bucket_id: 'bucket-spend', type: 'parent_top_up', status: 'completed', direction: 'credit', amount_cents: 5_000, stripe_ref: null }, + ]); + db.seed('stripe_authorizations', []); + db.seed('stripe_webhook_events', []); + db.seed('wallet_audit_logs', []); +} + +beforeEach(() => { + vi.stubEnv('STRIPE_MONEY_WEBHOOK_SECRET', SECRET); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + harness.calls = []; + harness.approveError = null; + harness.declineError = null; + harness.retrieved = null; + harness.retrieveError = null; + store(); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); + +type Decided = { approved: boolean; amount: number; reason: string }; +const decided = (approved: boolean, amount: number, reason = approved ? 'webhook_approved' : 'webhook_timeout'): Decided => ({ approved, amount, reason }); + +function authorization(over: Record = {}): Stripe.Issuing.Authorization { + return { + id: 'iauth_1', object: 'issuing.authorization', status: 'pending', approved: false, amount: 0, currency: 'usd', + card: 'ic_child', merchant_data: { name: 'Corner Books', category: 'book_stores' }, + pending_request: { amount: 2_000, currency: 'usd', is_amount_controllable: false, merchant_amount: 2_000, merchant_currency: 'usd' }, + request_history: [], transactions: [], + ...over, + } as unknown as Stripe.Issuing.Authorization; +} + +async function deliver(type: string, object: unknown, eventId = `evt_${++events}`): Promise { + const payload = JSON.stringify({ + id: eventId, object: 'event', type, created: 1_790_000_000, + api_version: '2026-05-27.dahlia', livemode: false, data: { object }, + }); + const signature = Stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET }); + return POST(new Request('https://bubaly.test/api/webhooks/money', { + method: 'POST', body: payload, headers: { 'stripe-signature': signature, 'content-type': 'application/json' }, + }) as never); +} + +/** The status Stripe sees. The route lets a handler error out of the `.request` + * branch, and Next answers an uncaught route error 500. */ +const answer = (pending: Promise) => pending.then((res) => res.status, () => 500); + +const ledger = () => db.table('wallet_transactions') as Row[]; +const liveHolds = () => ledger().filter((r) => r.type === 'card_spend' && r.status === 'processing').map((r) => [r.stripe_ref, r.amount_cents]); +const spendableCents = () => ledger() + .filter((r) => r.bucket_id === 'bucket-spend' && (r.status === 'completed' || r.status === 'processing')) + .reduce((sum, r) => sum + (r.direction === 'credit' ? Number(r.amount_cents) : -Number(r.amount_cents)), 0); +const placeHold = (ref: string, cents: number) => ledger().push({ + id: `hold-${ref}`, family_id: FAMILY, child_wallet_id: WALLET, bucket_id: 'bucket-spend', type: 'card_spend', + status: 'processing', direction: 'debit', amount_cents: cents, stripe_ref: ref, metadata: { source: 'issuing', kind: 'hold' }, +}); + +describe('issuing_authorization.created, where Stripe reports a decision it made itself', () => { + const declinedClosed = () => authorization({ status: 'closed', approved: false, pending_request: null, request_history: [decided(false, 2_000)] }); + + it('a purchase Stripe declined on timeout releases the hold our late reserve left', async () => { + placeHold('iauth_1', 2_000); + + const res = await deliver('issuing_authorization.created', declinedClosed()); + + expect(res.status).toBe(200); + expect(liveHolds()).toEqual([]); + expect(spendableCents()).toBe(5_000); + }); + + it('an approved, still-pending authorization keeps its hold', async () => { + placeHold('iauth_1', 2_000); + + const res = await deliver('issuing_authorization.created', authorization({ + status: 'pending', approved: true, amount: 2_000, pending_request: null, request_history: [decided(true, 2_000)], + })); + + expect(res.status).toBe(200); + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + }); + + it.each([ + ['.created first', ['issuing_authorization.created', 'issuing_authorization.updated']], + ['.updated first', ['issuing_authorization.updated', 'issuing_authorization.created']], + ])('.created and .updated for the same close release the hold once (%s)', async (_order, types) => { + placeHold('iauth_1', 2_000); + + for (const type of types) expect((await deliver(type, declinedClosed())).status).toBe(200); + + expect(liveHolds()).toEqual([]); + expect(spendableCents()).toBe(5_000); + }); + + it('with the 0487 functions, a closed .created goes through wallet_close_card_auth for the authorization', async () => { + const closes: unknown[] = []; + store({ wallet_close_card_auth: (args) => { closes.push(args); return { ok: true, released_cents: 0 }; } }); + + expect((await deliver('issuing_authorization.created', declinedClosed())).status).toBe(200); + + expect(closes).toEqual([{ p_family: FAMILY, p_child_wallet: WALLET, p_auth_id: 'iauth_1' }]); + }); + +}); + +describe('issuing_authorization.updated on an authorization still pending', () => { + const pending = (history: Decided[]) => authorization({ status: 'pending', approved: true, amount: 2_000, pending_request: null, request_history: history }); + + it('releases the hold of an increase Stripe declined, and keeps the purchase it raised', async () => { + placeHold('iauth_1', 2_000); + placeHold('iauth_1#request-1', 1_000); + + await deliver('issuing_authorization.updated', pending([decided(true, 2_000), decided(false, 1_000)])); + + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + expect(spendableCents()).toBe(3_000); + }); + + it('a decline the card network overrode (network_fallback) waits for the close, which releases it', async () => { + // Stripe: when the network approved what Stripe declined, it may still be captured. + placeHold('iauth_1', 2_000); + placeHold('iauth_1#request-1', 1_000); + placeHold('iauth_1#request-2', 500); + const history = [decided(true, 2_000), decided(false, 1_000), decided(false, 500, 'network_fallback')]; + + await deliver('issuing_authorization.updated', pending(history)); + expect(liveHolds()).toEqual([['iauth_1', 2_000], ['iauth_1#request-2', 500]]); + + await deliver('issuing_authorization.updated', authorization({ status: 'closed', approved: true, amount: 2_000, pending_request: null, request_history: history })); + expect(liveHolds()).toEqual([]); + }); + + it('a declined entry whose amount is not the hold\'s is not trusted', async () => { + placeHold('iauth_1', 2_000); + placeHold('iauth_1#request-1', 1_000); + + await deliver('issuing_authorization.updated', pending([decided(true, 2_000), decided(false, 999)])); + + expect(liveHolds()).toEqual([['iauth_1', 2_000], ['iauth_1#request-1', 1_000]]); + }); +}); + +describe('our approve call fails after the hold is reserved', () => { + beforeEach(() => { harness.approveError = new Error('This authorization is no longer pending.'); }); + const increase = (prior: Decided[] = [decided(true, 2_000)]) => authorization({ + approved: true, amount: 2_000, request_history: prior, + pending_request: { amount: 1_000, currency: 'usd', is_amount_controllable: false, merchant_amount: 1_000, merchant_currency: 'usd' }, + }); + const statuses = (ref: string) => ledger().filter((r) => r.stripe_ref === ref).map((r) => r.status); + + it('Stripe declined the purchase (timeout) and closed it: its hold is released, and the answer is still 500', async () => { + harness.retrieved = authorization({ status: 'closed', approved: false, pending_request: null, request_history: [decided(false, 2_000)] }); + + const status = await answer(deliver('issuing_authorization.request', authorization())); + + expect(status).toBe(500); + expect(harness.calls).toEqual(['approve iauth_1', 'retrieve iauth_1']); + expect(liveHolds()).toEqual([]); + expect(spendableCents()).toBe(5_000); + }); + + it('a declined increase releases only the increase, never the purchase it raised', async () => { + placeHold('iauth_1', 2_000); + harness.retrieved = authorization({ + status: 'pending', approved: true, amount: 2_000, pending_request: null, + request_history: [decided(true, 2_000), decided(false, 1_000)], + }); + + expect(await answer(deliver('issuing_authorization.request', increase()))).toBe(500); + + expect(harness.calls).toEqual(['approve iauth_1', 'retrieve iauth_1']); + expect(statuses('iauth_1#request-1')).toEqual(['cancelled']); + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + expect(spendableCents()).toBe(3_000); + }); + + it('a closed authorization with a capture listed is settled and closed, as its own event would', async () => { + harness.retrieved = authorization({ + status: 'closed', approved: true, amount: 2_000, pending_request: null, request_history: [decided(true, 2_000, 'webhook_timeout')], + transactions: [{ id: 'ipi_late', object: 'issuing.transaction', type: 'capture', amount: -2_000, card: 'ic_child', authorization: 'iauth_1', merchant_data: { name: 'Corner Books' } }], + }); + + expect(await answer(deliver('issuing_authorization.request', authorization()))).toBe(500); + + expect(ledger().filter((r) => r.stripe_ref === 'ipi_late')).toEqual([expect.objectContaining({ status: 'completed', direction: 'debit', amount_cents: 2_000 })]); + expect(liveHolds()).toEqual([]); + expect(spendableCents()).toBe(3_000); + }); + + it('a release that fails never replaces the error Stripe\'s answer is about', async () => { + store({ wallet_settle_card_capture: () => { throw new Error('the settle failed'); } }); + harness.retrieved = authorization({ + status: 'closed', approved: true, amount: 2_000, pending_request: null, request_history: [decided(true, 2_000, 'webhook_timeout')], + transactions: [{ id: 'ipi_late', object: 'issuing.transaction', type: 'capture', amount: -2_000, card: 'ic_child', authorization: 'iauth_1', merchant_data: { name: 'Corner Books' } }], + }); + + await expect(handleAuthorizationRequest(db as never, authorization(), undefined)).rejects.toThrow('Stripe authorization response failed'); + + // The close never ran, so the hold stays for the authorization's own events. + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + }); + + it('an authorization Stripe has since reversed is closed whole, as its own event would', async () => { + placeHold('iauth_1', 2_000); + harness.retrieved = authorization({ + status: 'reversed', approved: true, amount: 2_000, pending_request: null, + request_history: [decided(true, 2_000), decided(true, 1_000, 'webhook_timeout')], + }); + + expect(await answer(deliver('issuing_authorization.request', increase()))).toBe(500); + + expect(liveHolds()).toEqual([]); + expect(spendableCents()).toBe(5_000); + }); + + it('Stripe approved it (timeout setting approve): the hold stays, because it will be captured', async () => { + harness.retrieved = authorization({ status: 'pending', approved: true, amount: 2_000, pending_request: null, request_history: [decided(true, 2_000, 'webhook_timeout')] }); + + expect(await answer(deliver('issuing_authorization.request', authorization()))).toBe(500); + + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + }); + + it('only THIS request\'s entry counts: a later declined increase does not release the purchase', async () => { + // The same amount on purpose, so only the entry's position tells them apart. + harness.retrieved = authorization({ + status: 'pending', approved: true, amount: 2_000, pending_request: null, + request_history: [decided(true, 2_000), decided(false, 2_000)], + }); + + expect(await answer(deliver('issuing_authorization.request', authorization()))).toBe(500); + + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + }); + + it('an entry that does not match the amount this request asked for is not trusted', async () => { + placeHold('iauth_1', 2_000); + harness.retrieved = authorization({ + status: 'pending', approved: true, amount: 2_000, pending_request: null, + request_history: [decided(true, 2_000), decided(false, 999)], + }); + + await answer(deliver('issuing_authorization.request', increase())); + + expect(liveHolds()).toEqual([['iauth_1', 2_000], ['iauth_1#request-1', 1_000]]); + }); + + it('a decline the card network overrode (network_fallback) keeps the hold', async () => { + placeHold('iauth_1', 2_000); + harness.retrieved = authorization({ + status: 'pending', approved: true, amount: 2_000, pending_request: null, + request_history: [decided(true, 2_000), decided(false, 1_000, 'network_fallback')], + }); + + await answer(deliver('issuing_authorization.request', increase())); + + expect(liveHolds()).toEqual([['iauth_1', 2_000], ['iauth_1#request-1', 1_000]]); + }); + + it('Stripe has not decided yet: the hold stays', async () => { + harness.retrieved = authorization(); + + expect(await answer(deliver('issuing_authorization.request', authorization()))).toBe(500); + + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + }); + + it('Stripe cannot be asked: the hold stays, for the authorization\'s own events to settle', async () => { + harness.retrieveError = new Error('connection reset'); + + expect(await answer(deliver('issuing_authorization.request', authorization()))).toBe(500); + + expect(liveHolds()).toEqual([['iauth_1', 2_000]]); + }); +}); + +describe('our decline call fails', () => { + it('no hold was placed, so nothing is released and Stripe is not asked', async () => { + harness.declineError = new Error('This authorization is no longer pending.'); + placeHold('iauth_other', 4_500); // leaves $5, under the $20 asked + + expect(await answer(deliver('issuing_authorization.request', authorization()))).toBe(500); + + expect(harness.calls).toEqual(['decline iauth_1']); + expect(liveHolds()).toEqual([['iauth_other', 4_500]]); + }); +}); + +describe('the money endpoint\'s subscription, as documented', () => { + // Every event the route handles has to be subscribed at Stripe, or its + // handler never runs. The operator's setup step left out + // issuing_authorization.updated, which releases a reversed or expired hold. + const route = readFileSync('app/api/webhooks/money/route.ts', 'utf8'); + const handled = [...(route.match(/const HANDLED_EVENT_TYPES = new Set\(\[([\s\S]*?)\]\)/)?.[1] ?? '').matchAll(/'([a-z_.]+)'/g)].map((m) => m[1]); + const section = (file: string, start: string, end: RegExp) => { + const text = readFileSync(file, 'utf8'); + const from = text.indexOf(start); + expect(from, `${file} has the money endpoint's section`).toBeGreaterThan(-1); + const rest = text.slice(from); + const stop = rest.slice(1).search(end); + return stop === -1 ? rest : rest.slice(0, stop + 1); + }; + const mentioned = (text: string) => [...text.matchAll(/`((?:issuing_[a-z]+|account)\.[a-z_]+)`/g)].map((m) => m[1]); + + it('the route handles issuing_authorization.created', () => { + expect(handled).toContain('issuing_authorization.created'); + expect(handled).toContain('issuing_authorization.updated'); + }); + + it.each([ + ['docs/AGENT_HANDOFF.md', '`https://www.bubaly.com/api/webhooks/money` subscribed to', /\n> \d+\. /], + ['docs/runbooks/LB-006-provider-callback-smoke.md', '## Stripe Issuing (wallet)', /\n## /], + ])('%s subscribes exactly the events the route handles', (file, start, end) => { + const listed = mentioned(section(file, start, end)); + expect([...new Set(listed)].sort()).toEqual([...handled].sort()); + }); +});