Files
accounted/lib/agent-context/__tests__/underlag-candidates.test.ts
T
MattssonandClaude Fable 5 1a3686dd45 feat(inbox): promote a single prominent amount into the editable total (#2073)
* 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>
2026-08-31 11:40:30 +02:00

315 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([])
})
})