Files
accounted/lib/agent-context/__tests__/underlag-candidates.test.ts
T
43386b4852 feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its
backfill-by-document_id: a user photographs a receipt into WhatsApp, answers
the bot's questions, then opens the app, clicks the bank transaction and asks
the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we
are holding, then asked everything again.

WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id
(process-inbound.ts passes uploadAndExtract's matchedTransactionId as
undefined) nor transactions.document_id (that mirror is written by the manual
match route). Only TransactionMatchPicker fills either column. So the underlag
list comes back empty, and a backfill that keys on document_id has nothing to
key on.

Unmatched, unconsumed inbox items are now scored against the transaction with
the same pure scorer the picker uses and the strongest few are surfaced as
TROLIGT UNDERLAG, carrying their captured chat answers.

Proposals only: nothing writes matched_transaction_id, and the prompt tells
the agent to get the match confirmed and to book only against a confirmed one.
Setting the link at intake above a confidence bar is the obvious alternative
and is deliberately left open.

An uncomparable amount (cross-currency with no rate) disqualifies a candidate,
because calculateMatchConfidence drops the amount signal in that case and date
+ merchant alone then score a confident match nobody checked the sums for.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:07:30 +02:00

155 lines
4.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
CANDIDATE_MIN_CONFIDENCE,
scoreUnderlagCandidates,
} from '../underlag-candidates'
import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
function ctx(partial: Partial<InboxChannelContext>): InboxChannelContext {
return { channel: 'whatsapp', ...partial }
}
function extraction(partial: {
supplier?: string | null
date?: string | null
total?: number | null
vat?: number | null
currency?: string
}): InvoiceExtractionResult {
return {
supplier: {
name: partial.supplier ?? null,
orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null,
},
invoice: {
invoiceNumber: null, invoiceDate: partial.date ?? null, dueDate: null,
paymentReference: null, currency: partial.currency ?? 'SEK',
},
lineItems: [],
totals: { subtotal: null, vatAmount: partial.vat ?? null, total: partial.total ?? null },
vatBreakdown: [],
confidence: 0.9,
} as InvoiceExtractionResult
}
describe('scoreUnderlagCandidates', () => {
const tx = {
id: 'tx-1',
date: '2026-05-12',
description: 'ESPRESSO HOUSE 1234 STOCKHOLM',
merchant_name: 'Espresso House',
amount: -184,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
}
it('surfaces an unmatched WhatsApp receipt that matches the transaction', () => {
// The reported bug: this item is real, sitting in the inbox, and invisible
// to every lookup because matched_transaction_id is NULL.
const out = scoreUnderlagCandidates(tx, [
{
id: 'item-1',
document_id: 'doc-1',
extracted_data: extraction({
supplier: 'Espresso House',
date: '2026-05-12',
total: 184,
}),
channel_context: ctx({
representation: {
participants: [{ name: 'Anna Berg', company: 'Volvo' }],
purpose: 'kundmöte',
event_date: null,
raw_answer: 'jag och Anna',
answered_at: '2026-05-12T13:00:00Z',
},
}),
},
])
expect(out).toHaveLength(1)
expect(out[0].inbox_item_id).toBe('item-1')
expect(out[0].confidence).toBeGreaterThanOrEqual(CANDIDATE_MIN_CONFIDENCE)
// and it brings the captured answers along with it
expect(out[0].channelContext?.representation?.purpose).toBe('kundmöte')
})
it('rejects a same-amount receipt from a different month', () => {
const out = scoreUnderlagCandidates(tx, [
{
id: 'item-2',
document_id: 'doc-2',
extracted_data: extraction({ supplier: 'Okänd', date: '2026-01-02', total: 184 }),
channel_context: null,
},
])
expect(out).toEqual([])
})
it('does not propose a receipt whose amount cannot be compared', () => {
// 184 EUR is not 184 SEK, and with no stored rate the two are not
// comparable at all. Without an amount signal the score would rest on date
// + merchant alone and come back as a confident match, so an uncomparable
// amount disqualifies the candidate outright. Same-merchant same-day is a
// ranking hint for a human, not evidence of the same economic event.
const out = scoreUnderlagCandidates(tx, [
{
id: 'item-3',
document_id: 'doc-3',
extracted_data: extraction({
supplier: 'Espresso House',
date: '2026-05-12',
total: 184,
currency: 'EUR',
}),
channel_context: null,
},
])
expect(out).toEqual([])
})
it('skips extractions with no usable signal', () => {
const out = scoreUnderlagCandidates(tx, [
{
id: 'item-4',
document_id: 'doc-4',
extracted_data: extraction({ supplier: 'Espresso House' }),
channel_context: null,
},
])
expect(out).toEqual([])
})
it('returns the strongest candidates first', () => {
const out = scoreUnderlagCandidates(tx, [
{
id: 'weak',
document_id: 'doc-w',
extracted_data: extraction({ supplier: null, date: '2026-05-13', total: 184 }),
channel_context: null,
},
{
id: 'strong',
document_id: 'doc-s',
extracted_data: extraction({ supplier: 'Espresso House', date: '2026-05-12', total: 184 }),
channel_context: null,
},
])
expect(out[0].inbox_item_id).toBe('strong')
})
it('returns nothing for a transaction with no date or amount', () => {
expect(
scoreUnderlagCandidates({ ...tx, date: null }, [
{
id: 'item-5',
document_id: 'doc-5',
extracted_data: extraction({ supplier: 'Espresso House', date: '2026-05-12', total: 184 }),
channel_context: null,
},
]),
).toEqual([])
})
})