diff --git a/extensions/general/invoice-inbox/__tests__/purchases-route.test.ts b/extensions/general/invoice-inbox/__tests__/purchases-route.test.ts new file mode 100644 index 00000000..14424002 --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/purchases-route.test.ts @@ -0,0 +1,113 @@ +/** + * GET /purchases — the purchases the page could never show. + * + * The list has always been documents, so a purchase with no document at all + * could not appear on it. This route supplies that half, and attaches the + * portal link when we know where the supplier keeps its invoices. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import type { ExtensionContext } from '@/lib/extensions/types' + +const fetchPurchases = vi.fn() +vi.mock('@/lib/transactions/purchases-without-underlag', () => ({ + fetchPurchasesWithoutUnderlag: (...a: unknown[]) => fetchPurchases(...a), +})) + +const route = invoiceInboxExtension.apiRoutes!.find( + (r) => r.method === 'GET' && r.path === '/purchases', +)! + +function buildCtx(): ExtensionContext { + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'invoice-inbox', + supabase: {} as ExtensionContext['supabase'], + emit: vi.fn(), + settings: { get: vi.fn(), set: vi.fn() }, + storage: { from: vi.fn() } as unknown as ExtensionContext['storage'], + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'], + services: {}, + } as unknown as ExtensionContext +} + +const req = () => createMockRequest('/purchases', { method: 'GET' }) + +function purchase(over: Record = {}) { + return { + id: 'tx-1', + company_id: 'company-1', + date: '2026-07-23', + description: 'OPENAI CHATGPT SUBSCR', + merchant_name: null, + amount: -229, + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + journal_entry_id: null, + ...over, + } +} + +beforeEach(() => { + vi.clearAllMocks() + fetchPurchases.mockResolvedValue([]) +}) + +describe('GET /purchases', () => { + it('returns 401 without a context', async () => { + expect((await route.handler(req())).status).toBe(401) + }) + + it('returns the purchases with a count', async () => { + fetchPurchases.mockResolvedValue([purchase(), purchase({ id: 'tx-2' })]) + const { body } = await parseJsonResponse<{ data: { count: number; purchases: unknown[] } }>( + await route.handler(req(), buildCtx()), + ) + expect(body.data.count).toBe(2) + expect(body.data.purchases).toHaveLength(2) + }) + + it('says where the invoice lives when the supplier keeps it behind a login', async () => { + fetchPurchases.mockResolvedValue([purchase()]) + const { body } = await parseJsonResponse<{ + data: { purchases: { portal: { vendor: string; url: string } | null }[] } + }>(await route.handler(req(), buildCtx())) + expect(body.data.purchases[0].portal?.vendor).toBe('OpenAI') + expect(body.data.purchases[0].portal?.url).toContain('https://') + }) + + it('offers no portal for a payment that has no invoice', async () => { + // A salary run has nothing to fetch. A link there implies somewhere to go. + fetchPurchases.mockResolvedValue([ + purchase({ description: 'Lön Juli Jakob Överföring via internet', merchant_name: null }), + ]) + const { body } = await parseJsonResponse<{ data: { purchases: { portal: unknown }[] } }>( + await route.handler(req(), buildCtx()), + ) + expect(body.data.purchases[0].portal).toBeNull() + }) + + it('offers no portal for a supplier the directory does not know', async () => { + fetchPurchases.mockResolvedValue([ + purchase({ description: 'ALVIKS KOETT OCH FISK K3667', merchant_name: 'Alviks Kött och Fisk' }), + ]) + const { body } = await parseJsonResponse<{ data: { purchases: { portal: unknown }[] } }>( + await route.handler(req(), buildCtx()), + ) + expect(body.data.purchases[0].portal).toBeNull() + }) + + it('scopes the lookup to the caller’s company', async () => { + await route.handler(req(), buildCtx()) + expect(fetchPurchases).toHaveBeenCalledWith(expect.anything(), 'company-1') + }) + + it('reports a failure as a failure', async () => { + fetchPurchases.mockRejectedValue(new Error('boom')) + const res = await route.handler(req(), buildCtx()) + expect(res.status).toBe(500) + }) +}) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 961d0707..0db527bc 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -60,6 +60,8 @@ import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entrie import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import type { Transaction, EntityType } from '@/types' import { createLogger } from '@/lib/logger' +import { fetchPurchasesWithoutUnderlag } from '@/lib/transactions/purchases-without-underlag' +import { lookupPortal } from '@/lib/receipt-hunt/portal-directory' import { appendProcessingHistory } from '@/lib/processing-history/append' import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox' import { simpleParser } from 'mailparser' @@ -2450,6 +2452,54 @@ export const invoiceInboxExtension: Extension = { } }, }, + + // ── Purchases still missing their underlag ──────────────────── + // + // The page has always listed documents, so a purchase with no document at + // all could not appear on it. That is exactly the gap the receipt hunt + // exists to close, and the half a user can act on: fetch the invoice from + // the supplier's portal, or ask whoever made the purchase. + // + // Read-only. The predicate is shared with the hunt so the page and the + // nightly run cannot disagree about what "missing its receipt" means. + { + method: 'GET', + path: '/purchases', + handler: async (_request: Request, ctx?: ExtensionContext) => { + if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + try { + const purchases = await fetchPurchasesWithoutUnderlag(ctx.supabase, ctx.companyId) + + return NextResponse.json({ + data: { + count: purchases.length, + purchases: purchases.map((p) => { + // Where the invoice lives, when the supplier does not send one. + // lookupPortal answers null for salary and tax, which have no + // invoice to fetch: a link there would be worse than silence. + const portal = lookupPortal(p.merchant_name || p.description) + return { + id: p.id, + date: p.date, + description: p.description, + merchant_name: p.merchant_name, + amount: p.amount, + currency: p.currency, + amount_sek: p.amount_sek, + portal: portal ? { vendor: portal.vendor, url: portal.url, note: portal.note ?? null } : null, + } + }), + }, + }) + } catch (err) { + ctx.log.error('purchases lookup failed', { + error: err instanceof Error ? err.message : String(err), + }) + return NextResponse.json({ error: 'Kunde inte hämta köpen' }, { status: 500 }) + } + }, + }, ], } diff --git a/lib/transactions/__tests__/purchases-without-underlag.test.ts b/lib/transactions/__tests__/purchases-without-underlag.test.ts new file mode 100644 index 00000000..52db2889 --- /dev/null +++ b/lib/transactions/__tests__/purchases-without-underlag.test.ts @@ -0,0 +1,110 @@ +/** + * What counts as a purchase still missing its underlag. + * + * The interesting cases are the ones the column filter cannot see. A bank + * transaction can be booked three ways, and only one of them sets + * `journal_entry_id`: bulk-booking many transactions onto one verifikat records + * it in transaction_voucher_links, and a payment split across invoices records + * it in the payment tables. Both leave the column null. + * + * The receipt hunt tolerates those false candidates because the worst case is a + * proposal nobody accepts. This list is shown to a person, where the same rows + * would sit under "saknar underlag" forever, already booked, with nothing the + * user could do to clear them. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fetchPurchasesWithoutUnderlag } from '../purchases-without-underlag' + +const rows = vi.fn() +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: async () => rows(), +})) + +function tx(over: Record = {}) { + return { + id: 'tx-1', + company_id: 'company-1', + date: '2026-08-04', + description: 'Elgiganten', + merchant_name: 'Elgiganten', + amount: -21639, + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + journal_entry_id: null, + ...over, + } +} + +/** Supabase double whose link tables answer per table name. */ +function db(links: { voucher?: string[]; invoice?: string[]; supplier?: string[] } = {}) { + const calls: string[] = [] + return { + calls, + from(table: string) { + calls.push(table) + const ids = + table === 'transaction_voucher_links' + ? links.voucher + : table === 'invoice_payments' + ? links.invoice + : links.supplier + const data = (ids ?? []).map((id) => ({ transaction_id: id })) + const chain = { select: () => chain, in: async () => ({ data }) } + return chain + }, + } as never +} + +beforeEach(() => vi.clearAllMocks()) + +describe('fetchPurchasesWithoutUnderlag', () => { + it('returns purchases nothing has booked', async () => { + rows.mockReturnValue([tx()]) + const out = await fetchPurchasesWithoutUnderlag(db(), 'company-1') + expect(out).toHaveLength(1) + expect(out[0].id).toBe('tx-1') + }) + + it('skips the link-table lookups when there is nothing to check', async () => { + rows.mockReturnValue([]) + const supabase = db() + const out = await fetchPurchasesWithoutUnderlag(supabase, 'company-1') + expect(out).toEqual([]) + expect((supabase as unknown as { calls: string[] }).calls).toEqual([]) + }) + + it('excludes a transaction bulk-booked through a voucher link', async () => { + // Many transactions, one verifikat: journal_entry_id stays null on every + // one of them, so the column filter lets them all through. + rows.mockReturnValue([tx({ id: 'tx-bulk' })]) + const out = await fetchPurchasesWithoutUnderlag(db({ voucher: ['tx-bulk'] }), 'company-1') + expect(out).toEqual([]) + }) + + it('excludes a transaction that paid a customer invoice', async () => { + rows.mockReturnValue([tx({ id: 'tx-pay' })]) + const out = await fetchPurchasesWithoutUnderlag(db({ invoice: ['tx-pay'] }), 'company-1') + expect(out).toEqual([]) + }) + + it('excludes a transaction that paid a supplier invoice', async () => { + rows.mockReturnValue([tx({ id: 'tx-sup' })]) + const out = await fetchPurchasesWithoutUnderlag(db({ supplier: ['tx-sup'] }), 'company-1') + expect(out).toEqual([]) + }) + + it('keeps the unbooked ones when only some of a batch are booked', async () => { + rows.mockReturnValue([tx({ id: 'a' }), tx({ id: 'b' }), tx({ id: 'c' })]) + const out = await fetchPurchasesWithoutUnderlag(db({ voucher: ['b'] }), 'company-1') + expect(out.map((p) => p.id)).toEqual(['a', 'c']) + }) + + it('excludes a row that already carries a journal entry', async () => { + // Belt and braces: the query filters this out, but the predicate is what + // the page trusts and it must agree. + rows.mockReturnValue([tx({ journal_entry_id: 'je-1' })]) + const out = await fetchPurchasesWithoutUnderlag(db(), 'company-1') + expect(out).toEqual([]) + }) +}) diff --git a/lib/transactions/purchases-without-underlag.ts b/lib/transactions/purchases-without-underlag.ts new file mode 100644 index 00000000..9107b10b --- /dev/null +++ b/lib/transactions/purchases-without-underlag.ts @@ -0,0 +1,107 @@ +/** + * Purchases that have no underlag. + * + * The receipt hunt has asked this question since it was built, privately, in + * `fetchCandidates`. The Underlag page needs the same answer to show a purchase + * that is still missing its paper, so the query lives here now. + * + * The hunt is NOT yet migrated onto this: its query is byte-identical apart + * from the booked check below, but rewiring a nightly cron belongs in its own + * change with its own tests. The thresholds are deliberately kept equal to the + * hunt's so the two cannot drift in the meantime, and the hunt's constants are + * the source of that equality. + * + * One thing differs between the two callers, and it matters. The hunt filters on + * `journal_entry_id IS NULL` alone, which is not the same as "not booked": a + * bulk-booked transaction (many transactions, one verifikat, joined through + * transaction_voucher_links) and a payment split across several invoices both + * leave that column null. The hunt tolerates the false candidate, since the + * worst case is a proposal nobody accepts. A list shown to a person does not: + * those rows would sit under "saknar underlag" forever, already booked, with + * nothing the user could do to clear them. + * + * So the column filter stays as the cheap first pass the database can index, + * and `isTransactionBooked` settles it afterwards with the two link tables it + * needs. That predicate is canonical; nothing here re-implements it. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { isTransactionBooked } from '@/lib/transactions/is-booked' + +/** + * Kept equal to the receipt hunt's own thresholds (MIN_AMOUNT_SEK, + * LOOKBACK_MONTHS in lib/receipt-hunt/hunt.ts). A page that showed purchases + * the hunt never looks for would offer a row it can never resolve on its own. + */ +export const MIN_PURCHASE_AMOUNT_SEK = 100 +export const PURCHASE_LOOKBACK_MONTHS = 12 + +export interface PurchaseWithoutUnderlag { + id: string + company_id: string + date: string + description: string | null + merchant_name: string | null + amount: number + currency: string | null + amount_sek: number | null + exchange_rate: number | null + journal_entry_id: string | null +} + +const COLUMNS = + 'id, company_id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate, journal_entry_id' + +/** + * Outflows with no document, not ignored, not privately flagged, and not booked + * by any of the three routes a transaction can be booked through. + * + * Ordered newest first: a receipt for last week is findable, one from a year ago + * usually is not. + */ +export async function fetchPurchasesWithoutUnderlag( + supabase: SupabaseClient, + companyId: string, + options?: { lookbackMonths?: number; minAmountSek?: number }, +): Promise { + const lookback = options?.lookbackMonths ?? PURCHASE_LOOKBACK_MONTHS + const minAmount = options?.minAmountSek ?? MIN_PURCHASE_AMOUNT_SEK + + const since = new Date() + since.setMonth(since.getMonth() - lookback) + const sinceDate = since.toISOString().slice(0, 10) + + const rows = await fetchAllRows((range) => + supabase + .from('transactions') + .select(COLUMNS) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .is('document_id', null) + .eq('is_ignored', false) + // is_business IS DISTINCT FROM false: NULL is untriaged and true is + // "business, not yet booked". Only an explicit false means the user + // called it private, and a private purchase needs no underlag. + .not('is_business', 'is', false) + // Outflows only, and amount <= -MIN covers the floor in one filter. + .lte('amount', -minAmount) + .gte('date', sinceDate) + .order('date', { ascending: false }) + .range(range.from, range.to), + ) + + if (rows.length === 0) return [] + + // The column filter above cannot see these two. Fetched once for the whole + // page rather than per row. + const ids = rows.map((r) => r.id) + const [{ data: voucherLinks }, { data: invoicePayments }, { data: supplierPayments }] = + await Promise.all([ + supabase.from('transaction_voucher_links').select('transaction_id').in('transaction_id', ids), + supabase.from('invoice_payments').select('transaction_id').in('transaction_id', ids), + supabase.from('supplier_invoice_payments').select('transaction_id').in('transaction_id', ids), + ]) + + const payments = [...(invoicePayments ?? []), ...(supplierPayments ?? [])] + return rows.filter((tx) => !isTransactionBooked(tx, payments, voucherLinks ?? [])) +}