diff --git a/app/api/receipt-hunt/run/__tests__/route.test.ts b/app/api/receipt-hunt/run/__tests__/route.test.ts new file mode 100644 index 00000000..fedefd40 --- /dev/null +++ b/app/api/receipt-hunt/run/__tests__/route.test.ts @@ -0,0 +1,132 @@ +/** + * The button's route. What matters is that it cannot run for someone who is not + * signed in, cannot run without the tier that reads PDFs, and reports enough + * for a person to decide whether to press again. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const mockHuntCompany = vi.fn() +vi.mock('@/lib/receipt-hunt/hunt', () => ({ + huntCompany: (...args: unknown[]) => mockHuntCompany(...args), +})) + +const mockRequireCapability = vi.fn() +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: (...args: unknown[]) => mockRequireCapability(...args), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => ({}), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const context = { + requestId: 'req-1', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => context.log) }, + user: { id: 'user-1' }, + supabase: {}, + companyId: 'co-1', +} + +let unauthorized = false +vi.mock('@/lib/api/with-route-context', () => ({ + withRouteContext: (_op: string, handler: (req: unknown, ctx: unknown) => unknown) => { + return async (req: unknown) => { + if (unauthorized) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + } + return handler(req, context) + } + }, +})) + +import { POST } from '../route' + +beforeEach(() => { + vi.clearAllMocks() + unauthorized = false + mockRequireCapability.mockResolvedValue(null) + mockHuntCompany.mockResolvedValue({ + companyId: 'co-1', + candidates: 20, + poolSize: 5, + proposed: 2, + mail: { searched: 8, withCandidates: 3, ingested: 3, candidates: [] }, + }) +}) + +describe('POST /api/receipt-hunt/run', () => { + it('refuses an unauthenticated caller', async () => { + unauthorized = true + const response = await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + expect(response.status).toBe(401) + expect(mockHuntCompany).not.toHaveBeenCalled() + }) + + it('refuses a company without the tier that reads PDFs', async () => { + // Fetching receipts nobody can extract an amount from would file documents + // that can never pair: worse than not running. + mockRequireCapability.mockResolvedValue( + new Response(JSON.stringify({ error: 'capability_blocked' }), { status: 402 }), + ) + const response = await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + expect(response.status).toBe(402) + expect(mockHuntCompany).not.toHaveBeenCalled() + }) + + it('searches the mailboxes, which the nightly run still does not', async () => { + await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + const [, , , options] = mockHuntCompany.mock.calls[0] + expect(options.searchMail).toBe(true) + }) + + it('bounds the pass so one press cannot run past the function timeout', async () => { + await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + const [, , , options] = mockHuntCompany.mock.calls[0] + expect(options.mailSearchLimit).toBeGreaterThan(0) + expect(options.maxReceipts).toBeGreaterThan(0) + }) + + it('reports what is left, so pressing again is an informed choice', async () => { + const response = await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + const { body } = await parseJsonResponse<{ + data: { searched: number; fetched: number; proposed: number; remaining: number } + }>(response) + + expect(body.data).toMatchObject({ searched: 8, fetched: 3, proposed: 2 }) + // 20 purchases without a receipt, 8 looked at. + expect(body.data.remaining).toBe(12) + }) + + it('never reports negative work remaining', async () => { + mockHuntCompany.mockResolvedValue({ + companyId: 'co-1', + candidates: 3, + poolSize: 0, + proposed: 0, + mail: { searched: 8, withCandidates: 0, ingested: 0, candidates: [] }, + }) + const response = await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + const { body } = await parseJsonResponse<{ data: { remaining: number } }>(response) + expect(body.data.remaining).toBe(0) + }) + + it('survives a company with no mailbox connected', async () => { + // getMailSearchService falls back to a no-op, so the mail leg is absent + // rather than failing. + mockHuntCompany.mockResolvedValue({ + companyId: 'co-1', + candidates: 4, + poolSize: 2, + proposed: 1, + }) + const response = await POST(createMockRequest('http://localhost/api/receipt-hunt/run'), undefined as never) + const { body } = await parseJsonResponse<{ data: { searched: number; fetched: number } }>( + response, + ) + expect(response.status).toBe(200) + expect(body.data).toMatchObject({ searched: 0, fetched: 0 }) + }) +}) diff --git a/app/api/receipt-hunt/run/route.ts b/app/api/receipt-hunt/run/route.ts new file mode 100644 index 00000000..aaf354a2 --- /dev/null +++ b/app/api/receipt-hunt/run/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { createServiceClient } from '@/lib/supabase/server' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { huntCompany } from '@/lib/receipt-hunt/hunt' + +// The hunt uploads documents, and uploading emits document.uploaded, which is +// what makes the extraction extension read the amount out of a fetched PDF. +// Without this the receipts land with no amount and can never be paired. +ensureInitialized() + +/** + * A run of the hunt that a person asked for. + * + * The nightly cron exists but is deliberately not searching mailboxes yet: a + * sweep of one real 172-message mailbox took over 600s, and a scheduled + * function has 300. Pressing a button is the honest shape for that. A bounded + * pass reports what it found and what is left, and the person decides whether + * to press again, which a silent nightly truncation could never do. + * + * Writes no journal entries. Every pairing becomes an + * `attach_document_to_transaction` proposal that still waits for approval. + */ + +/** + * Purchases whose mailboxes are searched per press. + * + * Purchases are searched largest first, and on a real ledger the largest rows + * are the least likely to have a findable receipt: rent already invoiced, + * bare payment references, direct debits. A press that only reaches the top of + * that list finds nothing and looks broken. Measured: 8 purchases and 40 mails + * took 43s of the 300 available, so the budget was being spent on the wrong + * end rather than being scarce. + */ +const PURCHASES_PER_RUN = 25 + +/** Mails read per press. The real cost bound, and what the numbers above buy. */ +const MAILS_PER_RUN = 100 + +/** + * Receipts fetched per press. + * + * The binding constraint on the whole route, and it is the model reading the + * PDF rather than the network: measured on a real ledger, a fetched receipt + * costs about 50s from download to a stored amount, while searching and + * reading a hundred mail bodies costs roughly 100s in total. Seven receipts + * took 5.8 minutes and four took 5.1, both past the 300s a function gets. + * + * Three is what fits. It is also a stopgap: doing the fetch inside the request + * is the wrong shape for work this slow, and the honest fix is to move it off + * the request entirely rather than keep shaving this number. + */ +const RECEIPTS_PER_RUN = 3 + +export const maxDuration = 300 + +export const POST = withRouteContext('receipt_hunt.run', async (_request, ctx) => { + const { companyId, user, log } = ctx + + // Reading a PDF is what turns a fetched attachment into something matchable, + // and that is the paid AI tier. Without it the hunt would file documents that + // can never pair, which is worse than not running. + const blocked = await requireCapability(ctx.supabase, companyId, CAPABILITY.ai) + if (blocked) return blocked + + // Service role: the hunt reads mail_connections, whose RLS has no policies + // precisely so a browser session can never select a refresh token. + const supabase = createServiceClient() + const runId = crypto.randomUUID() + + log.info('manual receipt hunt starting', { companyId, runId, userId: user.id }) + + const result = await huntCompany(supabase, companyId, runId, { + searchMail: true, + mailSearchLimit: PURCHASES_PER_RUN, + maxMails: MAILS_PER_RUN, + maxReceipts: RECEIPTS_PER_RUN, + }) + + const searched = result.mail?.searched ?? 0 + log.info('manual receipt hunt finished', { + companyId, + runId, + searched, + fetched: result.mail?.ingested ?? 0, + proposed: result.proposed, + }) + + return NextResponse.json({ + data: { + // What the person needs to decide whether to press again. + purchasesWithoutReceipt: result.candidates, + searched, + fetched: result.mail?.ingested ?? 0, + proposed: result.proposed, + remaining: Math.max(0, result.candidates - searched), + }, + }) +}) diff --git a/components/extensions/general/MailConnectionsPanel.tsx b/components/extensions/general/MailConnectionsPanel.tsx index 03f02688..8d86bf12 100644 --- a/components/extensions/general/MailConnectionsPanel.tsx +++ b/components/extensions/general/MailConnectionsPanel.tsx @@ -15,6 +15,14 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks' import { formatDateLong } from '@/lib/utils' +interface HuntResult { + searched: number + fetched: number + proposed: number + remaining: number + failed?: boolean +} + interface MailConnection { id: string provider: 'gmail' | 'microsoft' @@ -34,6 +42,8 @@ export function MailConnectionsPanel() { const [loading, setLoading] = useState(true) const [connecting, setConnecting] = useState(false) const [pendingDisconnect, setPendingDisconnect] = useState(null) + const [hunting, setHunting] = useState(false) + const [huntResult, setHuntResult] = useState(null) const load = useCallback(async () => { try { @@ -73,6 +83,32 @@ export function MailConnectionsPanel() { } } + /** + * One bounded pass, on request. + * + * Deliberately not a background job: a sweep of a real mailbox runs longer + * than a serverless function may live, so the honest shape is a pass that + * ends, says what it found and what is left, and can be pressed again. + */ + async function hunt() { + setHunting(true) + setHuntResult(null) + try { + const response = await fetch('/api/receipt-hunt/run', { method: 'POST' }) + if (!response.ok) { + setHuntResult({ searched: 0, fetched: 0, proposed: 0, remaining: 0, failed: true }) + return + } + const body = (await response.json()) as { data: HuntResult } + setHuntResult(body.data) + void load() + } catch { + setHuntResult({ searched: 0, fetched: 0, proposed: 0, remaining: 0, failed: true }) + } finally { + setHunting(false) + } + } + async function disconnect(connection: MailConnection) { await fetch(`${BASE}/connections?id=${encodeURIComponent(connection.id)}`, { method: 'DELETE' }) setPendingDisconnect(null) @@ -122,6 +158,28 @@ export function MailConnectionsPanel() { )} + {connections.length > 0 ? ( + + + + {huntResult ? ( + + {huntResult.failed + ? t('hunt_failed') + : huntResult.fetched > 0 + ? t('hunt_found', { count: huntResult.fetched, left: huntResult.remaining }) + : t('hunt_none', { left: huntResult.remaining })} + + ) : null} + + + + + ) : null} +