Skip to content

Commit 24c5c3a

Browse files
authored
feat(stripe): capture early fraud warnings for review (#3554)
* feat(stripe): capture early fraud warnings for review * fix(stripe): harden early fraud warning admin listing * fix(stripe): avoid relinking deleted fraud warning owners * fix(stripe): synchronize EFW owner linking with deletion
1 parent d3b84f1 commit 24c5c3a

11 files changed

Lines changed: 1262 additions & 18 deletions

.specs/stripe-early-fraud-warnings.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capital
3838
3. Existing dispute, refund, and fraud-mark handling MUST continue to operate independently, subject to idempotency rules that prevent duplicated EFW side effects.
3939
4. The persistence-only foundation MAY exist before processing is enabled; until ingestion is deployed, it MUST remain unwritten by EFW automation.
4040

41+
### Observation-Only Rollout Interval
42+
43+
- Before automatic enforcement is deployed and enabled, newly delivered EFWs MUST be persisted as `review_required` cases for operator visibility only, including warnings that resolve to a canonical personal owner.
44+
- A case captured during the observation-only interval MUST remain manual-review work and MUST NOT later be promoted into automatic enforcement merely because enforcement is enabled for newly arriving warnings.
45+
- Observation-only ingestion MUST NOT create action-ledger work, block users, disable automatic top-up, refund payments, reverse value, modify subscriptions or compute, reverse payouts or rewards, or send enforcement notices.
46+
4147
### Ownership Resolution and Review Boundary
4248

4349
5. Automatic enforcement MUST occur only when the warned payment resolves to one canonical personal owner and does not resolve to an organization.

apps/web/src/app/admin/components/AppSidebar.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ const financialItems: MenuItem[] = [
109109
url: '/admin/kilo-pass/bulk-cancel',
110110
icon: () => <Coins />,
111111
},
112+
{
113+
title: () => 'Early Fraud Warnings',
114+
url: '/admin/early-fraud-warnings',
115+
icon: () => <Shield />,
116+
},
112117
{
113118
title: () => 'Revenue KPI',
114119
url: '/admin/revenue',
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
import { describe, expect, it } from '@jest/globals';
3+
import { renderToStaticMarkup } from 'react-dom/server';
4+
5+
import { EarlyFraudWarningsTable, type EarlyFraudWarningRow } from './EarlyFraudWarningsContent';
6+
7+
const rows = [
8+
{
9+
id: '11111111-1111-4111-8111-111111111111',
10+
stripeEarlyFraudWarningId: 'issfr_personal',
11+
stripeEventId: 'evt_personal',
12+
stripeChargeId: 'ch_personal',
13+
stripePaymentIntentId: 'pi_personal',
14+
stripeCustomerId: 'cus_personal',
15+
amountMinorUnits: 1900,
16+
currency: 'usd',
17+
ownerClassification: 'personal',
18+
status: 'review_required',
19+
reason: 'Observation only: canonical personal owner matched; manual review required',
20+
failureContext: null,
21+
warningCreatedAt: '2026-05-28T10:00:00.000Z',
22+
reviewRequiredAt: '2026-05-28T10:00:01.000Z',
23+
createdAt: '2026-05-28T10:00:01.000Z',
24+
user: { id: 'user-personal', email: 'personal@example.com', name: 'Personal User' },
25+
organization: null,
26+
},
27+
{
28+
id: '22222222-2222-4222-8222-222222222222',
29+
stripeEarlyFraudWarningId: 'issfr_organization',
30+
stripeEventId: 'evt_organization',
31+
stripeChargeId: null,
32+
stripePaymentIntentId: null,
33+
stripeCustomerId: 'cus_organization',
34+
amountMinorUnits: null,
35+
currency: null,
36+
ownerClassification: 'organization',
37+
status: 'review_required',
38+
reason: 'Organization-owned warning; manual review required',
39+
failureContext: null,
40+
warningCreatedAt: null,
41+
reviewRequiredAt: '2026-05-28T10:00:01.000Z',
42+
createdAt: '2026-05-28T10:00:01.000Z',
43+
user: null,
44+
organization: { id: 'organization-id', name: 'Review Organization' },
45+
},
46+
] satisfies EarlyFraudWarningRow[];
47+
48+
describe('EarlyFraudWarningsTable', () => {
49+
it('renders stored review cases with operational context and safe account links', () => {
50+
const html = renderToStaticMarkup(
51+
React.createElement(EarlyFraudWarningsTable, { rows, isLoading: false })
52+
);
53+
54+
expect(html).toContain('Personal observation');
55+
expect(html).toContain('Review required');
56+
expect(html).toContain('$19.00');
57+
expect(html).toContain('personal@example.com');
58+
expect(html).toContain('Review Organization');
59+
expect(html).toContain('issfr_personal');
60+
expect(html).toContain('ch_personal');
61+
expect(html).toContain('payments/ch_personal');
62+
expect(html).toContain(
63+
'Observation only: canonical personal owner matched; manual review required'
64+
);
65+
});
66+
});
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import { useQuery } from '@tanstack/react-query';
5+
import type { inferRouterOutputs } from '@trpc/server';
6+
import { ExternalLink } from 'lucide-react';
7+
import Link from 'next/link';
8+
9+
import type { RootRouter } from '@/routers/root-router';
10+
import { useTRPC } from '@/lib/trpc/utils';
11+
import { formatCents } from '@/lib/utils';
12+
import { Badge } from '@/components/ui/badge';
13+
import { Button } from '@/components/ui/button';
14+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
15+
import {
16+
Table,
17+
TableBody,
18+
TableCell,
19+
TableHead,
20+
TableHeader,
21+
TableRow,
22+
} from '@/components/ui/table';
23+
24+
const PAGE_SIZE = 25;
25+
type RouterOutputs = inferRouterOutputs<RootRouter>;
26+
export type EarlyFraudWarningRow =
27+
RouterOutputs['admin']['earlyFraudWarnings']['list']['rows'][number];
28+
29+
export function EarlyFraudWarningsContent() {
30+
const trpc = useTRPC();
31+
const [page, setPage] = useState(1);
32+
const casesQuery = useQuery(
33+
trpc.admin.earlyFraudWarnings.list.queryOptions({ page, limit: PAGE_SIZE })
34+
);
35+
const rows = casesQuery.data?.rows ?? [];
36+
const pagination = casesQuery.data?.pagination;
37+
38+
return (
39+
<div className="flex w-full flex-col gap-6">
40+
<div className="space-y-2">
41+
<h2 className="text-2xl font-bold">Early Fraud Warnings</h2>
42+
<p className="text-muted-foreground max-w-4xl">
43+
Review new Stripe warnings captured during the observation rollout. This view is
44+
read-only; captured cases do not restrict access, refund payments, or schedule automated
45+
actions.
46+
</p>
47+
</div>
48+
49+
<Card>
50+
<CardHeader>
51+
<CardTitle>Captured warnings</CardTitle>
52+
<CardDescription>
53+
One row is stored per newly delivered warning. Personal matches remain manual-review
54+
cases during observation.
55+
</CardDescription>
56+
</CardHeader>
57+
<CardContent className="flex flex-col gap-4">
58+
{casesQuery.isError ? (
59+
<p className="text-destructive text-sm" role="alert">
60+
Warning cases could not be loaded. Refresh the page to try again.
61+
</p>
62+
) : (
63+
<>
64+
<EarlyFraudWarningsTable rows={rows} isLoading={casesQuery.isLoading} />
65+
<div className="flex flex-col items-start justify-between gap-3 text-sm sm:flex-row sm:items-center">
66+
<p className="text-muted-foreground">
67+
{pagination
68+
? `${pagination.total} captured warning${pagination.total === 1 ? '' : 's'}`
69+
: 'Loading warning count...'}
70+
</p>
71+
<div className="flex gap-2">
72+
<Button
73+
variant="secondary"
74+
size="sm"
75+
onClick={() => setPage(current => Math.max(1, current - 1))}
76+
disabled={page <= 1 || casesQuery.isFetching}
77+
>
78+
Previous
79+
</Button>
80+
<Button
81+
variant="secondary"
82+
size="sm"
83+
onClick={() => setPage(current => current + 1)}
84+
disabled={!pagination || page >= pagination.totalPages || casesQuery.isFetching}
85+
>
86+
Next
87+
</Button>
88+
</div>
89+
</div>
90+
</>
91+
)}
92+
</CardContent>
93+
</Card>
94+
</div>
95+
);
96+
}
97+
98+
export function EarlyFraudWarningsTable({
99+
rows,
100+
isLoading,
101+
}: {
102+
rows: EarlyFraudWarningRow[];
103+
isLoading: boolean;
104+
}) {
105+
return (
106+
<div className="overflow-x-auto rounded-lg border">
107+
<Table>
108+
<TableHeader>
109+
<TableRow>
110+
<TableHead>Received</TableHead>
111+
<TableHead>Status</TableHead>
112+
<TableHead>Owner</TableHead>
113+
<TableHead>Amount</TableHead>
114+
<TableHead>Linked account</TableHead>
115+
<TableHead>Stripe identifiers</TableHead>
116+
<TableHead>Review reason</TableHead>
117+
</TableRow>
118+
</TableHeader>
119+
<TableBody>
120+
{rows.length === 0 ? (
121+
<TableRow>
122+
<TableCell colSpan={7} className="text-muted-foreground h-24 text-center">
123+
{isLoading
124+
? 'Loading captured warnings...'
125+
: 'No early fraud warnings captured yet.'}
126+
</TableCell>
127+
</TableRow>
128+
) : (
129+
rows.map(row => (
130+
<TableRow key={row.id}>
131+
<TableCell className="whitespace-nowrap text-sm">
132+
{formatTimestamp(row.warningCreatedAt ?? row.createdAt)}
133+
</TableCell>
134+
<TableCell>
135+
<Badge variant={row.status === 'failed' ? 'destructive' : 'secondary'}>
136+
{formatStatus(row.status)}
137+
</Badge>
138+
</TableCell>
139+
<TableCell>
140+
<Badge
141+
variant={row.ownerClassification === 'ambiguous' ? 'destructive' : 'outline'}
142+
>
143+
{formatOwnerClassification(row.ownerClassification)}
144+
</Badge>
145+
</TableCell>
146+
<TableCell className="whitespace-nowrap font-mono text-sm tabular-nums">
147+
{formatAmount(row.amountMinorUnits, row.currency)}
148+
</TableCell>
149+
<TableCell className="min-w-48 text-sm">{renderLinkedAccount(row)}</TableCell>
150+
<TableCell className="min-w-64 text-xs">
151+
<StripeIdentifiers row={row} />
152+
</TableCell>
153+
<TableCell className="text-muted-foreground min-w-64 text-sm">
154+
{row.reason ?? 'Manual review required'}
155+
{row.failureContext ? (
156+
<div className="text-destructive mt-1">{row.failureContext}</div>
157+
) : null}
158+
</TableCell>
159+
</TableRow>
160+
))
161+
)}
162+
</TableBody>
163+
</Table>
164+
</div>
165+
);
166+
}
167+
168+
function StripeIdentifiers({ row }: { row: EarlyFraudWarningRow }) {
169+
return (
170+
<div className="flex flex-col gap-1 font-mono">
171+
<span>{row.stripeEarlyFraudWarningId}</span>
172+
{row.stripeChargeId ? (
173+
<a
174+
href={stripePaymentUrl(row.stripeChargeId)}
175+
target="_blank"
176+
rel="noopener noreferrer"
177+
className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1"
178+
>
179+
{row.stripeChargeId}
180+
<ExternalLink className="size-3 shrink-0" />
181+
</a>
182+
) : null}
183+
{row.stripePaymentIntentId ? <span>{row.stripePaymentIntentId}</span> : null}
184+
{row.stripeCustomerId ? (
185+
<a
186+
href={stripeCustomerUrl(row.stripeCustomerId)}
187+
target="_blank"
188+
rel="noopener noreferrer"
189+
className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1"
190+
>
191+
{row.stripeCustomerId}
192+
<ExternalLink className="size-3 shrink-0" />
193+
</a>
194+
) : null}
195+
</div>
196+
);
197+
}
198+
199+
function renderLinkedAccount(row: EarlyFraudWarningRow) {
200+
if (row.user) {
201+
return (
202+
<Link
203+
className="text-blue-400 hover:text-blue-300"
204+
href={`/admin/users/${encodeURIComponent(row.user.id)}`}
205+
>
206+
{row.user.email}
207+
</Link>
208+
);
209+
}
210+
211+
if (row.organization) {
212+
return (
213+
<Link
214+
className="text-blue-400 hover:text-blue-300"
215+
href={`/admin/organizations/${encodeURIComponent(row.organization.id)}`}
216+
>
217+
{row.organization.name}
218+
</Link>
219+
);
220+
}
221+
222+
return <span className="text-muted-foreground">No owner linked</span>;
223+
}
224+
225+
function formatStatus(status: string): string {
226+
return status.replaceAll('_', ' ').replace(/^./, value => value.toUpperCase());
227+
}
228+
229+
function formatOwnerClassification(classification: string): string {
230+
return classification === 'personal'
231+
? 'Personal observation'
232+
: classification.replace(/^./, value => value.toUpperCase());
233+
}
234+
235+
function formatTimestamp(value: string | null): string {
236+
if (!value) return 'Not available';
237+
const date = new Date(value);
238+
return Number.isNaN(date.getTime()) ? 'Not available' : date.toLocaleString();
239+
}
240+
241+
function formatAmount(amountMinorUnits: number | null, currency: string | null): string {
242+
if (amountMinorUnits === null || !currency) return 'Not available';
243+
return formatCents(amountMinorUnits, currency);
244+
}
245+
246+
function stripeDashboardPrefix(): string {
247+
return process.env.NODE_ENV === 'development' ? 'test/' : '';
248+
}
249+
250+
function stripePaymentUrl(chargeId: string): string {
251+
return `https://dashboard.stripe.com/${stripeDashboardPrefix()}payments/${chargeId}`;
252+
}
253+
254+
function stripeCustomerUrl(customerId: string): string {
255+
return `https://dashboard.stripe.com/${stripeDashboardPrefix()}customers/${customerId}`;
256+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import AdminPage from '@/app/admin/components/AdminPage';
2+
import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb';
3+
import { EarlyFraudWarningsContent } from './EarlyFraudWarningsContent';
4+
5+
const breadcrumbs = (
6+
<BreadcrumbItem>
7+
<BreadcrumbPage>Early Fraud Warnings</BreadcrumbPage>
8+
</BreadcrumbItem>
9+
);
10+
11+
export default function EarlyFraudWarningsPage() {
12+
return (
13+
<AdminPage breadcrumbs={breadcrumbs}>
14+
<EarlyFraudWarningsContent />
15+
</AdminPage>
16+
);
17+
}

0 commit comments

Comments
 (0)