72c81c21e7
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>
96 lines
3.3 KiB
TypeScript
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('')
|
|
})
|
|
})
|