Files
accounted/lib/agent-context/__tests__/underlag-candidates.test.ts
T
MattssonandClaude Fable 5 516e8b62ff feat(inbox): match non-invoice documents via prominent amounts (#2048)
* feat(inbox): match non-invoice documents via prominent amounts

Bankintyg, bank agreements and other documentKind "other" PDFs carry no
invoice-style total, so extraction correctly left totals.total null and the
document became structurally unmatchable: findUnderlagCandidates hard-drops
items without a comparable amount and the picker lost the 40% amount signal.

- extraction: new prominentAmounts[] field (amount + document's own label),
  populated only when totals.total is null; account/org/phone/reference
  numbers and zero amounts excluded. totals.total semantics untouched.
- matching: bestProminentAmountVariance() tries each printed amount and
  feeds calculateMatchConfidence at reduced weight (0.3 vs 0.4) in both the
  agent candidate scorer and TransactionMatchPicker.
- UI: inbox rail shows the detected amounts read-only for such documents,
  list falls back to a single distinct prominent amount, and extraction no
  longer reads as "found nothing".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(inbox): discount prominent-amount fallback instead of reweighting it

Skeptic pass refutations on the first commit: normalized weighting made a
reduced amount weight self-defeating. Date + exact fallback amount with no
merchant scored (0.25+0.3)/0.55 = 1.0 ("100% sakerhet" on a wrong same-day
transaction), and a DISAGREEING fallback amount scored above a disagreeing
invoice total (0.67 vs 0.60) because shrinking the weight also shrank the
penalty.

- score fallbacks at full amount weight, then multiply by a flat
  FALLBACK_CONFIDENCE_FACTOR (0.85): agreement caps below certainty,
  disagreement stays at least as damning as for a real total.
- agent candidate surface additionally requires the document date within
  DATE_TOLERANCE_DAYS, so an avtal listing 349 kr no longer matches every
  future 349 kr charge from the same counterparty.
- bestProminentAmountVariance returns which amount matched + its document
  label, and the match reason names it ("Exakt belopp i dokumentet: 2 500
  SEK (Engangspris)"): no more bare "Exakt belopp" reaching the agent while
  total_amount is null.
- prompt: prominentAmounts restricted to non-invoice documentKinds, and
  never a parking spot for an unreadable invoice total.
- fix the stale "deliberately the same list" comment on
  EXTRACTED_FIELD_ACCESSORS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(receipt-hunt): never propose on the prominent-amounts fallback

Second skeptic pass: the nightly hunt is a third consumer of
scoreUnderlagCandidates and inherited the fallback unaware. A bankintyg
whose printed "Insatt belopp" equals a same-day outflow scores 0.85, which
clears CERTAIN_CONFIDENCE (0.8) and skips LLM adjudication, on a pairing
wrong by construction (the hunt scans outflows only; "Insatt belopp"
labels an inflow), with document_amount null in the approval preview.

UnderlagCandidate now carries amountSource ('total' | 'prominent') and
selectProposals drops fallback-scored candidates. Non-invoice documents
stay reachable through the manual picker and the agent candidate surface,
both of which have a human reading the amounts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(inbox): round fallback confidence via roundOre, not the naive pattern

The two confidence discounts (and their test) tripped the naive-ore-round
antipattern ratchet (625 vs baseline 622); use the sanctioned helper.

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-30 23:52:29 +02:00

262 lines
8.6 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 }[]
}): 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,
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('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([])
})
})