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>
155 lines
4.7 KiB
TypeScript
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([])
|
|
})
|
|
})
|