diff --git a/app/(app)/money/actions.ts b/app/(app)/money/actions.ts index 0367bf5cf..820cfb155 100644 --- a/app/(app)/money/actions.ts +++ b/app/(app)/money/actions.ts @@ -17,7 +17,7 @@ import { isManager } from '@/lib/constants/roles'; import { getMoneyCapabilities } from '@/lib/stripe/capabilities'; import { ensureConnectedAccount, createOnboardingLink, syncConnectedAccount } from '@/lib/stripe/connect'; import { ensureFinancialAccount } from '@/lib/stripe/treasury'; -import { ensureCardholder, issueCard, setCardFrozen, updateCardControls } from '@/lib/stripe/issuing'; +import { ensureCardholder, issueCard, setCardFrozen, StaleCardOrderError, updateCardControls } from '@/lib/stripe/issuing'; import { clampSpendLimitCents, normalizeSpendWindow, normalizeBlockedCategories } from '@/lib/wallet/card-controls'; import { evaluateTrust, roleOf } from '@/lib/trust/server'; import { getStripe } from '@/lib/stripe'; @@ -118,10 +118,17 @@ export async function activateTreasuryAction(): Promise { /** Issue a card for a child (requires Issuing capability). */ export async function issueCardAction(input: { childWalletId: string; type: 'virtual' | 'physical'; spendLimitCents: number | null; spendWindow: string; -}): Promise> { + /** How many of this child's cards of this type the ordering view showed, every status. */ + expectedCount: number; +}): Promise | { ok: false; error: string; stale: true }> { const t = await getTranslations(); const ctx = await requireUserContext(); if (!isManager(ctx.active.role)) return { ok: false, error: t('actions.onlyParentsCanIssueCards') }; + // The count the order was made against is what tells a stale view's repeat + // from a deliberate order (issueCard); without a real count there is no order. + if (!Number.isSafeInteger(input.expectedCount) || input.expectedCount < 0) { + return { ok: false, error: t('money.couldNotIssueTheCard') }; + } const svc = createServiceClient(); let caps: Awaited>; try { @@ -170,6 +177,7 @@ export async function issueCardAction(input: { familyId: ctx.active.familyId, childWalletId: wallet.id, cardholderRowId, stripeCardholderId, accountId: acct.stripe_account_id, type: input.type, spendLimitCents: input.spendLimitCents, spendWindow: input.spendWindow, userId: ctx.user.id, + expectedCount: input.expectedCount, }); // An adopted card's row was written, and is audited, by the request of this // same order that inserted it; answering with it here is not a second issue. @@ -182,6 +190,9 @@ export async function issueCardAction(input: { revalidatePath('/wallet'); return { ok: true, data: { cardId: rowId } }; } catch (e) { + // Made against a view another tab or device has moved past: nothing reached + // the provider, and the view re-reads on this answer. + if (e instanceof StaleCardOrderError) return { ok: false, error: t('wallet.refreshToTryAgain'), stale: true }; return actionFailure('issue the card', t('money.couldNotIssueTheCard'), e); } } diff --git a/components/wallet/money-cards-view.tsx b/components/wallet/money-cards-view.tsx index 67c5fa7c0..5703ea238 100644 --- a/components/wallet/money-cards-view.tsx +++ b/components/wallet/money-cards-view.tsx @@ -140,6 +140,16 @@ export function MoneyCardsView({ const childrenWithoutCards = childWallets.filter((c) => !cardsByChild.has(c.id)); + // How many of this child's cards of this type this view shows, every status: + // the count an order from here is made against. The server refuses an order + // whose count its mirror has moved past (another tab or device ordered since + // this view read), so a stale view cannot become a second card; it answers + // "refresh to try again", and the view re-reads so the next order is made + // against what now exists. Audit JIMMY-SUPPORT-CARD-RETRY-20261001. + function shownCount(childWalletId: string, type: IssuedCard['type']) { + return (cardsByChild.get(childWalletId) ?? []).filter((card) => card.type === type).length; + } + function claimPending(keys: string[]) { // Claim synchronously: a retained callback can run again before React has // committed disabled buttons. Independent operations keep their own keys. @@ -176,8 +186,15 @@ export function MoneyCardsView({ const release = claimPending([`issue-${childWalletId}`]); if (!release) return; try { - const res = await issueCardAction({ childWalletId, type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' }); - if (!res.ok) return toastError(res.error); + const res = await issueCardAction({ + childWalletId, type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', + expectedCount: shownCount(childWalletId, 'virtual'), + }); + if (!res.ok) { + toastError(res.error); + if ('stale' in res && res.stale) router.refresh(); + return; + } success(t('moneyCardsView.virtualCardCreated')); router.refresh(); } catch { @@ -205,7 +222,10 @@ export function MoneyCardsView({ let issued = 0; try { for (const child of childrenWithoutCards) { - const res = await issueCardAction({ childWalletId: child.id, type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' }); + const res = await issueCardAction({ + childWalletId: child.id, type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', + expectedCount: shownCount(child.id, 'virtual'), + }); if (!res.ok) failures.push(res.error || t('globalError.somethingWentWrong')); else issued += 1; } @@ -430,6 +450,7 @@ export function MoneyCardsView({ claimPending([`physical-${orderingCard.child.id}`])} isCurrent={() => formsMounted.current && orderInstance.current === orderingCard.instance} @@ -439,6 +460,15 @@ export function MoneyCardsView({ if (orderInstance.current === orderingCard.instance) closeOrder(); router.refresh(); }} + onStale={() => { + // The cards changed under this order. Close the dialog so the + // parent sees the refreshed list, with the card that made it + // stale, before deciding on another: left open, one more click + // would order a second card against the new count. + if (!formsMounted.current) return; + if (orderInstance.current === orderingCard.instance) closeOrder(); + router.refresh(); + }} /> )} @@ -549,9 +579,9 @@ function CardRow({ card, canManage, busy, expanded, controlsKey, claimControls, // ─── Physical Card Order Modal ──────────────────────────────────────────────── -function PhysicalCardModal({ child, pending, claim, isCurrent, onClose, onIssued }: { - child: CardChild; pending: boolean; claim: () => (() => void) | null; isCurrent: () => boolean; - onClose: () => void; onIssued: () => void; +function PhysicalCardModal({ child, expectedCount, pending, claim, isCurrent, onClose, onIssued, onStale }: { + child: CardChild; expectedCount: number; pending: boolean; claim: () => (() => void) | null; isCurrent: () => boolean; + onClose: () => void; onIssued: () => void; onStale: () => void; }) { const t = useTranslations(); const tr = useTranslations(); @@ -569,10 +599,11 @@ function PhysicalCardModal({ child, pending, claim, isCurrent, onClose, onIssued const res = await issueCardAction({ childWalletId: child.id, type: 'physical', spendLimitCents: spendLimitCents && spendLimitCents > 0 ? spendLimitCents : null, - spendWindow: 'daily', + spendWindow: 'daily', expectedCount, }); if (!res.ok) { if (isCurrent()) toastError(res.error ?? 'Could not order card'); + if ('stale' in res && res.stale) onStale(); return; } if (isCurrent()) success(t('wallet.physicalCardOrdered', { name: child.name })); diff --git a/lib/stripe/issuing.ts b/lib/stripe/issuing.ts index ca12cb7eb..e55c3aebb 100644 --- a/lib/stripe/issuing.ts +++ b/lib/stripe/issuing.ts @@ -103,17 +103,38 @@ export async function ensureCardholder( return { rowId: row.id, stripeCardholderId: cardholder.id }; } +/** + * A card order made against a count of the child's cards of its type that the + * mirror no longer holds: the view that sent it is stale (another tab or + * device ordered since it last read). Thrown before any card is created at the + * provider (a first order may already have created the child's cardholder), so + * the caller can tell it from a failure and ask for a refresh. + */ +export class StaleCardOrderError extends Error { + readonly expectedCount: number; + readonly mirroredCount: number; + + constructor(expectedCount: number, mirroredCount: number) { + super(`Card order made against ${expectedCount} existing cards; ${mirroredCount} are mirrored`); + this.name = 'StaleCardOrderError'; + this.expectedCount = expectedCount; + this.mirroredCount = mirroredCount; + } +} + /** * Issue a card for a child wallet. Mirrors spending controls to Stripe. - * `adopted` is true when this call did not mirror a new card but met the exact - * row another request for the same attempt already wrote. + * `expectedCount` is how many of this child's cards of this type the ordering + * view showed; an order whose count the mirror has moved past throws + * StaleCardOrderError. `adopted` is true when this call did not mirror a new + * card but met the exact row another request for the same attempt already wrote. */ export async function issueCard( supabase: DB, params: { familyId: string; childWalletId: string; cardholderRowId: string; stripeCardholderId: string; accountId: string; type: 'virtual' | 'physical'; spendLimitCents: number | null; - spendWindow: string; userId: string | null; + spendWindow: string; userId: string | null; expectedCount: number; }, ): Promise<{ rowId: string; stripeCardId: string; adopted: boolean }> { // The attempt this order belongs to. One order sent twice — two tabs, two @@ -136,6 +157,20 @@ export async function issueCard( if (countError || mirrored === null || !Number.isSafeInteger(mirrored) || mirrored < 0) { throw new Error('Could not count the existing cards'); } + // The count is also the one the order was made against. A view that still + // shows N cards after another tab's card was mirrored (N+1) would otherwise + // read as a new attempt and issue a second card: the server cannot tell it + // from a deliberate order, so the view says what it saw. Either direction is + // stale; the view re-reads and orders against what exists. Orders that + // overlap before the first is mirrored still match and share one key. + // Audit JIMMY-SUPPORT-CARD-RETRY-20261001 (stale-order guard). + if (mirrored !== params.expectedCount) { + // Not a failure, but worth a trace when a parent reports a refused order. + console.warn('[money] card order refused as stale', { + childWalletId: params.childWalletId, type: params.type, expectedCount: params.expectedCount, mirrored, + }); + throw new StaleCardOrderError(params.expectedCount, mirrored); + } const stripe = getStripe(); const interval = WINDOW_INTERVAL[params.spendWindow] ?? 'per_authorization'; @@ -183,31 +218,42 @@ export async function issueCard( .select('id') .single(); if (error) { - // The provider replayed a card another request of this attempt already - // mirrored, so this insert met that row under UNIQUE (stripe_card_id). - // Adopt it only when it is exactly this family, child, type, cardholder row - // and provider card. Its controls, freeze, status and author may have - // changed since, legitimately, so they are not compared. Anything else, - // including a failed re-read, keeps the refusal. - if (error.code === '23505') { - const winner = await supabase - .from('stripe_issuing_cards') - .select('id, family_id, child_wallet_id, type, cardholder_id, stripe_card_id') - .eq('stripe_card_id', card.id) - .maybeSingle() - .then(({ data, error: rereadError }) => { - if (!rereadError) return data; - console.error('[money] card duplicate re-read failed; keeping the refusal', { stripeCardId: card.id, code: rereadError.code }); - return undefined; - }, () => { - console.error('[money] card duplicate re-read rejected; keeping the refusal', { stripeCardId: card.id }); - return undefined; - }); - if (winner && winner.stripe_card_id === card.id && winner.family_id === params.familyId - && winner.child_wallet_id === params.childWalletId && winner.type === params.type - && winner.cardholder_id === params.cardholderRowId) { - return { rowId: winner.id, stripeCardId: card.id, adopted: true }; - } + // Two outcomes leave this attempt's exact row in the mirror although the + // insert reports an error; both are recovered by reading the card back. + // - 23505: the provider replayed a card another request of this attempt + // already mirrored (UNIQUE (stripe_card_id)). That request audits it; + // this one adopts it. + // - Any other error: the insert may have committed with only its reply + // lost (a dropped connection, a timeout). The row is then this order's + // own card, answered and audited here; unread, the parent was told + // "could not issue" for a live, mirrored card with no audit. (Should a + // concurrent request of the same attempt have committed while this + // insert did not, both audit it.) + // Adopt only exactly this family, child, type, cardholder row and card. + // Controls, freeze, status and author may have changed since, + // legitimately, so they are not compared. Anything else, including a + // failed read, keeps the refusal. + const duplicate = error.code === '23505'; + const reading = duplicate ? 'card duplicate re-read' : 'card mirror read-back after a failed insert'; + const winner = await supabase + .from('stripe_issuing_cards') + .select('id, family_id, child_wallet_id, type, cardholder_id, stripe_card_id') + .eq('stripe_card_id', card.id) + .maybeSingle() + .then(({ data, error: rereadError }) => { + if (!rereadError) return data; + console.error(`[money] ${reading} failed; keeping the refusal`, { stripeCardId: card.id, code: rereadError.code }); + return undefined; + }, () => { + console.error(`[money] ${reading} rejected; keeping the refusal`, { stripeCardId: card.id }); + return undefined; + }); + if (winner && winner.stripe_card_id === card.id && winner.family_id === params.familyId + && winner.child_wallet_id === params.childWalletId && winner.type === params.type + && winner.cardholder_id === params.cardholderRowId) { + return { rowId: winner.id, stripeCardId: card.id, adopted: duplicate }; + } + if (duplicate) { // Read, but not this attempt's row: only a corrupted mirror gets here. if (winner !== undefined) { console.error('[money] card duplicate is not this attempt\'s row; keeping the refusal', { stripeCardId: card.id, found: winner !== null }); diff --git a/tests/e2e/money-card-form-ownership.spec.ts b/tests/e2e/money-card-form-ownership.spec.ts index cbd9d90d6..856d2737b 100644 --- a/tests/e2e/money-card-form-ownership.spec.ts +++ b/tests/e2e/money-card-form-ownership.spec.ts @@ -127,7 +127,7 @@ for (const kind of ['physical', 'controls'] as const) { await submit(page, kind).click(); await expect(submit(page, kind)).toBeDisabled(); await submit(page, kind).evaluate(button => (button as HTMLButtonElement).click()); const expected = kind === 'physical' - ? { childWalletId: 'child-a', type: 'physical', spendLimitCents: value ? 1200 : null, spendWindow: 'daily' } + ? { childWalletId: 'child-a', type: 'physical', spendLimitCents: value ? 1200 : null, spendWindow: 'daily', expectedCount: 0 } : { cardId: 'card-a', spendLimitCents: value ? 1200 : null, spendWindow: 'weekly', blockedCategories: ['betting_casino_gambling'] }; expect((await read(page)).calls).toEqual([{ action: kind === 'physical' ? 'issueCardAction' : 'updateCardControlsAction', args: expected, settled: false }]); await complete(page, 0, 'refusal'); await expect(submit(page, kind)).toBeEnabled(); diff --git a/tests/e2e/money-cards-cross-tab.spec.ts b/tests/e2e/money-cards-cross-tab.spec.ts index f54155ccd..cddb5e387 100644 --- a/tests/e2e/money-cards-cross-tab.spec.ts +++ b/tests/e2e/money-cards-cross-tab.spec.ts @@ -12,20 +12,21 @@ import { reactBrowserScripts } from './helpers/react-browser'; // A browser tab is its own JavaScript realm with its own module registry, and the // view shares nothing between tabs (no BroadcastChannel, storage or lock), so a // second tab of the same parent never sees the first tab's claim. Both tabs send -// issueCardAction for the same child and type, with byte-identical input: the -// client sends no attempt identity, so nothing in the request tells "the same -// order twice" from "a second deliberate order". The same holds for a stale tab -// that orders after the first tab's order already completed. +// issueCardAction for the same child and type, with byte-identical input, +// including the count of that child's cards of that type each view showed +// (expectedCount). The server keys an attempt by its mirrored count (repair B, +// lib/stripe/issuing.ts), so two such orders in flight together get one card +// (server-side tests). A stale tab that orders after the first tab's card was +// mirrored still sends the old count, and the server refuses it ("refresh to try +// again"); the tab re-reads and a further order carries the new count. // -// The tests labelled "reproduces:" document that CURRENT behaviour and pass on -// the current source; "preserves:" tests are controls that must keep passing. -// -// What the server does with these calls is not exercised here: every action is -// a held promise and the fixture's answers stand in for the server. Server side, -// repair B (lib/stripe/issuing.ts) keys an order by its attempt, so two calls -// that overlap before the first card is mirrored get one card -// (tests/money-card-attempt-identity.test.ts). A stale tab that orders after the -// first card is mirrored is a new attempt there and still gets a second card. +// The tests labelled "reproduces:" document what the client sends and pass on +// the current source; "preserves:" tests are controls that must keep passing; +// "repaired:" tests pin the stale-order guard. What the server does with these +// calls is not exercised here: every action is a held promise and the fixture's +// answers stand in for the server. Server side, see +// tests/money-card-attempt-identity.test.ts and +// tests/money-card-stale-order.test.ts. // // The real MoneyCardsView and Button run in Chromium. Each tab is a separate page // in one browser context, each with its own copy of the view module (as each real @@ -45,7 +46,11 @@ const sources = Object.fromEntries([VIEW, 'components/ui/button.tsx'].map(file = ])); type ActionCall = { action: string; args?: Record; settled: boolean }; -type Outcome = 'refusal' | 'rejection' | 'success'; +// 'stale' is the server's answer to an order made against a count it has moved past. +// 'stale-localized' is that answer in another locale's words, and +// 'refusal-in-stale-words' an ordinary refusal that happens to read like it: the +// flag, not the words, is what makes the view re-read. +type Outcome = 'refusal' | 'rejection' | 'stale' | 'stale-localized' | 'refusal-in-stale-words' | 'success'; type ServerCall = { tab: string; action: string; args?: Record }; type Probe = { tab: string; calls: ActionCall[]; errors: string[]; successes: string[]; refreshes: number; faults: string[]; @@ -69,8 +74,12 @@ const PROPS = { }; // What a re-read of the page returns once the first order has been issued. const ISSUED = { id: 'card-1', childWalletId: 'child-a', type: 'virtual', status: 'active', last4: '4242', brand: 'Visa', isFrozen: false, spendLimitCents: null, spendWindow: 'per_authorization', blockedCategories: [] }; -const VIRTUAL_INPUT = { childWalletId: 'child-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' }; -const PHYSICAL_INPUT = { childWalletId: 'child-a', type: 'physical', spendLimitCents: null, spendWindow: 'daily' }; +const ISSUED_PHYSICAL = { ...ISSUED, type: 'physical', last4: '4343', spendWindow: 'daily' }; +// What a view showing no card for the child sends. +const VIRTUAL_INPUT = { childWalletId: 'child-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', expectedCount: 0 }; +const PHYSICAL_INPUT = { childWalletId: 'child-a', type: 'physical', spendLimitCents: null, spendWindow: 'daily', expectedCount: 0 }; +// The fixture's translator returns the key, so the stale answer reads as its key. +const STALE_TEXT = 'wallet.refreshToTryAgain'; const serverLogs = new WeakMap(); const tabNames = new WeakMap(); @@ -88,7 +97,7 @@ async function server(context: BrowserContext): Promise { return log; } -async function fixture(page: Page, tab: string) { +async function fixture(page: Page, tab: string, props: typeof PROPS | Record = PROPS) { await server(page.context()); tabNames.set(page, tab); await page.context().setOffline(true); @@ -107,7 +116,7 @@ async function fixture(page: Page, tab: string) { void window.__issuingServer({ action: name, args }); return new Promise((resolve, reject) => waiting.push({ resolve, reject })); } - let props = ${JSON.stringify(PROPS)}, reread = null, root = null; + let props = ${JSON.stringify(props)}, reread = null, root = null; // router.refresh() re-reads the page: the same mounted view re-renders with // what the server now returns (set per tab), as a Next refresh does. const router = { refresh: () => { @@ -172,7 +181,11 @@ async function fixture(page: Page, tab: string) { p.complete = (index, outcome) => { p.calls[index].settled = true; if (outcome === 'rejection') waiting[index].reject(new Error('Action unavailable')); - else waiting[index].resolve(outcome === 'refusal' ? { ok: false, error: 'Action refused' } : { ok: true, data: { cardId: 'card-' + (index + 1) } }); + else waiting[index].resolve(outcome === 'refusal' ? { ok: false, error: 'Action refused' } + : outcome === 'stale' ? { ok: false, error: ${JSON.stringify(STALE_TEXT)}, stale: true } + : outcome === 'stale-localized' ? { ok: false, error: 'Aktualisieren Sie, um es erneut zu versuchen.', stale: true } + : outcome === 'refusal-in-stale-words' ? { ok: false, error: ${JSON.stringify(STALE_TEXT)} } + : { ok: true, data: { cardId: 'card-' + (index + 1) } }); }; p.settle = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); p.mount(); @@ -247,7 +260,8 @@ test('reproduces: two tabs of the same parent each send a Virtual order for the await force(tab2, 'Virtual'); // Both orders are in flight at once and reach the "server" with identical - // input: no attempt identity tells them apart. + // input: both views showed no card, so both carry the same count and the + // server's attempt key (repair B) gives them one card. expect(await received(log, 2)).toEqual([ { tab: 'tab-1', action: 'issueCardAction', args: VIRTUAL_INPUT }, { tab: 'tab-2', action: 'issueCardAction', args: VIRTUAL_INPUT }, @@ -364,40 +378,109 @@ test('preserves: a Virtual order in one tab and a physical order in another for expect(log).toHaveLength(2); }); -test('reproduces: a stale second tab orders the same Virtual card after the first tab\'s order completed and refreshed (2 calls, no attempt identity)', async ({ context }) => { - const { log, tab1, tab2 } = await twoTabs(context); - // After the first order is issued, a re-read in tab 1 returns the new card. - await tab1.evaluate(card => window.__cardTab.refreshReads([card]), ISSUED); +const ENTRY_POINTS = [ + { name: 'the Virtual button', input: VIRTUAL_INPUT, card: ISSUED, toast: 'moneyCardsView.virtualCardCreated', send: (page: Page) => virtual(page).click() }, + { name: 'issue-all', input: VIRTUAL_INPUT, card: ISSUED, toast: 'moneyCards.issuedOneVirtualCard', send: (page: Page) => bulk(page).click() }, + { name: 'the physical order dialog', input: PHYSICAL_INPUT, card: ISSUED_PHYSICAL, toast: 'wallet.physicalCardOrdered:Child A', send: async (page: Page) => { await physical(page).click(); await order(page).click(); } }, +]; - await virtual(tab1).click(); - await complete(tab1, 0, 'success'); - expect(await read(tab1)).toMatchObject({ successes: ['moneyCardsView.virtualCardCreated'], errors: [], refreshes: 1 }); - // Tab 1 refreshed and shows the card. - await expect(tab1.getByText('Visa card', { exact: false })).toBeVisible(); - await expect(tab1.getByText('moneyCardsView.noCardYetUseThe')).toHaveCount(0); - await expect(bulk(tab1)).toHaveCount(0); - // A deliberate further order from the refreshed tab is still allowed (several cards per child). - await expect(virtual(tab1)).toBeEnabled(); - - // Tab 2 was never refreshed: it still shows the child with no card. - await expect(tab2.getByText('moneyCardsView.noCardYetUseThe')).toBeVisible(); - await expect(tab2.getByText('moneyCards.childNoCard:Child A')).toBeVisible(); - await expect(virtual(tab2)).toBeEnabled(); - await virtual(tab2).click(); +for (const entry of ENTRY_POINTS) { + test(`repaired: a stale second tab ordering through ${entry.name} after the first tab's order completed is refused, shows the answer and re-reads; its next order carries the new count`, async ({ context }) => { + const { log, tab1, tab2 } = await twoTabs(context); + // After the first order is issued, a re-read in either tab returns the new card. + for (const tab of [tab1, tab2]) await tab.evaluate(card => window.__cardTab.refreshReads([card]), entry.card); - // The second call is identical to the first: the server cannot tell a stale - // tab's repeat from a deliberate second card. Under repair B the first card is - // already mirrored, so this is a new attempt and gets a second card: the - // stale-tab residual, not asserted here. - expect(await received(log, 2)).toEqual([ - { tab: 'tab-1', action: 'issueCardAction', args: VIRTUAL_INPUT }, - { tab: 'tab-2', action: 'issueCardAction', args: VIRTUAL_INPUT }, - ]); - expect((await read(tab1)).calls).toEqual([{ action: 'issueCardAction', args: VIRTUAL_INPUT, settled: true }]); - expect((await read(tab2)).calls).toEqual([{ action: 'issueCardAction', args: VIRTUAL_INPUT, settled: false }]); + await entry.send(tab1); + await complete(tab1, 0, 'success'); + expect(await read(tab1)).toMatchObject({ successes: [entry.toast], errors: [], refreshes: 1 }); + // Tab 1 refreshed and shows the card. + await expect(tab1.getByText('Visa card', { exact: false })).toBeVisible(); + await expect(tab1.getByText('moneyCardsView.noCardYetUseThe')).toHaveCount(0); + await expect(bulk(tab1)).toHaveCount(0); - await complete(tab2, 0, 'success'); - expect(await read(tab2)).toMatchObject({ successes: ['moneyCardsView.virtualCardCreated'], errors: [], refreshes: 1 }); - expect((await read(tab1)).refreshes).toBe(1); + // Tab 2 was never refreshed: it still shows the child with no card. + await expect(tab2.getByText('moneyCardsView.noCardYetUseThe')).toBeVisible(); + await expect(tab2.getByText('moneyCards.childNoCard:Child A')).toBeVisible(); + await entry.send(tab2); + + // Both views showed no card, so both orders carry the count 0; by the time + // tab 2's arrives the server has mirrored one, and refuses it as stale. + expect(await received(log, 2)).toEqual([ + { tab: 'tab-1', action: 'issueCardAction', args: entry.input }, + { tab: 'tab-2', action: 'issueCardAction', args: entry.input }, + ]); + expect((await read(tab1)).calls).toEqual([{ action: 'issueCardAction', args: entry.input, settled: true }]); + expect((await read(tab2)).calls).toEqual([{ action: 'issueCardAction', args: entry.input, settled: false }]); + + await complete(tab2, 0, 'stale'); + // The answer is shown as any refusal is, and the view re-reads. + expect(await read(tab2)).toMatchObject({ successes: [], errors: [STALE_TEXT], refreshes: 1 }); + await expect(tab2.getByText('Visa card', { exact: false })).toBeVisible(); + await expect(tab2.getByText('moneyCardsView.noCardYetUseThe')).toHaveCount(0); + await expect(bulk(tab2)).toHaveCount(0); + expect((await read(tab1)).refreshes).toBe(1); + + // The order dialog closes on a stale answer, so the parent sees the card that + // made it stale before deciding on another, rather than one more click away + // from a second card. + if (entry.input.type === 'physical') await expect(order(tab2)).toHaveCount(0); + // A further order from the refreshed tab is deliberate and carries the new count. + if (entry.input.type === 'physical') { await physical(tab2).click(); await order(tab2).click(); } + else await virtual(tab2).click(); + const next = { ...entry.input, expectedCount: 1 }; + expect((await received(log, 3))[2]).toEqual({ tab: 'tab-2', action: 'issueCardAction', args: next }); + expect((await read(tab2)).calls.map(call => call.args)).toEqual([entry.input, next]); + }); +} + +test('repaired: the view re-reads on the server\'s stale flag, whatever its words, and not on a refusal that only reads like it', async ({ page }) => { + const log = await server(page.context()); + await fixture(page, 'tab-1'); + + await virtual(page).click(); + await complete(page, 0, 'stale-localized'); + expect(await read(page)).toMatchObject({ successes: [], errors: ['Aktualisieren Sie, um es erneut zu versuchen.'], refreshes: 1 }); + + await expect(virtual(page)).toBeEnabled(); + await virtual(page).click(); + await complete(page, 1, 'refusal-in-stale-words'); + expect(await read(page)).toMatchObject({ + successes: [], errors: ['Aktualisieren Sie, um es erneut zu versuchen.', STALE_TEXT], refreshes: 1, + }); expect(log).toHaveLength(2); }); + +test('repaired: an up-to-date tab sends, for each order, that child\'s count of that card type, every status', async ({ page }) => { + const log = await server(page.context()); + const card = (id: string, childWalletId: string, type: string, status: string) => ({ ...ISSUED, id, childWalletId, type, status, last4: id.slice(-4) }); + // Child A: one virtual card, two physical (one canceled). Child B: one frozen virtual card. + // Per child and type: A virtual 1, A physical 2, B virtual 1; A in all types 3; the family 4. + await fixture(page, 'tab-1', { + ...PROPS, + childWallets: [{ id: 'child-a', name: 'Child A', color: null }, { id: 'child-b', name: 'Child B', color: null }], + cards: [ + card('card-0001', 'child-a', 'virtual', 'active'), + card('card-0002', 'child-a', 'physical', 'active'), + card('card-0003', 'child-a', 'physical', 'canceled'), + { ...card('card-0004', 'child-b', 'virtual', 'inactive'), isFrozen: true }, + ], + }); + await expect(bulk(page)).toHaveCount(0); + + await virtual(page).first().click(); + await physical(page).first().click(); + await order(page).click(); + await virtual(page).nth(1).click(); + + const expected = [ + { ...VIRTUAL_INPUT, expectedCount: 1 }, + { ...PHYSICAL_INPUT, expectedCount: 2 }, + { ...VIRTUAL_INPUT, childWalletId: 'child-b', expectedCount: 1 }, + ]; + expect(await received(log, 3)).toEqual(expected.map(args => ({ tab: 'tab-1', action: 'issueCardAction', args }))); + expect((await read(page)).calls.map(call => call.args)).toEqual(expected); + + for (const index of [0, 1, 2]) await complete(page, index, 'success'); + expect(await read(page)).toMatchObject({ errors: [], refreshes: 3 }); + expect(log).toHaveLength(3); +}); diff --git a/tests/e2e/money-cards-pending-owner.spec.ts b/tests/e2e/money-cards-pending-owner.spec.ts index 987e1b867..d34316db5 100644 --- a/tests/e2e/money-cards-pending-owner.spec.ts +++ b/tests/e2e/money-cards-pending-owner.spec.ts @@ -178,9 +178,11 @@ test('a remounted view of the same owner keeps an in-flight order pending, then expect(await read(page)).toMatchObject({ successes: ['moneyCardsView.virtualCardCreated'], refreshes: 2 }); // A deliberate new order after the first settled still goes out: several cards per child. await virtual(page).click(); + // Each carries the count of child-a's virtual cards the view shows (this + // fixture's refresh does not re-read the list). expect((await read(page)).calls.map(call => call.args)).toEqual([ - { childWalletId: 'child-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' }, - { childWalletId: 'child-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' }, + { childWalletId: 'child-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', expectedCount: 1 }, + { childWalletId: 'child-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', expectedCount: 1 }, ]); await complete(page, 1, 'refusal'); expect(requests).toEqual([]); diff --git a/tests/money-card-attempt-identity.test.ts b/tests/money-card-attempt-identity.test.ts index 833abeb0f..c39555336 100644 --- a/tests/money-card-attempt-identity.test.ts +++ b/tests/money-card-attempt-identity.test.ts @@ -73,11 +73,16 @@ import { issueCardAction } from '@/app/(app)/money/actions'; const FAMILY = 'family-synthetic'; const ACCOUNT = 'acct_synthetic'; const T0 = Date.UTC(2026, 9, 1, 12, 0, 0); -// What the two UI entry points send (components/wallet/money-cards-view.tsx). -const VIRTUAL = { childWalletId: 'wallet-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' } as const; -const PHYSICAL = { childWalletId: 'wallet-a', type: 'physical', spendLimitCents: 2500, spendWindow: 'daily' } as const; +// What the two UI entry points send (components/wallet/money-cards-view.tsx) +// from a view showing no card of that type for the child; `shown` gives the +// count a view showing others sends. +const VIRTUAL = { childWalletId: 'wallet-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', expectedCount: 0 } as const; +const PHYSICAL = { childWalletId: 'wallet-a', type: 'physical', spendLimitCents: 2500, spendWindow: 'daily', expectedCount: 0 } as const; +const shown = (input: T, expectedCount: number) => ({ ...input, expectedCount }); // The action's own text, and lib/supabase/errors.ts's text for a duplicate key. const REFUSED = 'translated:money.couldNotIssueTheCard'; +// The answer to an order made against a count the mirror has moved past. +const STALE = 'translated:wallet.refreshToTryAgain'; const DUPLICATE_TEXT = 'That already exists. Try a different value.'; type Result = Awaited>; /** A canned answer for one `from('stripe_issuing_cards')` builder, or a rejection. */ @@ -414,7 +419,8 @@ describe('one card order reaching the server twice before its card is mirrored', describe('controls: orders that must stay separate cards, before and after the repair', () => { it('control: deliberate later orders, each after the previous card is mirrored, each issue a new card', async () => { - const orders = [VIRTUAL, VIRTUAL, PHYSICAL, PHYSICAL]; + // Each is made from a view that shows the cards issued before it. + const orders = [VIRTUAL, shown(VIRTUAL, 1), PHYSICAL, shown(PHYSICAL, 1)]; const results: Result[] = []; for (const [step, input] of orders.entries()) { vi.setSystemTime(T0 + step * 60_000); @@ -458,17 +464,21 @@ describe('controls: orders that must stay separate cards, before and after the r }); describe('residuals the attempt key does not cover', () => { - it.each([0, 1, 60_000])('residual: a second order of the same type after the first card is mirrored gets a new card, keyed by the count and not the clock (%i ms later)', async gap => { - // A second tab whose stale view never showed the first card sends exactly - // what a deliberate second order sends. The mirrored count moved, so it is - // a new attempt; telling them apart needs the view to send the count it - // saw (an expectedCount design, client and server), not done here. + it.each([0, 1, 60_000])('formerly residual, now refused: a second order of the same type after the first card is mirrored, from a view that never showed it, is refused as stale; from one that did, it is a new card keyed by the count and not the clock (%i ms later)', async gap => { + // A second tab whose stale view never showed the first card sends the + // count it saw (0), which the mirror has moved past (1): refused before the + // provider. A view that shows the first card sends 1, a deliberate second + // order. See tests/money-card-stale-order.test.ts. const first = await issueCardAction(VIRTUAL); vi.setSystemTime(T0 + gap); const stale = await issueCardAction(VIRTUAL); + expect(stale).toEqual({ ok: false, error: STALE, stale: true }); + expect(provider.active('virtual')).toHaveLength(1); + expect(provider.log.cardKeys).toEqual([key('virtual', 0)]); + const deliberate = await issueCardAction(shown(VIRTUAL, 1)); const rows = mirrorCards(); - expect([first, stale]).toEqual(rows.map(row => ({ ok: true, data: { cardId: row.id } }))); + expect([first, deliberate]).toEqual(rows.map(row => ({ ok: true, data: { cardId: row.id } }))); expect(provider.active('virtual')).toHaveLength(2); expect(provider.log.cardKeys).toEqual([key('virtual', 0), key('virtual', 1)]); expect(provider.log.cardOutcomes).toEqual(['created', 'created']); @@ -571,10 +581,10 @@ describe('the count that names the attempt', () => { { family_id: FAMILY, child_wallet_id: 'wallet-a', cardholder_id: 'holder-a', stripe_card_id: 'ic_seeded_4', type: 'virtual', status: 'canceled' }, { family_id: FAMILY, child_wallet_id: 'wallet-a', cardholder_id: 'holder-a', stripe_card_id: 'ic_seeded_5', type: 'virtual', status: 'inactive', is_frozen: true }, ]); - expect((await issueCardAction(VIRTUAL)).ok).toBe(true); + expect((await issueCardAction(shown(VIRTUAL, 2))).ok).toBe(true); vi.setSystemTime(T0 + 60_000); - expect((await issueCardAction(VIRTUAL)).ok).toBe(true); - expect((await issueCardAction(PHYSICAL)).ok).toBe(true); + expect((await issueCardAction(shown(VIRTUAL, 3))).ok).toBe(true); + expect((await issueCardAction(shown(PHYSICAL, 1))).ok).toBe(true); expect(provider.log.cardKeys).toEqual([key('virtual', 2), key('virtual', 3), key('physical', 1)]); expect(provider.log.cardOutcomes).toEqual(['created', 'created', 'created']); }); @@ -603,7 +613,7 @@ describe('a card mirror insert that meets UNIQUE (stripe_card_id)', () => { }, ])('adopts the row that is exactly this family, child wallet, type, cardholder row and card ($label), without a second audit', async ({ row }) => { db.seed('stripe_issuing_cards', [row]); - expect(await issueCardAction(VIRTUAL)).toEqual({ ok: true, data: { cardId: EXACT.id } }); + expect(await issueCardAction(shown(VIRTUAL, 1))).toEqual({ ok: true, data: { cardId: EXACT.id } }); // Adopted as it is: nothing about the row is rewritten. expect(db.table('stripe_issuing_cards')).toEqual([expect.objectContaining(row)]); expect(provider.log.cardKeys).toEqual([key('virtual', 1)]); @@ -613,14 +623,16 @@ describe('a card mirror insert that meets UNIQUE (stripe_card_id)', () => { expect(console.error).not.toHaveBeenCalled(); }); + // The last column is how many of this child's virtual cards the view shows: + // the seeded row counts only when it is this family's, this child's and virtual. it.each([ - ['another family', { family_id: 'family-other' }], - ['another child wallet', { child_wallet_id: 'wallet-b' }], - ['another type', { type: 'physical' }], - ['another cardholder row', { cardholder_id: 'holder-other' }], - ])('keeps the refusal when the mirrored row belongs to %s', async (_label, change) => { + ['another family', { family_id: 'family-other' }, 0], + ['another child wallet', { child_wallet_id: 'wallet-b' }, 0], + ['another type', { type: 'physical' }, 0], + ['another cardholder row', { cardholder_id: 'holder-other' }, 1], + ])('keeps the refusal when the mirrored row belongs to %s', async (_label, change, count) => { db.seed('stripe_issuing_cards', [{ ...EXACT, ...change }]); - expect(await issueCardAction(VIRTUAL)).toEqual({ ok: false, error: DUPLICATE_TEXT }); + expect(await issueCardAction(shown(VIRTUAL, count))).toEqual({ ok: false, error: DUPLICATE_TEXT }); expect(db.table('stripe_issuing_cards')).toEqual([expect.objectContaining({ ...EXACT, ...change })]); expect(provider.stripe.issuing.cards.create).toHaveBeenCalledTimes(1); expect(issuedAudits()).toEqual([]); @@ -651,7 +663,7 @@ describe('a card mirror insert that meets UNIQUE (stripe_card_id)', () => { ])('keeps the original refusal when the re-read gives $label', async ({ reply, logged, mismatch }) => { db.seed('stripe_issuing_cards', [EXACT]); script.push(null, null, reply); - expect(await issueCardAction(VIRTUAL)).toEqual({ ok: false, error: DUPLICATE_TEXT }); + expect(await issueCardAction(shown(VIRTUAL, 1))).toEqual({ ok: false, error: DUPLICATE_TEXT }); expect(failures()).toEqual([expect.stringMatching(/^Failed to persist card: duplicate key value/)]); expect(rereadLogs()).toEqual(logged); expect(mismatchLogs()).toEqual(mismatch); @@ -659,19 +671,47 @@ describe('a card mirror insert that meets UNIQUE (stripe_card_id)', () => { expect(cardTableCalls()).toBe(3); }); - it.each([ + const NON_DUPLICATE = [ { code: '08006', message: 'synthetic connection refusal' }, { code: '23503', message: 'synthetic foreign key refusal' }, { message: 'synthetic refusal without a code' }, - ])('does not re-read after a non-duplicate mirror insert failure %j, and names the provider card', async error => { - // An adoptable row is there: only the error code keeps it from being read. + ]; + const readBackLogs = () => vi.mocked(console.error).mock.calls + .filter(([message]) => typeof message === 'string' && message.startsWith('[money] card mirror read-back')); + + it.each(NON_DUPLICATE)('reads the card back after a non-duplicate mirror insert failure %j and, when this attempt\'s exact row is there (the insert committed, its reply was lost), answers with it and audits it once', async error => { db.seed('stripe_issuing_cards', [EXACT]); script.push(null, { error }); - expect(await issueCardAction(VIRTUAL)).toEqual({ ok: false, error: REFUSED }); + expect(await issueCardAction(shown(VIRTUAL, 1))).toEqual({ ok: true, data: { cardId: EXACT.id } }); + // This order's own card: audited here, by the request whose insert committed. + expect(issuedAudits()).toEqual([EXACT.id]); + expect(cardTableCalls()).toBe(3); + expect(failures()).toEqual([]); + expect(unmirroredLogs()).toEqual([]); + expect(readBackLogs()).toEqual([]); + expect(rereadLogs()).toEqual([]); + }); + + it.each(NON_DUPLICATE)('keeps the refusal after a non-duplicate mirror insert failure %j when the read-back finds no row, and names the provider card', async error => { + script.push(null, { error }); + expect(await issueCardAction(shown(VIRTUAL, 0))).toEqual({ ok: false, error: REFUSED }); expect(failures()).toEqual([`Failed to persist card: ${error.message}`]); expect(unmirroredLogs()).toEqual([[UNMIRRORED, { stripeCardId: CARD, code: error.code }]]); - expect(cardTableCalls()).toBe(2); + expect(cardTableCalls()).toBe(3); expect(issuedAudits()).toEqual([]); - expect(rereadLogs()).toEqual([]); + expect(mismatchLogs()).toEqual([]); + }); + + it.each([ + { label: 'a returned error', reply: { error: { code: '08006', message: 'synthetic read-back refusal' } } as Scripted, logged: [['[money] card mirror read-back after a failed insert failed; keeping the refusal', { stripeCardId: CARD, code: '08006' }]] }, + { label: 'a rejection', reply: 'reject' as Scripted, logged: [['[money] card mirror read-back after a failed insert rejected; keeping the refusal', { stripeCardId: CARD }]] }, + ])('keeps the refusal after a non-duplicate mirror insert failure when the read-back gives $label', async ({ reply, logged }) => { + db.seed('stripe_issuing_cards', [EXACT]); + script.push(null, { error: { code: '08006', message: 'synthetic connection refusal' } }, reply); + expect(await issueCardAction(shown(VIRTUAL, 1))).toEqual({ ok: false, error: REFUSED }); + expect(readBackLogs()).toEqual(logged); + expect(unmirroredLogs()).toEqual([[UNMIRRORED, { stripeCardId: CARD, code: '08006' }]]); + expect(issuedAudits()).toEqual([]); + expect(cardTableCalls()).toBe(3); }); }); diff --git a/tests/money-card-retry-characterization.test.ts b/tests/money-card-retry-characterization.test.ts index 4c33d4a42..128a1a5b3 100644 --- a/tests/money-card-retry-characterization.test.ts +++ b/tests/money-card-retry-characterization.test.ts @@ -10,8 +10,10 @@ import { syntheticProvider } from './helpers/synthetic-issuing-provider'; // named "reproduces" describe a defect candidate; "preserves" describe the // existing multiple-card contract any repair must keep; "repaired (B)" pin what // attempt identity (issueCard's count-based key, lib/stripe/issuing.ts) -// changed on purpose. Its interleavings live in -// tests/money-card-attempt-identity.test.ts. +// changed on purpose, and "repaired (stale guard)" what the count the view +// sends (expectedCount) changed. Their interleavings live in +// tests/money-card-attempt-identity.test.ts and +// tests/money-card-stale-order.test.ts. // // Sealed: no network, no real Stripe SDK, no real database client. The provider // is an in-process double with Stripe's documented idempotency semantics @@ -92,9 +94,10 @@ const FAMILY = 'family-synthetic'; const ACCOUNT = 'acct_synthetic'; const T0 = Date.UTC(2026, 9, 1, 12, 0, 0); // What the two UI entry points send (components/wallet/money-cards-view.tsx): -// the row's Virtual button and the physical order dialog with a limit entered. -const VIRTUAL = { childWalletId: 'wallet-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' } as const; -const PHYSICAL = { childWalletId: 'wallet-a', type: 'physical', spendLimitCents: 2500, spendWindow: 'daily' } as const; +// the row's Virtual button and the physical order dialog with a limit entered, +// from a view showing no card of that type for the child. +const VIRTUAL = { childWalletId: 'wallet-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization', expectedCount: 0 } as const; +const PHYSICAL = { childWalletId: 'wallet-a', type: 'physical', spendLimitCents: 2500, spendWindow: 'daily', expectedCount: 0 } as const; let db: ReturnType; let provider: ReturnType; @@ -179,37 +182,37 @@ afterEach(() => { afterAll(() => network.restore()); describe('card issuance reaching the server more than once', () => { - it.each([VIRTUAL, PHYSICAL])('reproduces: one $type order sent again (a remounted view re-click) issues a second live card', async input => { + it.each([VIRTUAL, PHYSICAL])('repaired (stale guard): one $type order sent again after its card is mirrored (a remounted view re-click, still showing the old count) is refused, one live card', async input => { const first = await issueCardAction(input); vi.setSystemTime(T0 + 1); const second = await issueCardAction(input); - expect(first.ok && second.ok).toBe(true); - if (!first.ok || !second.ok) return; - expect(first.data?.cardId).not.toBe(second.data?.cardId); - // The key carries the attempt now (the mirrored count), not the clock, but - // the first card was mirrored before the re-click arrived: the count moved, - // so the provider still cannot tell it from a second order. Telling them - // apart needs the view to send the count it saw (not implemented). - expect(provider.log.cardKeys).toEqual([cardKey(input.type, 0), cardKey(input.type, 1)]); - expect(provider.log.cardOutcomes).toEqual(['created', 'created']); - expect(provider.active(input.type)).toHaveLength(2); - expect(mirrorCards().map(row => row.type)).toEqual([input.type, input.type]); - expect(audits('card_issued')).toHaveLength(2); + // Before the guard the re-click read the moved count (1), took a new key + // and the provider issued a second live card. The view sends the count it + // showed (0), which the mirror has moved past: refused before the provider. + expect(first.ok).toBe(true); + expect(second).toEqual({ ok: false, error: 'translated:wallet.refreshToTryAgain', stale: true }); + expect(provider.log.cardKeys).toEqual([cardKey(input.type, 0)]); + expect(provider.log.cardOutcomes).toEqual(['created']); + expect(provider.active(input.type)).toHaveLength(1); + expect(mirrorCards().map(row => row.type)).toEqual([input.type]); + expect(audits('card_issued')).toHaveLength(1); // The cardholder is created once (stable key + lookup) and reused. expect(provider.log.cardholderKeys).toEqual(['cardholder-member-a']); expect(provider.cardholders).toHaveLength(1); }); - it('repaired (B): the same order sent again within one millisecond, after the first card is mirrored, is a new attempt — no clock collision, a second live card', async () => { + it('repaired (B): the next order sent within one millisecond against the up-to-date count, after the first card is mirrored, is a new attempt — no clock collision, a second live card', async () => { const first = await issueCardAction(VIRTUAL); - const second = await issueCardAction(VIRTUAL); + const second = await issueCardAction({ ...VIRTUAL, expectedCount: 1 }); // Before B the shared clock key replayed one card and the second mirror // insert's 23505 reached the parent as "That already exists". The key no // longer reads the clock: the first card was mirrored before the second - // order read the count, so it is a new attempt (the re-click case above). - // Orders that overlap before the first is mirrored share one key: see + // order read the count, so it is a new attempt. Sent from a view that + // shows the first card (1) it is deliberate and issues; sent against the + // old count (0) it is the stale re-click above, refused. Orders that + // overlap before the first is mirrored share one key: see // tests/money-card-attempt-identity.test.ts. expect(first.ok && second.ok).toBe(true); if (!first.ok || !second.ok) return; @@ -309,7 +312,8 @@ describe('card issuance reaching the server more than once', () => { }); it('preserves: deliberate later orders each issue their own card — several virtual and physical cards per child, one cardholder', async () => { - const orders = [VIRTUAL, PHYSICAL, VIRTUAL, { ...VIRTUAL, childWalletId: 'wallet-b' }]; + // Each is made from a view that shows the cards issued before it. + const orders = [VIRTUAL, PHYSICAL, { ...VIRTUAL, expectedCount: 1 }, { ...VIRTUAL, childWalletId: 'wallet-b' }]; for (const [step, input] of orders.entries()) { vi.setSystemTime(T0 + step * 60_000); expect((await issueCardAction(input)).ok).toBe(true); diff --git a/tests/money-card-stale-order.test.ts b/tests/money-card-stale-order.test.ts new file mode 100644 index 000000000..62d1bfa95 --- /dev/null +++ b/tests/money-card-stale-order.test.ts @@ -0,0 +1,440 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '@/lib/database.types'; +import { createInMemorySupabase, type Row } from './helpers/in-memory-supabase'; +import { syntheticProvider } from './helpers/synthetic-issuing-provider'; + +// JIMMY-SUPPORT-CARD-RETRY-20261001, the stale-order guard: a tab whose view is +// STALE (it still shows N cards of a type for a child) orders that type AFTER +// another tab's card was mirrored. The mirrored count is N+1 by then, so the +// attempt key alone (repair B) treats it as a new attempt and issues a second +// card. The view now sends the count it showed (expectedCount), and issueCard +// refuses an order whose count no longer matches the mirror, before the +// provider is asked for anything. +// +// These drive the real issueCardAction → ensureCardholder → issueCard against +// the synthetic provider (tests/helpers/synthetic-issuing-provider.ts) and the +// in-memory database with the 00901 unique constraints. +// +// Sealed: no network, no real Stripe SDK, no real database client. + +const network = await vi.hoisted(async () => { + const http = (await import('node:http')).default; + const https = (await import('node:https')).default; + const net = (await import('node:net')).default; + const tls = (await import('node:tls')).default; + const attempts = { fetch: 0, http: 0, https: 0, socket: 0, tls: 0 }; + const deny = (kind: keyof typeof attempts) => () => { attempts[kind]++; throw new Error(`Unexpected ${kind} network attempt`); }; + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(deny('fetch')); + const guards = [ + vi.spyOn(http, 'request').mockImplementation(deny('http')), + vi.spyOn(http, 'get').mockImplementation(deny('http')), + vi.spyOn(https, 'request').mockImplementation(deny('https')), + vi.spyOn(https, 'get').mockImplementation(deny('https')), + vi.spyOn(net, 'connect').mockImplementation(deny('socket')), + vi.spyOn(net, 'createConnection').mockImplementation(deny('socket')), + vi.spyOn(net.Socket.prototype, 'connect').mockImplementation(deny('socket')), + vi.spyOn(tls, 'connect').mockImplementation(deny('tls')), + ]; + const entries = () => [ + http.request, http.get, https.request, https.get, + net.connect, net.createConnection, net.Socket.prototype.connect, tls.connect, globalThis.fetch, + ] as unknown as (() => unknown)[]; + const sealed = () => entries().map(fn => vi.isMockFunction(fn)); + return { attempts, sealed, restore() { guards.forEach(guard => guard.mockRestore()); globalThis.fetch = originalFetch; } }; +}); +const SEALED = Array(9).fill(true); +const mock = vi.hoisted(() => ({ + requireUserContext: vi.fn(), createServiceClient: vi.fn(), capabilities: vi.fn(), getStripe: vi.fn(), + trust: vi.fn(), roleOf: vi.fn(), audit: vi.fn(), translations: vi.fn(), revalidate: vi.fn(), + forbidden: vi.fn(), sdkModuleLoads: 0, stripeModuleLoads: 0, stripeInitializations: 0, +})); +vi.mock('@/lib/supabase/auth', () => ({ requireUserContext: mock.requireUserContext })); +vi.mock('@/lib/supabase/server', () => ({ createServiceClient: mock.createServiceClient, createServer: mock.forbidden })); +vi.mock('@/lib/stripe/capabilities', () => ({ getMoneyCapabilities: mock.capabilities })); +vi.mock('@/lib/stripe/connect', () => ({ ensureConnectedAccount: mock.forbidden, createOnboardingLink: mock.forbidden, syncConnectedAccount: mock.forbidden })); +vi.mock('@/lib/stripe/treasury', () => ({ ensureFinancialAccount: mock.forbidden })); +vi.mock('@/lib/stripe/settings', () => ({ effectivePublishableKey: mock.forbidden })); +vi.mock('@/lib/stripe', () => ({ getStripe: mock.getStripe })); +vi.mock('@/lib/trust/server', () => ({ evaluateTrust: mock.trust, roleOf: mock.roleOf })); +vi.mock('@/lib/server/audit', () => ({ logWalletAudit: mock.audit })); +vi.mock('next/headers', () => ({ headers: mock.forbidden })); +vi.mock('next/cache', () => ({ revalidatePath: mock.revalidate })); +vi.mock('@/lib/i18n/server', () => ({ getTranslations: mock.translations })); +vi.mock('@supabase/supabase-js', () => { mock.sdkModuleLoads++; return { createClient: mock.forbidden }; }); +vi.mock('@supabase/ssr', () => { mock.sdkModuleLoads++; return { createServerClient: mock.forbidden, createBrowserClient: mock.forbidden }; }); +vi.mock('stripe', () => { + mock.stripeModuleLoads++; + return { default: class { constructor() { mock.stripeInitializations++; throw new Error('Stripe SDK must not initialize'); } } }; +}); + +import { issueCardAction } from '@/app/(app)/money/actions'; + +const FAMILY = 'family-synthetic'; +const ACCOUNT = 'acct_synthetic'; +const T0 = Date.UTC(2026, 9, 1, 12, 0, 0); +// What the two UI entry points send (components/wallet/money-cards-view.tsx), +// without the count the view showed; each case adds it. +const VIRTUAL = { childWalletId: 'wallet-a', type: 'virtual', spendLimitCents: null, spendWindow: 'per_authorization' } as const; +const PHYSICAL = { childWalletId: 'wallet-a', type: 'physical', spendLimitCents: 2500, spendWindow: 'daily' } as const; +// The stale refusal, and the action's own text for every other failure. +const STALE = 'translated:wallet.refreshToTryAgain'; +const REFUSED = 'translated:money.couldNotIssueTheCard'; +type Input = Parameters[0]; +type Order = typeof VIRTUAL | typeof PHYSICAL | { childWalletId: string; type: 'virtual' | 'physical'; spendLimitCents: number | null; spendWindow: string }; +/** A canned answer for one `from('stripe_issuing_cards')` builder, or a rejection. */ +type Scripted = { data?: unknown; error?: unknown; count?: number | null } | 'reject'; + +let db: ReturnType; +let provider: ReturnType; +let service: SupabaseClient; +// Per `from('stripe_issuing_cards')` call, in order: null is the real table. +let script: (Scripted | null)[]; +let quiet: { mockRestore(): void }[] = []; + +function scripted(reply: Scripted) { + const settle = () => reply === 'reject' + ? Promise.reject(new Error('synthetic transport failure')) + : Promise.resolve({ data: null, error: null, count: null, ...reply }); + const chain: Record = { + single: settle, maybeSingle: settle, + then: (ok: (value: unknown) => unknown, bad: (reason: unknown) => unknown) => settle().then(ok, bad), + }; + for (const method of ['select', 'insert', 'eq']) chain[method] = () => chain; + return chain; +} + +/** One order, sent with the count the sending view showed. */ +const order = (input: Order, expectedCount: number) => issueCardAction({ ...input, expectedCount }); +const mirrorCards = (childWalletId = 'wallet-a') => db.table('stripe_issuing_cards').filter(row => row.child_wallet_id === childWalletId); +const holderRow = (childWalletId = 'wallet-a') => db.table('stripe_cardholders').find(row => row.child_wallet_id === childWalletId)!.id as string; +/** Repair B's attempt key for the n-th mirrored card of a type for a child. */ +const key = (type: string, n: number, wallet = 'wallet-a') => `card-${holderRow(wallet)}-${wallet}-${type}-${n}`; +/** entity_id of every card_issued audit, in the order they were written. */ +const issuedAudits = () => mock.audit.mock.calls.filter(([, row]) => (row as Row).action === 'card_issued').map(([, row]) => (row as Row).entity_id); +/** What issueCardAction logged for each failed order. */ +const failures = () => vi.mocked(console.error).mock.calls + .filter(([message]) => message === '[money-action] issue the card failed') + .map(([, error]) => (error instanceof Error ? error.message : error)); +/** Every effect a card order can have: provider creates, live cards, mirror rows, card_issued audits. */ +const tally = () => ({ + creates: provider.stripe.issuing.cards.create.mock.calls.length, + live: provider.active().length, + rows: db.table('stripe_issuing_cards').length, + audits: issuedAudits().length, +}); + +beforeEach(() => { + expect(network.sealed()).toEqual(SEALED); + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(T0); + db = createInMemorySupabase({ + uniques: { + stripe_cardholders: [['family_id', 'member_id'], ['stripe_cardholder_id']], + stripe_issuing_cards: [['stripe_card_id']], + }, + }); + db.seed('stripe_connected_accounts', [{ family_id: FAMILY, stripe_account_id: ACCOUNT, card_issuing_enabled: true }]); + db.seed('child_wallets', [ + { id: 'wallet-a', family_id: FAMILY, member_id: 'member-a' }, + { id: 'wallet-b', family_id: FAMILY, member_id: 'member-b' }, + ]); + db.seed('family_members', [ + { id: 'member-a', family_id: FAMILY, display_name: 'Synthetic A' }, + { id: 'member-b', family_id: FAMILY, display_name: 'Synthetic B' }, + ]); + provider = syntheticProvider(); + script = []; + service = { + from(table: string) { + const builder = db.from(table); + if (table !== 'stripe_issuing_cards') return builder; + const reply = script.shift(); + return reply ? scripted(reply) : builder; + }, + } as unknown as SupabaseClient; + mock.requireUserContext.mockResolvedValue({ + user: { id: 'user-synthetic', email: 'parent@example.invalid' }, + active: { familyId: FAMILY, role: 'parent', member: { id: 'manager-synthetic' } }, + }); + mock.createServiceClient.mockReturnValue(service); + mock.capabilities.mockResolvedValue({ issuing: true, physicalCards: true }); + mock.getStripe.mockReturnValue(provider.stripe); + mock.trust.mockResolvedValue({ decision: { effect: 'allow' } }); + mock.roleOf.mockReturnValue('parent'); + mock.audit.mockResolvedValue(undefined); + mock.translations.mockResolvedValue((text: string) => `translated:${text}`); + mock.forbidden.mockImplementation(() => { throw new Error('Unrelated client/provider call'); }); + quiet = [ + vi.spyOn(console, 'error').mockImplementation(() => {}), + vi.spyOn(console, 'warn').mockImplementation(() => {}), + ]; +}); +afterEach(() => { + vi.useRealTimers(); + // Only this case's console spies; restoring every mock would unseal the network. + quiet.forEach(spy => spy.mockRestore()); + expect(network.sealed()).toEqual(SEALED); + expect(network.attempts).toEqual({ fetch: 0, http: 0, https: 0, socket: 0, tls: 0 }); + expect(mock.forbidden).not.toHaveBeenCalled(); + expect(mock.sdkModuleLoads).toBe(0); + expect(mock.stripeModuleLoads).toBe(0); + expect(mock.stripeInitializations).toBe(0); +}); +afterAll(() => network.restore()); + +describe('an order made against a count the mirror has moved past', () => { + it.each([ + { input: VIRTUAL, shown: 0, label: 'no' }, + { input: VIRTUAL, shown: 2, label: 'two' }, + { input: PHYSICAL, shown: 0, label: 'no' }, + { input: PHYSICAL, shown: 2, label: 'two' }, + ])('a stale tab still showing $label $input.type cards orders after another tab\'s card was mirrored: refused with "refresh to try again", no card, row or audit', async ({ input, shown }) => { + // The child already has `shown` cards of this type, each ordered on purpose. + for (let n = 0; n < shown; n++) { + vi.setSystemTime(T0 + n * 60_000); + expect((await order(input, n)).ok).toBe(true); + } + // Tab 1 orders against the same view and its card is mirrored. + vi.setSystemTime(T0 + shown * 60_000); + expect((await order(input, shown)).ok).toBe(true); + const before = tally(); + expect(before).toEqual({ creates: shown + 1, live: shown + 1, rows: shown + 1, audits: shown + 1 }); + + // Tab 2 was never refreshed: it still shows `shown` cards. + vi.setSystemTime(T0 + (shown + 1) * 60_000); + expect(await order(input, shown)).toEqual({ ok: false, error: STALE, stale: true }); + // Traced on the server, with ids and counts only. + expect(vi.mocked(console.warn).mock.calls.filter(([message]) => message === '[money] card order refused as stale')).toEqual([[ + '[money] card order refused as stale', { childWalletId: 'wallet-a', type: input.type, expectedCount: shown, mirrored: shown + 1 }, + ]]); + + expect(tally()).toEqual(before); + expect(provider.log.cardKeys).toEqual(Array.from({ length: shown + 1 }, (_, n) => key(input.type, n))); + expect(provider.log.cardOutcomes).toEqual(Array(shown + 1).fill('created')); + // The cardholder is reused, not recreated; nothing else reached the provider. + expect(provider.log.cardholderKeys).toEqual(['cardholder-member-a']); + expect(provider.stripe.accounts.retrieve).toHaveBeenCalledTimes(1); + // A stale view is an expected answer, not a failure, and nothing changed to revalidate. + expect(failures()).toEqual([]); + expect(mock.revalidate).toHaveBeenCalledTimes(shown + 1); + }); + + it.each([VIRTUAL, PHYSICAL])('a deliberate $type order from an up-to-date view (expectedCount = the mirrored count) issues a new card', async input => { + expect((await order(input, 0)).ok).toBe(true); + vi.setSystemTime(T0 + 60_000); + const next = await order(input, 1); + + const rows = mirrorCards(); + expect(next).toEqual({ ok: true, data: { cardId: rows[1].id } }); + expect(tally()).toEqual({ creates: 2, live: 2, rows: 2, audits: 2 }); + expect(provider.log.cardKeys).toEqual([key(input.type, 0), key(input.type, 1)]); + expect(provider.log.cardOutcomes).toEqual(['created', 'created']); + expect(rows.map(row => row.stripe_card_id)).toEqual(provider.active(input.type).map(card => card.id)); + expect(issuedAudits()).toEqual(rows.map(row => row.id)); + }); + + it.each([VIRTUAL, PHYSICAL])('after the stale refusal the refreshed view orders a second $type card on purpose against the new count', async input => { + expect((await order(input, 0)).ok).toBe(true); + vi.setSystemTime(T0 + 60_000); + expect(await order(input, 0)).toEqual({ ok: false, error: STALE, stale: true }); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + vi.setSystemTime(T0 + 120_000); + expect((await order(input, 1)).ok).toBe(true); + + expect(tally()).toEqual({ creates: 2, live: 2, rows: 2, audits: 2 }); + expect(provider.log.cardKeys).toEqual([key(input.type, 0), key(input.type, 1)]); + }); + + it('an order ahead of the mirror (expectedCount above the count) is refused too, before any card reaches the provider', async () => { + // A view can show a card the mirror no longer counts (a recreated + // cardholder's cards cascade away). It is just as stale. + expect(await order(VIRTUAL, 1)).toEqual({ ok: false, error: STALE, stale: true }); + expect(tally()).toEqual({ creates: 0, live: 0, rows: 0, audits: 0 }); + // The cardholder is ensured first, as for any order; it is reused later. + expect(provider.log.cardholderKeys).toEqual(['cardholder-member-a']); + expect(mock.revalidate).not.toHaveBeenCalled(); + expect((await order(VIRTUAL, 0)).ok).toBe(true); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + expect(provider.log.cardKeys).toEqual([key('virtual', 0)]); + }); +}); + +describe('two overlapping orders that were both made against N', () => { + it.each([VIRTUAL, PHYSICAL])('the second reads N (the first is not mirrored yet): one shared key, one $type card, both answered with it', async input => { + provider.holdCards('response'); + const requests = [order(input, 0)]; + await vi.waitFor(() => expect(provider.heldCards).toHaveLength(1)); + vi.setSystemTime(T0 + 1); + requests.push(order(input, 0)); + await vi.waitFor(() => expect(provider.heldCards).toHaveLength(2)); + provider.heldCards.forEach(request => request.release()); + const results = await Promise.all(requests); + + const rows = mirrorCards(); + expect(results).toEqual([{ ok: true, data: { cardId: rows[0].id } }, { ok: true, data: { cardId: rows[0].id } }]); + expect(tally()).toEqual({ creates: 2, live: 1, rows: 1, audits: 1 }); + expect(provider.log.cardKeys).toEqual([key(input.type, 0), key(input.type, 0)]); + expect(provider.log.cardOutcomes).toEqual(['created', 'replayed']); + }); + + it.each([VIRTUAL, PHYSICAL])('the second reads N+1 (the first was mirrored before its count read): the second is refused as stale, one $type card', async input => { + // The second tab's request was sent first but is held at the Trust step, + // before it reads the count, while the first tab's order completes. + let admit!: () => void; + mock.trust.mockImplementationOnce(() => new Promise(resolve => { admit = () => resolve({ decision: { effect: 'allow' } }); })); + const late = order(input, 0); + await vi.waitFor(() => expect(mock.trust).toHaveBeenCalledTimes(1)); + vi.setSystemTime(T0 + 1); + const first = await order(input, 0); + expect(mirrorCards()).toHaveLength(1); + admit(); + const second = await late; + + expect(first).toEqual({ ok: true, data: { cardId: mirrorCards()[0].id } }); + expect(second).toEqual({ ok: false, error: STALE, stale: true }); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + expect(provider.log.cardKeys).toEqual([key(input.type, 0)]); + expect(provider.log.cardOutcomes).toEqual(['created']); + }); +}); + +describe('what the count is made of', () => { + it('virtual and physical, and each child, are counted on their own', async () => { + const steps: [Order, number, boolean][] = [ + [VIRTUAL, 0, true], + // No physical card yet, although a virtual one exists. + [PHYSICAL, 0, true], + // The child's total (2) is not its physical count (1), nor its virtual count (1). + [PHYSICAL, 2, false], + [VIRTUAL, 2, false], + [VIRTUAL, 1, true], + // Another child's count is its own, not the family's total (3). + [{ ...VIRTUAL, childWalletId: 'wallet-b' }, 3, false], + [{ ...VIRTUAL, childWalletId: 'wallet-b' }, 0, true], + ]; + const results: Awaited>[] = []; + for (const [step, [input, shown]] of steps.entries()) { + vi.setSystemTime(T0 + step * 60_000); + results.push(await order(input, shown)); + } + + expect(results.map(result => (result.ok ? 'issued' : result.error))).toEqual(steps.map(([, , issues]) => (issues ? 'issued' : STALE))); + expect(tally()).toEqual({ creates: 4, live: 4, rows: 4, audits: 4 }); + expect(provider.log.cardKeys).toEqual([key('virtual', 0), key('physical', 0), key('virtual', 1), key('virtual', 0, 'wallet-b')]); + expect(mirrorCards('wallet-a').map(row => row.type)).toEqual(['virtual', 'physical', 'virtual']); + expect(mirrorCards('wallet-b').map(row => row.type)).toEqual(['virtual']); + }); + + it('every status counts, as the view shows every status', async () => { + db.seed('stripe_cardholders', [{ id: 'holder-a', family_id: FAMILY, member_id: 'member-a', child_wallet_id: 'wallet-a', stripe_cardholder_id: 'ich_seeded_a' }]); + db.seed('stripe_issuing_cards', [ + { family_id: FAMILY, child_wallet_id: 'wallet-a', cardholder_id: 'holder-a', stripe_card_id: 'ic_seeded_1', type: 'virtual', status: 'canceled' }, + { family_id: FAMILY, child_wallet_id: 'wallet-a', cardholder_id: 'holder-a', stripe_card_id: 'ic_seeded_2', type: 'virtual', status: 'inactive', is_frozen: true }, + ]); + // Counting only the active cards (0) is stale. + expect(await order(VIRTUAL, 0)).toEqual({ ok: false, error: STALE, stale: true }); + expect(provider.stripe.issuing.cards.create).not.toHaveBeenCalled(); + expect((await order(VIRTUAL, 2)).ok).toBe(true); + expect(provider.log.cardKeys).toEqual([key('virtual', 2)]); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 3, audits: 1 }); + }); +}); + +describe('an expectedCount that is not a count', () => { + const MISSING = Symbol('missing'); + it.each([ + { label: 'negative', value: -1 }, + { label: 'fractional', value: 0.5 }, + { label: 'NaN', value: Number.NaN }, + { label: 'infinite', value: Number.POSITIVE_INFINITY }, + { label: 'beyond the safe integers', value: Number.MAX_SAFE_INTEGER + 1 }, + { label: 'a numeric string', value: '0' }, + { label: 'null', value: null }, + { label: 'missing', value: MISSING }, + ])('refuses an order whose expectedCount is $label before any read, Trust check or provider call', async ({ value }) => { + const input: Record = { ...VIRTUAL }; + if (value !== MISSING) input.expectedCount = value; + expect(await issueCardAction(input as unknown as Input)).toEqual({ ok: false, error: REFUSED }); + + expect(tally()).toEqual({ creates: 0, live: 0, rows: 0, audits: 0 }); + expect(provider.stripe.accounts.retrieve).not.toHaveBeenCalled(); + expect(provider.stripe.issuing.cardholders.create).not.toHaveBeenCalled(); + expect(db.log).toEqual([]); + expect(mock.trust).not.toHaveBeenCalled(); + expect(mock.revalidate).not.toHaveBeenCalled(); + }); + + it('still refuses an invalid expectedCount when the child already has cards', async () => { + expect((await order(VIRTUAL, 0)).ok).toBe(true); + const tables = db.log.length; + expect(await issueCardAction({ ...VIRTUAL, expectedCount: '1' } as unknown as Input)).toEqual({ ok: false, error: REFUSED }); + expect(db.log).toHaveLength(tables); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + }); +}); + +describe('a count that cannot be read', () => { + it.each([ + { label: 'a returned error', reply: { error: { code: '08006', message: 'synthetic read refusal' } }, logged: 'Could not count the existing cards' }, + { label: 'a returned error beside a matching count', reply: { count: 0, error: { code: '08006', message: 'synthetic read refusal' } }, logged: 'Could not count the existing cards' }, + { label: 'no count', reply: { count: null }, logged: 'Could not count the existing cards' }, + { label: 'a rejection', reply: 'reject' as const, logged: 'synthetic transport failure' }, + ])('still refuses with the generic text, not as stale, when the count read gives $label', async ({ reply, logged }) => { + script.push(reply); + expect(await order(VIRTUAL, 0)).toEqual({ ok: false, error: REFUSED }); + expect(tally()).toEqual({ creates: 0, live: 0, rows: 0, audits: 0 }); + expect(failures()).toEqual([logged]); + expect(db.log.filter(entry => entry.table === 'stripe_issuing_cards')).toHaveLength(1); + }); +}); + +describe('a card that was mirrored but whose success never reached the parent', () => { + /** + * The next card mirror INSERT commits its row, then its reply is lost (a + * dropped connection or a timeout after commit): the server sees an error + * although the row is there. + */ + function loseNextInsertReplyAfterCommit() { + const loose = service as unknown as { from: (table: string) => unknown }; + const realFrom = loose.from.bind(service); + let armed = true; + loose.from = (table: string) => { + const builder = realFrom(table) as Record unknown>; + if (table !== 'stripe_issuing_cards' || !armed) return builder; + return Object.assign(Object.create(builder), { + insert(values: unknown) { + armed = false; + const committed = (builder.insert(values) as { select: (c: string) => { single: () => Promise } }).select('id').single(); + const lost = committed.then(() => ({ data: null, error: { code: '08006', message: 'synthetic reply lost after commit' } })); + return { select: () => ({ single: () => lost }) }; + }, + }); + }; + } + + it.each([VIRTUAL, PHYSICAL])('the action\'s own answer is lost after a $type card was mirrored and audited: the retry from the unrefreshed view is refused as stale, so one card, row and audit', async input => { + // Tab 1's answer never arrives; its view still shows no card. + expect((await order(input, 0)).ok).toBe(true); + vi.setSystemTime(T0 + 30_000); + expect(await order(input, 0)).toEqual({ ok: false, error: STALE, stale: true }); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + }); + + it.each([VIRTUAL, PHYSICAL])('the mirror insert of a $type card committed but its reply was lost: the order answers with that card and audits it once, and a retry from the unrefreshed view is refused as stale', async input => { + loseNextInsertReplyAfterCommit(); + const first = await order(input, 0); + const [row] = mirrorCards(); + expect(row).toBeDefined(); + // Recovered: the committed row is this order's card. + expect(first).toEqual({ ok: true, data: { cardId: row.id } }); + expect(issuedAudits()).toEqual([row.id]); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + + vi.setSystemTime(T0 + 30_000); + expect(await order(input, 0)).toEqual({ ok: false, error: STALE, stale: true }); + expect(tally()).toEqual({ creates: 1, live: 1, rows: 1, audits: 1 }); + }); +}); diff --git a/tests/money-card-trust-bridge.test.ts b/tests/money-card-trust-bridge.test.ts index d854e2b63..0fb9a5d67 100644 --- a/tests/money-card-trust-bridge.test.ts +++ b/tests/money-card-trust-bridge.test.ts @@ -63,7 +63,7 @@ type Mode = 'allow' | 'deny' | 'policy_approval' | 'degraded_policy_error' | 'de type Reply = { data: unknown; error: unknown; rejection?: unknown }; const family = 'family-bridge-synthetic'; const manager = 'manager-bridge-synthetic'; -const input = { childWalletId: 'wallet-bridge-synthetic', type: 'virtual' as const, spendLimitCents: 5000, spendWindow: 'monthly' }; +const input = { childWalletId: 'wallet-bridge-synthetic', type: 'virtual' as const, spendLimitCents: 5000, spendWindow: 'monthly', expectedCount: 0 }; let replies: Record; let reads: { table: string; method: string; args: unknown[] }[]; let auditRows: Record[]; diff --git a/tests/money-treasury-card-actions.test.ts b/tests/money-treasury-card-actions.test.ts index 6468cdf43..49c63fd2e 100644 --- a/tests/money-treasury-card-actions.test.ts +++ b/tests/money-treasury-card-actions.test.ts @@ -32,6 +32,8 @@ const mock = vi.hoisted(() => ({ treasury: vi.fn(), cardholder: vi.fn(), issueCard: vi.fn(), freezeCard: vi.fn(), controls: vi.fn(), trust: vi.fn(), roleOf: vi.fn(), stripe: vi.fn(), publishableKey: vi.fn(), audit: vi.fn(), forbiddenClient: vi.fn(), stripeModuleLoads: 0, stripeInitializations: 0, + // Stands in for the library's stale-order error, which the action detects by class. + StaleCardOrderError: class StaleCardOrderError extends Error {}, })); // Every provider/server factory is replaced outright: no importOriginal/importActual. vi.mock('@/lib/supabase/auth', () => ({ requireUserContext: mock.requireUserContext })); @@ -39,7 +41,7 @@ vi.mock('@/lib/supabase/server', () => ({ createServiceClient: mock.createServic vi.mock('@/lib/stripe/capabilities', () => ({ getMoneyCapabilities: mock.capabilities })); vi.mock('@/lib/stripe/connect', () => ({ ensureConnectedAccount: mock.ensureAccount, createOnboardingLink: mock.link, syncConnectedAccount: mock.sync })); vi.mock('@/lib/stripe/treasury', () => ({ ensureFinancialAccount: mock.treasury })); -vi.mock('@/lib/stripe/issuing', () => ({ ensureCardholder: mock.cardholder, issueCard: mock.issueCard, setCardFrozen: mock.freezeCard, updateCardControls: mock.controls })); +vi.mock('@/lib/stripe/issuing', () => ({ ensureCardholder: mock.cardholder, issueCard: mock.issueCard, setCardFrozen: mock.freezeCard, updateCardControls: mock.controls, StaleCardOrderError: mock.StaleCardOrderError })); vi.mock('@/lib/stripe', () => ({ getStripe: mock.stripe })); vi.mock('@/lib/stripe/settings', () => ({ effectivePublishableKey: mock.publishableKey })); vi.mock('@/lib/trust/server', () => ({ evaluateTrust: mock.trust, roleOf: mock.roleOf })); @@ -62,7 +64,7 @@ import { evaluateAction, type Decision, type Policy, type TrustRole } from '@/li const family = 'family-synthetic-a'; const memberId = 'manager-member-synthetic'; const service = { from: mock.from }; -const input = { childWalletId: 'wallet-synthetic', type: 'virtual' as const, spendLimitCents: 5000, spendWindow: 'monthly' }; +const input = { childWalletId: 'wallet-synthetic', type: 'virtual' as const, spendLimitCents: 5000, spendWindow: 'monthly', expectedCount: 0 }; const detail = 'synthetic provider detail must not reach the result'; type QueryReply = { data: Record | null; error: unknown; rejection?: unknown }; let context: { user: { id: string }; active: { familyId: string; role: TrustRole; member: { id: string } } }; @@ -293,8 +295,8 @@ describe('card capability, lookup and orchestration boundaries', () => { expect(mock.trust).not.toHaveBeenCalled(); noInstrumentEffects(); }); it.each([ - { childWalletId: 'wallet-synthetic', type: 'virtual' as const, spendLimitCents: null, spendWindow: 'per_authorization' }, - { childWalletId: 'wallet-synthetic', type: 'physical' as const, spendLimitCents: 5000, spendWindow: 'daily' }, + { childWalletId: 'wallet-synthetic', type: 'virtual' as const, spendLimitCents: null, spendWindow: 'per_authorization', expectedCount: 0 }, + { childWalletId: 'wallet-synthetic', type: 'physical' as const, spendLimitCents: 5000, spendWindow: 'daily', expectedCount: 3 }, ])('preserves scoped helper/audit payloads, mirror id and ordering for %j', async request => { context.active.familyId = 'family-synthetic-b'; replies.stripe_connected_accounts.data = { stripe_account_id: 'acct_synthetic_b', card_issuing_enabled: true }; @@ -316,6 +318,7 @@ describe('card capability, lookup and orchestration boundaries', () => { expect(mock.issueCard).toHaveBeenCalledExactlyOnceWith(service, { familyId: 'family-synthetic-b', childWalletId: request.childWalletId, cardholderRowId: 'holder-row', stripeCardholderId: 'ich_synthetic', accountId: 'acct_synthetic_b', type: request.type, spendLimitCents: request.spendLimitCents, spendWindow: request.spendWindow, userId: context.user.id, + expectedCount: request.expectedCount, }); expect(mock.audit).toHaveBeenCalledExactlyOnceWith(service, { family_id: 'family-synthetic-b', actor_user_id: context.user.id, action: 'card_issued', @@ -336,6 +339,28 @@ describe('card capability, lookup and orchestration boundaries', () => { if (stage === 'cardholder') expect(mock.issueCard).not.toHaveBeenCalled(); expect(mock.audit).not.toHaveBeenCalled(); expect(mock.revalidate).not.toHaveBeenCalled(); }); + it('answers a stale-order refusal from issueCard with "refresh to try again", without audit, refresh or failure log', async () => { + mock.issueCard.mockRejectedValue(new mock.StaleCardOrderError(detail)); + expect(await issueCardAction(input)).toEqual({ ok: false, error: 'translated:wallet.refreshToTryAgain', stale: true }); + expect(mock.issueCard).toHaveBeenCalledTimes(1); + expect(mock.audit).not.toHaveBeenCalled(); expect(mock.revalidate).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + it.each([ + ['an error named like it', Object.assign(new Error(detail), { name: 'StaleCardOrderError' })], + ['an error whose message reads like it', new Error('Card order made against 0 existing cards; 1 are mirrored')], + ])('keeps the generic refusal for %s that is not the stale-order class', async (_label, error) => { + mock.issueCard.mockRejectedValue(error); + expect(await issueCardAction(input)).toEqual({ ok: false, error: 'translated:money.couldNotIssueTheCard' }); + expect(mock.audit).not.toHaveBeenCalled(); expect(mock.revalidate).not.toHaveBeenCalled(); + }); + it.each([ + ['-1', -1], ['0.5', 0.5], ['NaN', Number.NaN], ['Infinity', Number.POSITIVE_INFINITY], + ["the string '0'", '0'], ['null', null], ['undefined', undefined], + ])('refuses an expectedCount of %s before service, Trust or provider access', async (_label, expectedCount) => { + expect(await issueCardAction({ ...input, expectedCount } as unknown as typeof input)).toEqual({ ok: false, error: 'translated:money.couldNotIssueTheCard' }); + noEffects(); + }); it('preserves safe classified permission errors without leaking detail', async () => { replies.child_wallets.error = { code: '42501', message: detail }; expect(await issueCardAction(input)).toEqual({ ok: false, error: "You don't have permission to do that. Ask a family admin if you think this is a mistake." });