* feat(inbox): promote a single prominent amount into the editable total Follow-up to #2048 after founder review: the Belopp row was load-bearing for matching but read-only, so a misread amount could not be corrected, and an empty TOTALT still read as "extraction failed". - promoteSingleProminentAmount (extraction post-step, all intake paths): documentKind other/government_letter with no total and exactly one distinct nonzero prominent amount gets it copied into totals.total, stamped totalSource: 'prominent'. Multi-amount documents are left alone: picking one silently would invent a total. - provenance keeps the safety rails: matching demotes a promoted total back through the prominent-amounts fallback (0.85 discount, date guard, amountSource tag), so the nightly receipt-hunt still excludes these documents and confidence never presents as certainty. - the fields-PATCH route clears totalSource when a human edits TOTALT: a user-set amount is a verified total at full weight. - the read-only Belopp row now renders only for multi-amount documents, and filters zero amounts ("Totalt manadspris: 0 kr" noise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): review pass: concurrency-safe fields PATCH, zero-amount predicate CodeRabbit findings on #2073: - the fields-PATCH read-merge-write could let a racing autosave restore a stale extracted_data blob (including a totalSource stamp a concurrent TOTALT edit had just cleared). The update is now conditional on the trigger-maintained updated_at; zero rows matched returns 409 and the client's next debounced save re-reads. - hasAnyExtractedField now uses the same meaningful-amount predicate as the Belopp render filter, so a zero-only prominentAmounts list no longer suppresses the retry / upgrade affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
315 lines
10 KiB
TypeScript
315 lines
10 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
|
||
prominentAmounts?: { amount: number; label: string | null }[]
|
||
totalSource?: 'prominent' | null
|
||
}): 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: [],
|
||
prominentAmounts: partial.prominentAmounts,
|
||
totalSource: partial.totalSource,
|
||
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)
|
||
expect(out[0].amountSource).toBe('total')
|
||
// 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('proposes a non-invoice document via its prominent amounts', () => {
|
||
// The Robotministeriet case: an SEB account agreement (documentKind
|
||
// "other") has no "Att betala" total, only "Anslutnings-/Engångspris
|
||
// 2 500". The bank charges AVGIFT -2500 the same day. Before the
|
||
// prominentAmounts fallback this document was structurally unmatchable.
|
||
const avgiftTx = {
|
||
...tx,
|
||
description: 'AVGIFT',
|
||
merchant_name: null,
|
||
amount: -2500,
|
||
date: '2026-08-26',
|
||
}
|
||
const out = scoreUnderlagCandidates(avgiftTx, [
|
||
{
|
||
id: 'item-avtal',
|
||
document_id: 'doc-avtal',
|
||
extracted_data: extraction({
|
||
supplier: 'SEB',
|
||
date: '2026-08-26',
|
||
total: null,
|
||
prominentAmounts: [
|
||
{ amount: 2500, label: 'Anslutnings-/Engångspris' },
|
||
],
|
||
}),
|
||
channel_context: null,
|
||
},
|
||
])
|
||
expect(out).toHaveLength(1)
|
||
expect(out[0].inbox_item_id).toBe('item-avtal')
|
||
expect(out[0].confidence).toBeGreaterThanOrEqual(CANDIDATE_MIN_CONFIDENCE)
|
||
// ...but never as certainty: a printed figure is not an invoice total.
|
||
expect(out[0].confidence).toBeLessThan(1)
|
||
// Tagged so low-scrutiny consumers (the nightly hunt) can exclude it.
|
||
expect(out[0].amountSource).toBe('prominent')
|
||
// The reason names WHICH figure matched, since total_amount stays null.
|
||
expect(out[0].total_amount).toBeNull()
|
||
// toLocaleString('sv-SE') groups with a non-breaking space (U+00A0).
|
||
expect(out[0].matchReasons.join(' ')).toContain(`2${' '}500`)
|
||
expect(out[0].matchReasons.join(' ')).toContain('Anslutnings-/Engångspris')
|
||
})
|
||
|
||
it('scores a promoted total (totalSource prominent) as fallback-grade, not invoice-grade', () => {
|
||
// promoteSingleProminentAmount fills TOTALT for the UI, but matching must
|
||
// not mistake that for an invoice total: same discount, same tagging.
|
||
const out = scoreUnderlagCandidates(
|
||
{ ...tx, description: 'AVGIFT', merchant_name: null, amount: -2500, date: '2026-08-26' },
|
||
[
|
||
{
|
||
id: 'item-promoted',
|
||
document_id: 'doc-promoted',
|
||
extracted_data: extraction({
|
||
supplier: 'SEB',
|
||
date: '2026-08-26',
|
||
total: 2500,
|
||
totalSource: 'prominent',
|
||
prominentAmounts: [{ amount: 2500, label: 'Anslutnings-/Engångspris' }],
|
||
}),
|
||
channel_context: null,
|
||
},
|
||
],
|
||
)
|
||
expect(out).toHaveLength(1)
|
||
expect(out[0].confidence).toBeLessThan(1)
|
||
expect(out[0].amountSource).toBe('prominent')
|
||
})
|
||
|
||
it('scores a user-corrected total at full weight', () => {
|
||
// The fields-PATCH route clears totalSource when a human edits TOTALT:
|
||
// from then on the amount is a verified total, no discount.
|
||
const out = scoreUnderlagCandidates(
|
||
{ ...tx, description: 'AVGIFT', merchant_name: null, amount: -2500, date: '2026-08-26' },
|
||
[
|
||
{
|
||
id: 'item-corrected',
|
||
document_id: 'doc-corrected',
|
||
extracted_data: extraction({
|
||
supplier: 'SEB',
|
||
date: '2026-08-26',
|
||
total: 2500,
|
||
totalSource: null,
|
||
prominentAmounts: [{ amount: 9999, label: 'Fel belopp' }],
|
||
}),
|
||
channel_context: null,
|
||
},
|
||
],
|
||
)
|
||
expect(out).toHaveLength(1)
|
||
expect(out[0].confidence).toBe(1)
|
||
expect(out[0].amountSource).toBe('total')
|
||
expect(out[0].total_amount).toBe(2500)
|
||
})
|
||
|
||
it('does not let a prominent amount alone carry a dateless document over the floor', () => {
|
||
// Amount agreement without a date is weaker than a total + date pair; the
|
||
// candidate surface trades recall for precision, so this stays in the
|
||
// manual picker only.
|
||
const out = scoreUnderlagCandidates({ ...tx, amount: -25000 }, [
|
||
{
|
||
id: 'item-intyg',
|
||
document_id: 'doc-intyg',
|
||
extracted_data: extraction({
|
||
supplier: 'SEB',
|
||
date: null,
|
||
total: null,
|
||
prominentAmounts: [{ amount: 25000, label: 'Insatt belopp' }],
|
||
}),
|
||
channel_context: null,
|
||
},
|
||
])
|
||
expect(out).toEqual([])
|
||
})
|
||
|
||
it('does not match an avtal to a later charge on amount + merchant alone', () => {
|
||
// A Telia avtal listing 349 kr must not surface for every future 349 kr
|
||
// Telia charge: the fallback requires the document date to agree within
|
||
// the normal tolerance.
|
||
const out = scoreUnderlagCandidates(
|
||
{ ...tx, description: 'TELIA SVERIGE AB', merchant_name: 'Telia Sverige AB', amount: -349 },
|
||
[
|
||
{
|
||
id: 'item-telia',
|
||
document_id: 'doc-telia',
|
||
extracted_data: extraction({
|
||
supplier: 'Telia Sverige AB',
|
||
date: '2026-01-15',
|
||
total: null,
|
||
prominentAmounts: [{ amount: 349, label: 'Månadspris' }],
|
||
}),
|
||
channel_context: null,
|
||
},
|
||
],
|
||
)
|
||
expect(out).toEqual([])
|
||
})
|
||
|
||
it('rejects a non-invoice document whose prominent amounts all disagree', () => {
|
||
// Same-day, same merchant, but the printed amounts match nothing: the
|
||
// discount keeps this under the floor, where a real disagreeing invoice
|
||
// total would sit exactly at it.
|
||
const out = scoreUnderlagCandidates(tx, [
|
||
{
|
||
id: 'item-wrong',
|
||
document_id: 'doc-wrong',
|
||
extracted_data: extraction({
|
||
supplier: 'Espresso House',
|
||
date: '2026-05-12',
|
||
total: null,
|
||
prominentAmounts: [{ amount: 9999, label: 'Pris' }],
|
||
}),
|
||
channel_context: null,
|
||
},
|
||
])
|
||
expect(out).toEqual([])
|
||
})
|
||
|
||
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([])
|
||
})
|
||
})
|