1a3686dd45
* 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>
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
/**
|
|
* The hunt's judgement: which pairing gets proposed, and every reason one does
|
|
* not. Pure, so no database is involved; the reads and the staging write are
|
|
* covered by the cron route test.
|
|
*/
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
AMBIGUITY_MARGIN,
|
|
HUNT_MIN_CONFIDENCE,
|
|
canHaveEmailReceipt,
|
|
pairKey,
|
|
receiptIdentity,
|
|
worthFetching,
|
|
selectProposals,
|
|
type HuntPoolItem,
|
|
type HuntTransaction,
|
|
} from '../select'
|
|
|
|
function tx(overrides: Partial<HuntTransaction> = {}): HuntTransaction {
|
|
return {
|
|
id: 'tx-1',
|
|
company_id: 'co-1',
|
|
date: '2026-05-02',
|
|
description: 'CIRCLE K 421',
|
|
merchant_name: 'Circle K',
|
|
amount: -438.75,
|
|
currency: 'SEK',
|
|
amount_sek: -438.75,
|
|
exchange_rate: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function item(overrides: Partial<HuntPoolItem> = {}, extraction: Record<string, unknown> = {}): HuntPoolItem {
|
|
return {
|
|
id: 'item-1',
|
|
document_id: 'doc-1',
|
|
extracted_data: {
|
|
supplier: { name: 'Circle K' },
|
|
invoice: { invoiceDate: '2026-05-02', currency: 'SEK' },
|
|
totals: { total: 438.75, vatAmount: 87.75 },
|
|
...extraction,
|
|
},
|
|
channel_context: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const noSuppression = {
|
|
claimedTransactionIds: new Set<string>(),
|
|
claimedDocumentIds: new Set<string>(),
|
|
rejectedPairs: new Set<string>(),
|
|
}
|
|
|
|
describe('selectProposals', () => {
|
|
it('proposes an exact same-day match', () => {
|
|
const result = selectProposals([tx()], [item()], noSuppression)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]).toMatchObject({
|
|
transaction_id: 'tx-1',
|
|
document_id: 'doc-1',
|
|
inbox_item_id: 'item-1',
|
|
})
|
|
expect(result[0].confidence).toBeGreaterThanOrEqual(HUNT_MIN_CONFIDENCE)
|
|
})
|
|
|
|
it('returns nothing when the pool is empty', () => {
|
|
expect(selectProposals([tx()], [], noSuppression)).toEqual([])
|
|
})
|
|
|
|
it('never proposes on the prominent-amounts fallback', () => {
|
|
// A bankintyg (documentKind "other", no invoice-style total) whose printed
|
|
// "Insatt belopp" happens to equal a same-day outflow scores 0.85 on the
|
|
// shared matcher, which would clear CERTAIN_CONFIDENCE and skip
|
|
// adjudication, on a pairing that is wrong by construction: the hunt scans
|
|
// outflows only, and "Insatt belopp" labels an inflow. Fallback-scored
|
|
// candidates are for the picker and the agent, never the nightly hunt.
|
|
const bankintyg = item(
|
|
{ id: 'item-intyg', document_id: 'doc-intyg' },
|
|
{
|
|
supplier: { name: null },
|
|
totals: { total: null, vatAmount: null },
|
|
documentKind: 'other',
|
|
prominentAmounts: [{ amount: 438.75, label: 'Insatt belopp' }],
|
|
},
|
|
)
|
|
expect(selectProposals([tx()], [bankintyg], noSuppression)).toEqual([])
|
|
})
|
|
|
|
it('never proposes on a PROMOTED total either (totalSource prominent)', () => {
|
|
// promoteSingleProminentAmount copies a single prominent amount into
|
|
// totals.total for the editable TOTALT field. That must not smuggle the
|
|
// document past the hunt's fallback exclusion: the provenance stamp
|
|
// demotes it back to fallback-grade in the shared scorer.
|
|
const promoted = item(
|
|
{ id: 'item-promoted', document_id: 'doc-promoted' },
|
|
{
|
|
supplier: { name: null },
|
|
totals: { total: 438.75, vatAmount: null },
|
|
totalSource: 'prominent',
|
|
documentKind: 'other',
|
|
prominentAmounts: [{ amount: 438.75, label: 'Insatt belopp' }],
|
|
},
|
|
)
|
|
expect(selectProposals([tx()], [promoted], noSuppression)).toEqual([])
|
|
})
|
|
|
|
it('skips a transaction that already has a live proposal', () => {
|
|
const result = selectProposals([tx()], [item()], {
|
|
claimedTransactionIds: new Set(['tx-1']),
|
|
claimedDocumentIds: new Set<string>(),
|
|
rejectedPairs: new Set<string>(),
|
|
})
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('never re-proposes a pair a human rejected', () => {
|
|
const result = selectProposals([tx()], [item()], {
|
|
claimedTransactionIds: new Set<string>(),
|
|
claimedDocumentIds: new Set<string>(),
|
|
rejectedPairs: new Set([pairKey('tx-1', 'doc-1')]),
|
|
})
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('lets a rejection retire one receipt without retiring the purchase', () => {
|
|
const other = item({ id: 'item-2', document_id: 'doc-2' })
|
|
const result = selectProposals([tx()], [item(), other], {
|
|
claimedTransactionIds: new Set<string>(),
|
|
claimedDocumentIds: new Set<string>(),
|
|
rejectedPairs: new Set([pairKey('tx-1', 'doc-1')]),
|
|
})
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].document_id).toBe('doc-2')
|
|
})
|
|
|
|
it('drops a match that clears the shared floor but not the hunt floor', () => {
|
|
// Right merchant, right day, wrong amount (>5% out) scores exactly 0.60:
|
|
// good enough for the picker, where a human compares the two totals side
|
|
// by side, and not good enough to propose unattended. This is the whole
|
|
// reason HUNT_MIN_CONFIDENCE sits above CANDIDATE_MIN_CONFIDENCE, so the
|
|
// case has to be exercised at that exact seam.
|
|
const wrongAmount = item({}, {
|
|
supplier: { name: 'Circle K' },
|
|
invoice: { invoiceDate: '2026-05-02', currency: 'SEK' },
|
|
totals: { total: 500, vatAmount: 0 },
|
|
})
|
|
const result = selectProposals([tx()], [wrongAmount], noSuppression)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('drops a match that fails even the shared floor', () => {
|
|
const weak = item({}, {
|
|
supplier: { name: 'Helt Annat Bolag AB' },
|
|
invoice: { invoiceDate: '2026-01-02', currency: 'SEK' },
|
|
totals: { total: 12_000, vatAmount: 0 },
|
|
})
|
|
expect(selectProposals([tx()], [weak], noSuppression)).toEqual([])
|
|
})
|
|
|
|
it('refuses to guess between two equally good receipts', () => {
|
|
// Same merchant, same date, same amount: a duplicate or a split payment.
|
|
// Picking one would be a coin flip presented as a finding.
|
|
const twin = item({ id: 'item-2', document_id: 'doc-2' })
|
|
const result = selectProposals([tx()], [item(), twin], noSuppression)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('proposes the winner when it is clear of the runner-up', () => {
|
|
const weaker = item({ id: 'item-2', document_id: 'doc-2' }, {
|
|
supplier: { name: 'Circle K' },
|
|
invoice: { invoiceDate: '2026-05-05', currency: 'SEK' },
|
|
totals: { total: 500, vatAmount: 0 },
|
|
})
|
|
const result = selectProposals([tx()], [item(), weaker], noSuppression)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].document_id).toBe('doc-1')
|
|
})
|
|
|
|
it('never spends one receipt on two purchases', () => {
|
|
const first = tx({ id: 'tx-1', amount: -438.75 })
|
|
const second = tx({ id: 'tx-2', amount: -438.75, date: '2026-05-02' })
|
|
const result = selectProposals([first, second], [item()], noSuppression)
|
|
expect(result).toHaveLength(1)
|
|
})
|
|
|
|
it('caps a run and takes the largest amounts first', () => {
|
|
const transactions = [
|
|
tx({ id: 'small', amount: -100, description: 'A', merchant_name: 'A' }),
|
|
tx({ id: 'large', amount: -9000, description: 'B', merchant_name: 'B' }),
|
|
]
|
|
const pool = [
|
|
item({ id: 'i-small', document_id: 'd-small' }, {
|
|
supplier: { name: 'A' },
|
|
invoice: { invoiceDate: '2026-05-02', currency: 'SEK' },
|
|
totals: { total: 100, vatAmount: 0 },
|
|
}),
|
|
item({ id: 'i-large', document_id: 'd-large' }, {
|
|
supplier: { name: 'B' },
|
|
invoice: { invoiceDate: '2026-05-02', currency: 'SEK' },
|
|
totals: { total: 9000, vatAmount: 0 },
|
|
}),
|
|
]
|
|
const result = selectProposals(transactions, pool, noSuppression, 1)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].transaction_id).toBe('large')
|
|
})
|
|
|
|
it('ignores an item whose extraction carries no date and no total', () => {
|
|
const blank = item({}, { invoice: { invoiceDate: null, currency: 'SEK' }, totals: { total: null, vatAmount: null } })
|
|
expect(selectProposals([tx()], [blank], noSuppression)).toEqual([])
|
|
})
|
|
|
|
it('does not match across currencies on amount alone', () => {
|
|
// 438.75 EUR is not 438.75 SEK. Without a comparable amount the pair must
|
|
// not ride to a high score on merchant + date.
|
|
const euro = item({}, {
|
|
supplier: { name: 'Circle K' },
|
|
invoice: { invoiceDate: '2026-05-02', currency: 'EUR' },
|
|
totals: { total: 438.75, vatAmount: 0 },
|
|
})
|
|
expect(selectProposals([tx()], [euro], noSuppression)).toEqual([])
|
|
})
|
|
|
|
it('exposes the margin and floor it enforces', () => {
|
|
expect(HUNT_MIN_CONFIDENCE).toBeGreaterThan(0.6)
|
|
expect(AMBIGUITY_MARGIN).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Which purchases are worth a mailbox search. Drawn from a provkörning where
|
|
* salary and tax rows, being the largest, consumed the entire search budget.
|
|
*/
|
|
describe('canHaveEmailReceipt', () => {
|
|
it('skips salary, which no merchant confirms by mail', () => {
|
|
expect(canHaveEmailReceipt('Lön Juli Jakob Överföring via internet')).toBe(false)
|
|
})
|
|
|
|
it('skips tax even when the bank has truncated the word', () => {
|
|
// Real row: the statement cuts "skatt" to "skat" at 16 characters.
|
|
expect(canHaveEmailReceipt('Inbetalning skat BG 0000050501055 Bg-bet. via internet')).toBe(false)
|
|
expect(canHaveEmailReceipt('Skatt lön Juni BG 0000050501055 Bg-bet. via internet')).toBe(false)
|
|
})
|
|
|
|
it('keeps a supplier invoice paid over bankgiro', () => {
|
|
// This one matched a real emailed invoice; skipping the whole rail would
|
|
// have thrown away the hunt's best hit.
|
|
expect(canHaveEmailReceipt('Kontorsplatser j BG 0000059142596 Bg-bet. via internet')).toBe(true)
|
|
})
|
|
|
|
it('keeps an expense reimbursement, which has a receipt behind it', () => {
|
|
expect(canHaveEmailReceipt('Utlägg Norwegian Överföring via internet')).toBe(true)
|
|
})
|
|
|
|
it('keeps ordinary card purchases', () => {
|
|
expect(canHaveEmailReceipt('ANTHROPIC* CLAUDE SUB SAN FRANCISCO Kortköp/uttag')).toBe(true)
|
|
expect(canHaveEmailReceipt('Elgiganten Aktiebolag K3667 Kortköp/uttag')).toBe(true)
|
|
})
|
|
|
|
it('hunts a transaction with no description rather than silently dropping it', () => {
|
|
expect(canHaveEmailReceipt(null)).toBe(true)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* The gate before a download. Not the match: the real amount comes out of the
|
|
* PDF afterwards. What matters is that a stated amount is enough on its own,
|
|
* that a vendor needs a plausible date, and that currencies are never
|
|
* converted to make a number agree.
|
|
*/
|
|
describe('worthFetching', () => {
|
|
const charge = (o: Partial<HuntTransaction> = {}): HuntTransaction =>
|
|
tx({
|
|
description: 'Elgiganten Aktiebolag K3667 Kortköp/uttag',
|
|
// Bank rows usually carry no merchant_name; the descriptor is all there is.
|
|
merchant_name: null,
|
|
amount: -21639,
|
|
currency: 'SEK',
|
|
date: '2026-08-04',
|
|
...o,
|
|
})
|
|
|
|
const doc = (o: Partial<Parameters<typeof worthFetching>[0]> = {}) => ({
|
|
vendor: 'Elgiganten',
|
|
date: '2026-08-03',
|
|
amount: null,
|
|
currency: null,
|
|
...o,
|
|
})
|
|
|
|
it('fetches on a matching amount alone, whatever the date says', () => {
|
|
// An amount that agrees is close to proof. Banks post days late and mail
|
|
// gets forwarded months later, so the date must not be able to veto it.
|
|
expect(
|
|
worthFetching(
|
|
doc({ vendor: null, date: '2025-01-01', amount: 21639, currency: 'SEK' }),
|
|
[charge()],
|
|
),
|
|
).toBe(true)
|
|
})
|
|
|
|
it('fetches on vendor and a nearby date when the body states no amount', () => {
|
|
// The common case: most receipts state their total only inside the PDF.
|
|
expect(worthFetching(doc(), [charge()])).toBe(true)
|
|
})
|
|
|
|
it('does not fetch on a vendor whose date is months away', () => {
|
|
expect(worthFetching(doc({ date: '2026-02-01' }), [charge()])).toBe(false)
|
|
})
|
|
|
|
it('fetches a matching vendor that gave no date at all', () => {
|
|
// Missing evidence, not contrary evidence.
|
|
expect(worthFetching(doc({ date: null }), [charge()])).toBe(true)
|
|
})
|
|
|
|
it('never converts currency to make an amount agree', () => {
|
|
// 180 EUR really was this charge, but turning it into 2014 SEK is a guess.
|
|
// The vendor path is what rescues this case, so the vendor is cleared too.
|
|
expect(
|
|
worthFetching(
|
|
{ vendor: null, date: '2026-06-15', amount: 180, currency: 'EUR' },
|
|
[charge({ description: 'ANTHROPIC* CLAUDE SUB', amount: -2014.32, date: '2026-06-16' })],
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('ignores a document that matches nothing the company is missing', () => {
|
|
expect(
|
|
worthFetching({ vendor: 'Spotify', date: '2026-08-03', amount: 119, currency: 'SEK' }, [
|
|
charge(),
|
|
]),
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('worthFetching, on the search that found it', () => {
|
|
it('fetches when the purchase that found the mail is close in time', () => {
|
|
// The bank calls the landlord "Kontorsplatser j BG"; the invoice says
|
|
// "Stockholm Innovation & Growth AB". The names will never match, but the
|
|
// search that produced this mail was that purchase's own.
|
|
const landlord = tx({
|
|
description: 'Kontorsplatser j BG 0000059142596 Bg-bet. via internet',
|
|
merchant_name: null,
|
|
amount: -15000,
|
|
date: '2026-07-04',
|
|
})
|
|
const invoice = {
|
|
vendor: 'Stockholm Innovation & Growth AB',
|
|
date: '2026-07-02',
|
|
amount: null,
|
|
currency: null,
|
|
}
|
|
expect(worthFetching(invoice, [landlord])).toBe(false)
|
|
expect(worthFetching(invoice, [landlord], [landlord])).toBe(true)
|
|
})
|
|
|
|
it('still refuses when the dates are nowhere near each other', () => {
|
|
const landlord = tx({ description: 'Kontorsplatser j BG', merchant_name: null, amount: -15000, date: '2026-07-04' })
|
|
const stale = { vendor: 'Stockholm Innovation & Growth AB', date: '2025-11-02', amount: null, currency: null }
|
|
expect(worthFetching(stale, [landlord], [landlord])).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('a receipt already offered elsewhere', () => {
|
|
it('is not offered again to a second purchase', () => {
|
|
// Caught on a real ledger: one H&M receipt was proposed against a -358
|
|
// purchase, then against a -354 purchase on the next run. Approving both
|
|
// would put the same underlag on two verifikat.
|
|
const result = selectProposals([tx()], [item()], {
|
|
claimedTransactionIds: new Set<string>(),
|
|
claimedDocumentIds: new Set(['doc-1']),
|
|
rejectedPairs: new Set<string>(),
|
|
})
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
|
|
/**
|
|
* What identifies one purchase's paperwork. A mail carries the invoice and the
|
|
* receipt for the same purchase under different names, and the same receipt
|
|
* reaches a second mailbox on another message, so fetching per file filled the
|
|
* pool with identical candidates the matcher then refused to choose between.
|
|
*/
|
|
describe('receiptIdentity', () => {
|
|
it('collapses the invoice and the receipt for one purchase', () => {
|
|
const d = { vendor: 'Anthropic', amount: 180, currency: 'EUR', date: '2026-06-15', messageId: 'm1' }
|
|
expect(receiptIdentity({ ...d, attachmentName: 'Invoice-E19DBF63-0021.pdf' })).toBe(
|
|
receiptIdentity({ ...d, attachmentName: 'Receipt-2066-0204-8388.pdf' }),
|
|
)
|
|
})
|
|
|
|
it('keeps a subscription billing the same amount every month apart', () => {
|
|
// Without the date, July would look like a duplicate of June and be
|
|
// suppressed forever: a permanent, silent loss.
|
|
expect(
|
|
receiptIdentity({ vendor: 'Anthropic', amount: 225, currency: 'EUR', date: '2026-06-15' }),
|
|
).not.toBe(
|
|
receiptIdentity({ vendor: 'Anthropic', amount: 225, currency: 'EUR', date: '2026-07-15' }),
|
|
)
|
|
})
|
|
|
|
it('reads equivalent totals as one amount', () => {
|
|
expect(
|
|
receiptIdentity({ vendor: 'Uber', amount: 0.1 + 0.2, currency: 'SEK', date: '2026-06-01' }),
|
|
).toBe(receiptIdentity({ vendor: 'Uber', amount: 0.3, currency: 'SEK', date: '2026-06-01' }))
|
|
})
|
|
|
|
it('ignores wrapping the matcher already folds away', () => {
|
|
expect(receiptIdentity({ vendor: 'Loopia AB', amount: 388, currency: 'SEK', date: '2026-06-11' })).toBe(
|
|
receiptIdentity({ vendor: 'LOOPIA', amount: 388, currency: 'SEK', date: '2026-06-11' }),
|
|
)
|
|
})
|
|
|
|
it('keeps two suppliers apart', () => {
|
|
expect(receiptIdentity({ vendor: 'Loopia', amount: 388, currency: 'SEK', date: '2026-06-11' })).not.toBe(
|
|
receiptIdentity({ vendor: 'Hetzner', amount: 388, currency: 'SEK', date: '2026-06-11' }),
|
|
)
|
|
})
|
|
|
|
it('never lets a missing vendor make two documents the same', () => {
|
|
// "invoice.pdf" is what half the world's billing systems attach, so the
|
|
// message has to be part of the identity when there is no vendor.
|
|
expect(
|
|
receiptIdentity({ vendor: null, amount: 500, currency: 'SEK', messageId: 'm1', attachmentName: 'invoice.pdf' }),
|
|
).not.toBe(
|
|
receiptIdentity({ vendor: '', amount: 500, currency: 'SEK', messageId: 'm2', attachmentName: 'invoice.pdf' }),
|
|
)
|
|
})
|
|
})
|