Files
accounted/lib/agent/categorize/__tests__/underlag.test.ts
T
Jakob Wennberg 72c81c21e7 feat(categorize): feed the selector the underlag, not just the bank line (#1785)
The highest-leverage quality lever for real users. A prod read showed the
majority are cold-start (365 companies, 32.7k unbooked transactions, median 0
counterparty templates), so the LLM selector carries them — and it was only
seeing the bank line (merchant + amount), never the receipt.

- lib/agent/categorize/underlag.ts: gathers the matched receipt/invoice text
  for a transaction (receipts.matched_transaction_id + invoice_inbox_items
  .matched_transaction_id + the transaction's own attached document) and renders
  it as bounded Swedish text — supplier, date, total, moms, line items. Same
  sources the categorization intent reads, as a string not a tool loop. Core
  queries the tables directly (no @/extensions import). Best-effort: '' on any
  failure.
- POST /api/agent/categorize gathers it server-side when the caller didn't
  supply `underlag`, so the model reasons over the actual supplier + line items.

Server-side only, no client change. 31 categorize tests green; lint + guards +
scoped typecheck clean.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 16:00:43 +02:00

96 lines
3.3 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { gatherUnderlag } from '../underlag'
function makeSupabase(opts: {
receipts?: unknown[]
inbox?: unknown[]
doc?: unknown | null
throwOn?: string
}): SupabaseClient {
return {
from(table: string) {
const rejects = opts.throwOn === table
const chain = {
select: () => chain,
eq: () => chain,
maybeSingle: async () => {
if (rejects) throw new Error('db down')
return { data: table === 'document_attachments' ? (opts.doc ?? null) : null }
},
// The awaited list query resolves with rows — or rejects async, exactly
// how a real supabase query fails (never a synchronous throw from .from).
then: (resolve: (v: { data: unknown[] }) => unknown, reject: (e: unknown) => unknown) => {
if (rejects) return reject(new Error('db down'))
return resolve({
data:
table === 'receipts' ? (opts.receipts ?? []) : table === 'invoice_inbox_items' ? (opts.inbox ?? []) : [],
})
},
}
return chain
},
} as unknown as SupabaseClient
}
describe('gatherUnderlag', () => {
it('renders a matched receipt with amount, VAT and flags', async () => {
const supabase = makeSupabase({
receipts: [
{
merchant_name: 'Biltema',
receipt_date: '2026-08-12',
total_amount: 499,
vat_amount: 99.8,
currency: 'SEK',
is_restaurant: false,
is_systembolaget: false,
},
],
})
const out = await gatherUnderlag(supabase, 'c1', 't1')
expect(out).toContain('Kvitto: Biltema')
expect(out).toContain('2026-08-12')
expect(out).toContain('totalt 499 SEK')
expect(out).toContain('moms 99.8 SEK')
})
it('renders an inbox invoice with supplier + line items', async () => {
const supabase = makeSupabase({
inbox: [
{
extracted_data: {
supplier: { name: 'Vercel Inc' },
invoice: { invoiceDate: '2026-08-01', currency: 'USD' },
totals: { total: 20, vatAmount: 0 },
lineItems: [{ description: 'Pro plan' }, { description: 'Bandwidth' }],
},
},
],
})
const out = await gatherUnderlag(supabase, 'c1', 't1')
expect(out).toContain('leverantör Vercel Inc')
expect(out).toContain('totalt 20 USD')
expect(out).toContain('Rader: "Pro plan"; "Bandwidth"')
})
it('renders the transaction attached document when a documentId is given', async () => {
const supabase = makeSupabase({
doc: { extracted_data: { supplier: { name: 'Telia' }, invoice: { currency: 'SEK' }, totals: { total: 349 } } },
})
const out = await gatherUnderlag(supabase, 'c1', 't1', 'doc-9')
expect(out).toContain('Bifogat underlag: leverantör Telia')
expect(out).toContain('totalt 349 SEK')
})
it('returns empty string when there is no underlag', async () => {
const out = await gatherUnderlag(makeSupabase({}), 'c1', 't1')
expect(out).toBe('')
})
it('is best-effort: a failing query yields empty string, not a throw', async () => {
const out = await gatherUnderlag(makeSupabase({ throwOn: 'receipts' }), 'c1', 't1')
expect(out).toBe('')
})
})