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
15 changes: 13 additions & 2 deletions app/(app)/money/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,10 +118,17 @@ export async function activateTreasuryAction(): Promise<Result> {
/** 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<Result<{ cardId: string }>> {
/** How many of this child's cards of this type the ordering view showed, every status. */
expectedCount: number;
}): Promise<Result<{ cardId: string }> | { 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<ReturnType<typeof getMoneyCapabilities>>;
try {
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}
}
Expand Down
45 changes: 38 additions & 7 deletions components/wallet/money-cards-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -430,6 +450,7 @@ export function MoneyCardsView({
<PhysicalCardModal
key={orderingCard.instance}
child={orderingCard.child}
expectedCount={shownCount(orderingCard.child.id, 'physical')}
pending={busy.has(`physical-${orderingCard.child.id}`)}
claim={() => claimPending([`physical-${orderingCard.child.id}`])}
isCurrent={() => formsMounted.current && orderInstance.current === orderingCard.instance}
Expand All @@ -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();
}}
/>
)}

Expand Down Expand Up @@ -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();
Expand All @@ -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 }));
Expand Down
102 changes: 74 additions & 28 deletions lib/stripe/issuing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/money-card-form-ownership.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading