Files
accounted/lib/documents/__tests__/core-receipt-matcher.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

322 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
FALLBACK_CONFIDENCE_FACTOR,
levenshteinDistance,
normalizeMerchantName,
normalizeForMatch,
calculateMerchantSimilarity,
calculateMatchConfidence,
amountVarianceForMatch,
bestProminentAmountVariance,
} from '../core-receipt-matcher'
import { roundOre } from '@/lib/money'
describe('levenshteinDistance', () => {
it('returns 0 for identical strings', () => {
expect(levenshteinDistance('abc', 'abc')).toBe(0)
})
it('returns length of other string for empty string', () => {
expect(levenshteinDistance('', 'abc')).toBe(3)
expect(levenshteinDistance('abc', '')).toBe(3)
})
it('calculates correct edit distance', () => {
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
expect(levenshteinDistance('saturday', 'sunday')).toBe(3)
})
})
describe('normalizeMerchantName', () => {
it('lowercases and trims', () => {
expect(normalizeMerchantName(' ICA MAXI ')).toBe('ica maxi')
})
it('removes Swedish company suffixes', () => {
expect(normalizeMerchantName('Telia AB')).toBe('telia')
})
it('removes special characters but keeps Swedish letters', () => {
expect(normalizeMerchantName('Café Överkås!')).toBe('café överkås')
})
it('collapses whitespace', () => {
expect(normalizeMerchantName('ica maxi stockholm')).toBe('ica maxi stockholm')
})
})
describe('calculateMerchantSimilarity', () => {
it('returns 1 for exact match', () => {
expect(calculateMerchantSimilarity('ICA Maxi', 'ICA Maxi')).toBe(1)
})
it('returns 1 for match after normalization', () => {
expect(calculateMerchantSimilarity('Telia AB', 'telia')).toBe(1)
})
it('returns 0.9 when one contains the other', () => {
expect(calculateMerchantSimilarity('ICA', 'ICA MAXI STOCKHOLM')).toBe(0.9)
})
it('returns 0 for empty strings', () => {
expect(calculateMerchantSimilarity('', 'abc')).toBe(0)
expect(calculateMerchantSimilarity('abc', '')).toBe(0)
})
it('returns score between 0 and 1 for partial matches', () => {
const score = calculateMerchantSimilarity('ICA Maxi', 'Coop Forum')
expect(score).toBeGreaterThanOrEqual(0)
expect(score).toBeLessThanOrEqual(1)
})
it('gives high score for word overlap', () => {
const score = calculateMerchantSimilarity('ICA Maxi Stockholm', 'ICA Maxi Solna')
expect(score).toBeGreaterThan(0.7)
})
})
describe('calculateMatchConfidence', () => {
it('gives high confidence for exact date + amount + merchant', () => {
const { confidence, matchReasons } = calculateMatchConfidence(0, 0, 1.0)
expect(confidence).toBeGreaterThan(0.9)
expect(matchReasons).toContain('Exakt datum')
expect(matchReasons).toContain('Exakt belopp')
expect(matchReasons).toContain('Handlare matchar')
})
it('gives lower confidence when date is off', () => {
const exact = calculateMatchConfidence(0, 0, 1.0)
const dateOff = calculateMatchConfidence(2, 0, 1.0)
expect(dateOff.confidence).toBeLessThan(exact.confidence)
})
it('gives lower confidence when amount is off', () => {
const exact = calculateMatchConfidence(0, 0, 1.0)
const amountOff = calculateMatchConfidence(0, 0.03, 1.0)
expect(amountOff.confidence).toBeLessThan(exact.confidence)
})
it('gives lower confidence with no merchant similarity when other signals are imperfect', () => {
// With imperfect date/amount, missing merchant signal lowers overall confidence
const withMerchant = calculateMatchConfidence(1, 0.02, 0.8)
const noMerchant = calculateMatchConfidence(1, 0.02, 0)
expect(noMerchant.confidence).toBeLessThan(withMerchant.confidence)
})
it('respects custom tolerances', () => {
// With wider tolerance, same variance should give higher score
const narrow = calculateMatchConfidence(2, 0.03, 0.5, 3, 0.05)
const wide = calculateMatchConfidence(2, 0.03, 0.5, 7, 0.10)
expect(wide.confidence).toBeGreaterThan(narrow.confidence)
})
it('drops the amount signal when amountVariance is null (cross-currency)', () => {
// A null variance means the amounts could not be compared across
// currencies. Confidence must rely on date + merchant only, never reward
// a coincidental same-number match (750 EUR vs 750 SEK).
const { confidence, matchReasons } = calculateMatchConfidence(0, null, 1.0)
// Date (1.0) + merchant (1.0) both perfect, amount excluded → still ~1.0,
// but no amount reason is emitted.
expect(confidence).toBeGreaterThan(0.9)
expect(matchReasons).not.toContain('Exakt belopp')
expect(matchReasons).toContain('Exakt datum')
expect(matchReasons).toContain('Handlare matchar')
})
it('does not let a coincidental number reward a cross-currency mismatch', () => {
// Same date, no merchant signal. With a real 0 amountVariance the score is
// high; with null (uncomparable currencies) it must fall back to date only.
const sameNumber = calculateMatchConfidence(5, 0, 0, 120)
const uncomparable = calculateMatchConfidence(5, null, 0, 120)
expect(uncomparable.confidence).toBeLessThan(sameNumber.confidence)
})
})
describe('amountVarianceForMatch', () => {
it('compares raw magnitudes for same-currency rows (expense sign-agnostic)', () => {
// 750 EUR underlag vs a -750 EUR bank expense → exact.
expect(amountVarianceForMatch(750, 'EUR', null, -750, 'EUR', -8625)).toBe(0)
})
it('does NOT match 750 EUR against 750 SEK (the reported bug)', () => {
// No FX rate (receiptSek null) and different currencies → not comparable,
// so the amount signal is dropped rather than rewarding the coincidence.
expect(amountVarianceForMatch(750, 'EUR', null, -750, 'SEK', -750)).toBeNull()
})
it('normalises to SEK when a rate is available and matches the equivalent charge', () => {
// 750 EUR ≈ 8625 SEK (rate 11.5). A -8505 SEK bank charge is ~1.4% off.
const v = amountVarianceForMatch(750, 'EUR', 8625, -8505, 'SEK', -8505)
expect(v).not.toBeNull()
expect(v!).toBeLessThan(0.05)
})
it('flags a real SEK mismatch as a large variance', () => {
const v = amountVarianceForMatch(750, 'EUR', 8625, -500, 'SEK', -500)
expect(v!).toBeGreaterThan(0.05)
})
it('returns null when there is no underlag total or it is zero', () => {
expect(amountVarianceForMatch(null, 'SEK', null, -100, 'SEK', -100)).toBeNull()
expect(amountVarianceForMatch(0, 'SEK', 0, -100, 'SEK', -100)).toBeNull()
})
it('treats currency codes case-insensitively', () => {
expect(amountVarianceForMatch(100, 'eur', null, -100, 'EUR', -1150)).toBe(0)
})
})
describe('bestProminentAmountVariance', () => {
it('picks the closest of several printed amounts and names it', () => {
// An agreement listing both a monthly price and a one-off price: the
// one-off 2500 matches the -2500 AVGIFT charge exactly.
const best = bestProminentAmountVariance(
[
{ amount: 49, label: 'Månadspris' },
{ amount: 2500, label: 'Engångspris' },
],
'SEK',
-2500,
'SEK',
-2500,
)
expect(best).toEqual({ variance: 0, amount: 2500, label: 'Engångspris' })
})
it('returns null when nothing is comparable', () => {
expect(bestProminentAmountVariance([], 'SEK', -2500, 'SEK', -2500)).toBeNull()
// Cross-currency without a rate stays incomparable, like a total would.
expect(
bestProminentAmountVariance([{ amount: 2500, label: null }], 'EUR', -2500, 'SEK', -2500),
).toBeNull()
// Zero amounts carry no signal (amountVarianceForMatch drops them).
expect(
bestProminentAmountVariance([{ amount: 0, label: null }], 'SEK', -2500, 'SEK', -2500),
).toBeNull()
})
it('the discount factor keeps fallback agreement below certainty', () => {
// Exact date + exact amount + no merchant normalises to 1.0; a fallback
// match must not present that as certainty (this exact geometry scored
// "100% säkerhet" on a wrong same-day transaction before the factor).
const { confidence } = calculateMatchConfidence(0, 0, 0)
const discounted = roundOre(confidence * FALLBACK_CONFIDENCE_FACTOR)
expect(confidence).toBe(1)
expect(discounted).toBeLessThan(1)
})
it('a disagreeing fallback amount scores no better than a disagreeing total', () => {
// Renormalized weights made a wrong fallback amount OUTSCORE a wrong
// invoice total (0.67 vs 0.60 with exact date + merchant); the factor
// approach scores both at full weight and then discounts the fallback.
const asTotal = calculateMatchConfidence(0, 1.4, 0.9).confidence
const asFallback = roundOre(asTotal * FALLBACK_CONFIDENCE_FACTOR)
expect(asFallback).toBeLessThanOrEqual(asTotal)
expect(asFallback).toBeLessThan(0.6)
})
})
describe('normalizeForMatch', () => {
it('leaves the frozen key normalizer alone', () => {
// normalizeMerchantName feeds a PERSISTED unique key with a SQL mirror.
// If this ever fails, the konteringskarta join is about to drift.
expect(normalizeMerchantName('Telia AB')).toBe('telia')
expect(normalizeMerchantName('Café Överkås!')).toBe('café överkås')
})
it('strips the Swedish card rails', () => {
expect(normalizeForMatch('Ryde Sweden AB K8066 Kortköp/uttag')).toBe('ryde sweden')
expect(normalizeForMatch('Kortköp 260612 Prime Video')).toBe('prime video')
expect(normalizeForMatch('ELGIGANTEN S/25-07-14')).toBe('elgiganten s')
})
it('folds the three ways banks mangle Swedish letters', () => {
// Same merchant, spelled three ways by three feeds.
const a = normalizeForMatch('Alviks kött och fisk')
expect(normalizeForMatch('Alviks koett och fisk')).toBe(a)
expect(normalizeForMatch('Alviks k??tt och fisk')).not.toBe('')
})
it('keeps both sides of a processor marker', () => {
// The merchant is second in K*IKEA and first in GOOGLE*PLAY.
expect(normalizeForMatch('K*IKEA GALLE')).toContain('ikea')
expect(normalizeForMatch('GOOGLE*PLAY')).toContain('google')
})
it('drops legal forms and reference numbers', () => {
expect(normalizeForMatch('Adobe Systems Software Ireland Ltd')).toBe('adobe systems software ireland')
expect(normalizeForMatch('GOOGLE ADS8047863617')).toBe('google ads')
})
})
describe('calculateMerchantSimilarity on real confirmed pairs', () => {
// Every pair below is one a human actually made in production
// (invoice_inbox_items.matched_transaction_id), so these are recall targets,
// not invented examples.
const CONFIRMED: Array<[string, string]> = [
['APPLE COM/SE', 'APPLE COM/SE/25-02-20'],
['Word and Sound Medien GmbH', 'Word and Sound GmbH'],
['Anomaly', 'ANOMALY,SAN FRANCISCO,US Kortköp'],
['Tradera Marketplace AB', 'TRADERA 1022'],
['DigitalOcean LLC', 'DIGITALOCEAN.COM AMSTERDAM Kortköp/uttag'],
['Cinode AB', 'WWW.CINODE.COM'],
['Kjell & Company', 'KjellCo Oktober'],
['Hostinger International Ltd.', 'Hostinger Apr JW'],
['OpenAI OpCo, LLC', 'OPENAI *CHATGP'],
['Google Ads', 'GOOGLE ADS8047863617'],
['Elgiganten', 'ELGIGANTEN S/25-07-14'],
['Hanko GmbH', 'HANKO IO'],
['Panduro', 'PANDURO LUND'],
['Rusta Lindingö 135', 'RUSTA LINDING?? 135'],
['Loopia AB', 'Loopia'],
['Kilo Code', 'KILO CODE INC,SAN FRANCISCO,US Kortköp'],
['DNH GODADDY', 'DNH GODADDY /25-07-06'],
['Adobe Systems Software Ireland Ltd', 'Adobe'],
['Lennart & Bror Kött (Alviks kött och fisk AB)', 'Alviks koett och fisk K3667 Kortköp/uttag'],
['Ryde Sweden AB', 'Ryde Sweden AB K8066 Kortköp/uttag'],
['IKEA', 'K*IKEA GALLE'],
['Espresso House', 'ESPRESSO HOUSE 1234 STOCKHOLM'],
]
it.each(CONFIRMED)('recognises %s ↔ %s', (receipt, bank) => {
expect(calculateMerchantSimilarity(receipt, bank)).toBeGreaterThanOrEqual(0.6)
})
// Aggressive folding buys recall; these guard the price of it.
const DIFFERENT: Array<[string, string]> = [
['Cloudflare', 'Clas Ohlson'],
['SJ AB', 'Skatteverket'],
['Adobe', 'Apple'],
['ICA Maxi Stockholm', 'Coop Solna'],
['Anthropic, PBC', 'Anomaly'],
['Hostinger International Ltd.', 'Hanko GmbH'],
['Google Ads', 'Google Cloud EMEA Limited'],
]
it.each(DIFFERENT)('keeps %s apart from %s', (a, b) => {
expect(calculateMerchantSimilarity(a, b)).toBeLessThan(0.6)
})
})
/**
* Swedish bank vocabulary that wraps a merchant name without being part of it.
* Drawn from a real ledger, where "Utlägg Norwegian" against "Norwegian Air
* Shuttle AOC AS" scored 0.18 and an exact 1 998 kr match was never proposed.
*/
describe('payment words are not merchant names', () => {
it('sees through an expense reimbursement', () => {
expect(calculateMerchantSimilarity('Utlägg Norwegian', 'Norwegian Air Shuttle AOC AS'))
.toBeGreaterThan(0.8)
})
it('sees through a transfer', () => {
expect(calculateMerchantSimilarity('Kontorsplatser j Bg-bet. via internet', 'Kontorsplatser AB'))
.toBeGreaterThan(0.8)
})
it('still tells two different merchants apart', () => {
expect(calculateMerchantSimilarity('Utlägg Norwegian', 'Scandinavian Airlines System'))
.toBeLessThan(0.5)
})
})