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 { 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([]) }) })