Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/api/webhooks/money/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const MAX_WEBHOOK_BODY_BYTES = 256_000;
*/
const HANDLED_EVENT_TYPES = new Set<string>([
'issuing_authorization.request',
'issuing_authorization.created',
'issuing_authorization.updated',
'issuing_transaction.created',
'issuing_card.created',
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions docs/AGENT_HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions docs/runbooks/LB-006-provider-callback-smoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,16 @@ event** (`stripe events resend <id>`) → 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`

Expand Down
92 changes: 86 additions & 6 deletions lib/stripe/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Database>;
const STALE_EVENT_MS = 10 * 60 * 1000;
Expand Down Expand Up @@ -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;

Expand All @@ -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' };
}
Expand All @@ -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');
}

Expand Down Expand Up @@ -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<typeof getStripe>, auth: Stripe.Issuing.Authorization,
priorRequests: number, holdRef: string, amount: number, opts: Stripe.RequestOptions | undefined,
): Promise<void> {
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
Expand Down Expand Up @@ -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
Expand All @@ -387,11 +459,19 @@ export async function handleIssuingCardUpdated(supabase: DB, card: Stripe.Issuin
export async function handleAuthorizationUpdated(
supabase: DB, auth: Stripe.Issuing.Authorization,
): Promise<void> {
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 ?? []) {
Expand Down
23 changes: 23 additions & 0 deletions lib/wallet/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
// 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<string>();
Expand Down
Loading
Loading