Files
accounted/lib/receipt-hunt/select.ts
T
Mattsson 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

369 lines
14 KiB
TypeScript

/**
* Which receipt gets proposed for which unbooked card purchase.
*
* The hunt attaches an underlag to a transaction *before* it is booked, so the
* gap never forms: `commitAttachDocumentToTransaction` sets
* `invoice_inbox_items.matched_transaction_id`, and when the user later books
* the transaction `categorize-core.ts` propagates that document onto the new
* verifikat. Chasing already-posted verifikat is deliberately NOT this job:
* that backlog is 96% imported history whose originals live in the previous
* system, and it stays a pull (the `verifikat_missing_document` worklist).
*
* Everything here is pure so the ranking and every guard is unit-testable
* without a database; the caller owns the reads and the staging write.
*/
import { scoreUnderlagCandidates, type CandidateTransaction } from '@/lib/agent-context/underlag-candidates'
import {
calculateMerchantSimilarity,
normalizeForMatch,
} from '@/lib/documents/core-receipt-matcher'
/**
* Confidence a candidate must reach to be proposed unattended.
*
* Above `CANDIDATE_MIN_CONFIDENCE` (0.6), which governs candidates an agent
* reads and reasons about with a human in the loop. A proposal staged by the
* nightly hunt is read as "these two belong together", so it trades recall for
* precision: a wrong receipt on the wrong purchase is a mis-booking, and the
* weaker pairs still reach the user through the manual picker.
*/
export const HUNT_MIN_CONFIDENCE = 0.7
/**
* Above this the arithmetic decides alone.
*
* An exact amount from a merchant the matcher recognises needs no second
* opinion, and paying for one on every pair would be latency and cost for a
* verdict nobody doubts.
*/
export const CERTAIN_CONFIDENCE = 0.8
/**
* Below this nothing is worth a second opinion either: the signals disagree,
* and asking a model to rescue a pair the evidence does not support is how
* plausible-sounding wrong answers get made.
*/
export const UNCERTAIN_FLOOR = 0.6
/**
* How far clear the winner must be before we propose it.
*
* Two receipts scoring the same against one purchase is a signal, not a tie to
* break: a duplicate, a split payment, or a recurring charge whose sibling we
* picked at random. Proposing either would be a coin flip presented as a
* finding, so both are left to the picker.
*/
export const AMBIGUITY_MARGIN = 0.05
/**
* Proposals per company per run.
*
* The queue is drained largest-amount-first over several nights instead of
* arriving at once: prod holds companies with hundreds of receiptless
* purchases, and a first run that staged all of them would bury the
* granskningskö the feature is supposed to relieve.
*/
export const MAX_PROPOSALS_PER_RUN = 20
/** A transaction the hunt may propose an underlag for. */
export interface HuntTransaction extends CandidateTransaction {
company_id: string
}
/** An unconsumed inbox item, already filtered to ones whose document is attachable. */
export interface HuntPoolItem {
id: string
document_id: string | null
extracted_data: unknown
channel_context: unknown
/** The receipt's total in kronor, resolved from a rate before scoring. */
sek_total?: number | null
}
export interface HuntProposal {
transaction_id: string
document_id: string
inbox_item_id: string
confidence: number
matchReasons: string[]
/** Receipt-side facts, for the approval preview. */
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
currency: string | null
/** The total in kronor, when a rate was resolved: both sides then compare. */
sek_total?: number | null
/** Settled by a second opinion rather than by the formula alone. */
wasAdjudicated?: boolean
/**
* Where the document came from, carried through from the inbox item so the
* proposal can say "found in your mailbox" rather than implying a human
* uploaded it.
*/
mailProvenance: {
mailbox?: string
subject?: string
from?: string
} | null
}
export interface SuppressionSets {
/**
* Transactions that already have an open or settled proposal. One live
* question per purchase: a second one is noise even when it names a
* different receipt.
*/
claimedTransactionIds: ReadonlySet<string>
/**
* Receipts already offered to some purchase and not yet rejected. One
* underlag belongs to one verifikat, and a proposal is a claim on it until a
* human says otherwise.
*/
claimedDocumentIds: ReadonlySet<string>
/**
* Pairs a human already said no to, as `${transaction_id}:${document_id}`.
* Scoped to the pair rather than the transaction so a rejection retires one
* wrong guess without retiring the purchase.
*/
rejectedPairs: ReadonlySet<string>
}
export function pairKey(transactionId: string, documentId: string): string {
return `${transactionId}:${documentId}`
}
/**
* Rank the pool against each transaction and return the proposals worth
* staging, strongest purchases first.
*
* Ordering is by absolute amount, not by confidence: when the cap truncates the
* run, the money that matters most for the books should be the part that gets
* asked about tonight.
*/
export function selectProposals(
transactions: readonly HuntTransaction[],
pool: readonly HuntPoolItem[],
suppression: SuppressionSets,
limit: number = MAX_PROPOSALS_PER_RUN,
/**
* Lowest confidence worth returning. The caller lowers it to collect the
* band it intends to adjudicate; every other guard still applies.
*/
minConfidence: number = HUNT_MIN_CONFIDENCE,
): HuntProposal[] {
if (pool.length === 0) return []
const byLargestAmount = [...transactions].sort(
(a, b) => Math.abs(b.amount ?? 0) - Math.abs(a.amount ?? 0),
)
const poolById = new Map(pool.map((item) => [item.id, item]))
const proposals: HuntProposal[] = []
// One receipt can only settle one purchase, and the pool is scored per
// transaction, so the same document can win twice in a single run. Whoever
// is scored first (the larger amount) keeps it.
const spentDocumentIds = new Set<string>()
for (const tx of byLargestAmount) {
if (proposals.length >= limit) break
if (suppression.claimedTransactionIds.has(tx.id)) continue
const scored = scoreUnderlagCandidates(tx, pool as never[]).filter(
(candidate) =>
candidate.document_id != null &&
// Never propose on the prominent-amounts fallback (non-invoice
// documents: bankintyg, avtal). The hunt's thresholds were calibrated
// for invoice-style totals; a fallback pair can reach 0.85 on date +
// printed-figure alone, which clears CERTAIN_CONFIDENCE and skips
// adjudication, and its preview would show document_amount null. The
// hunt also scans outflows only, so an inflow-labeled figure
// ("Insatt belopp") is guaranteed to pair with the wrong row. Those
// documents stay reachable through the picker and agent candidates.
candidate.amountSource !== 'prominent' &&
!spentDocumentIds.has(candidate.document_id) &&
!suppression.claimedDocumentIds.has(candidate.document_id) &&
!suppression.rejectedPairs.has(pairKey(tx.id, candidate.document_id)),
)
if (scored.length === 0) continue
const [winner, runnerUp] = scored
if (winner.confidence < minConfidence) continue
if (runnerUp && winner.confidence - runnerUp.confidence < AMBIGUITY_MARGIN) continue
const documentId = winner.document_id as string
spentDocumentIds.add(documentId)
const context = poolById.get(winner.inbox_item_id)?.channel_context as
| { channel?: string; mail_mailbox?: string; mail_subject?: string; mail_from?: string }
| null
| undefined
proposals.push({
mailProvenance:
context?.channel === 'mail_hunt'
? {
mailbox: context.mail_mailbox,
subject: context.mail_subject,
from: context.mail_from,
}
: null,
transaction_id: tx.id,
document_id: documentId,
inbox_item_id: winner.inbox_item_id,
confidence: winner.confidence,
matchReasons: winner.matchReasons,
merchant_name: winner.merchant_name,
receipt_date: winner.receipt_date,
total_amount: winner.total_amount,
currency: winner.currency,
sek_total: poolById.get(winner.inbox_item_id)?.sek_total ?? null,
})
}
return proposals
}
/**
* Payments that cannot have an emailed receipt, however hard we look.
*
* Salary, tax, employer contributions, VAT settlements, dividends, loan
* amortisation and interest are all money moving on the strength of a
* declaration or an agreement, not a purchase a merchant confirms by mail.
*
* Deliberately narrow. Supplier payments over bankgiro DO arrive with an
* emailed invoice (a provkörning matched a Sting office invoice that way), and
* an "Utlägg" reimbursement has a real receipt behind it, so neither the rail
* nor the word "överföring" is grounds for skipping.
*
* Swedish bank statements truncate hard, which is why "skat" has to match as
* well as "skatt": a real ledger row reads "Inbetalning skat BG 000005...".
*/
const NO_EMAIL_RECEIPT_EXISTS =
/\bl[oö]n\b|\bl[oö]ner\b|\bskatt?\b|skatteverket|arbetsgivaravg|\bmoms\b|utdelning|amortering|\br[aä]nta\b|egen ins[aä]ttning/i
/**
* Whether it is worth spending a mailbox search on this purchase.
*
* Only gates the mail leg: the Underlag pairing is scored on amount and
* merchant and is already safe on these rows.
*/
export function canHaveEmailReceipt(description: string | null | undefined): boolean {
if (!description) return true
return !NO_EMAIL_RECEIPT_EXISTS.test(description)
}
/** Vendor names this alike are treated as the same merchant. */
export const FETCH_VENDOR_SIMILARITY = 0.6
/** How far a receipt's own date may sit from the charge and still be plausible. */
export const FETCH_DATE_WINDOW_DAYS = 10
/** Amounts within this fraction of each other are the same amount. */
const AMOUNT_TOLERANCE = 0.01
function daysBetween(a: string, b: string): number {
return Math.abs(new Date(a).getTime() - new Date(b).getTime()) / 86_400_000
}
/**
* Is this mailbox document worth downloading?
*
* The gate before spending a fetch, a megabyte of storage and a page of the
* granskningskö on a document. It is deliberately not the match: a mail body
* states the total only about a quarter of the time, and the real amount comes
* out of the PDF afterwards, at which point the ordinary matcher decides.
*
* Ordered by how much each signal is worth. An amount that agrees is close to
* proof on its own, which is why it does not also need the vendor or the date.
* A vendor that agrees is suggestive rather than conclusive, so it is asked to
* bring a plausible date along. Everything else waits.
*/
export function worthFetching(
doc: {
vendor: string | null
date: string | null
amount: number | null
currency: string | null
},
transactions: readonly HuntTransaction[],
/**
* The purchases whose own search turned this mail up.
*
* Already evidence, and evidence of a kind the document itself cannot carry:
* the query was this purchase's amount or its merchant tokens, so a hit means
* one of those appeared in the mail or inside its attachment. It rescues the
* case where the two names genuinely differ, which is common and not an
* error: the bank writes "Kontorsplatser j BG" and the landlord's invoice
* says "Stockholm Innovation & Growth AB".
*/
retrievedFor: readonly HuntTransaction[] = [],
): boolean {
for (const tx of transactions) {
if (tx.amount == null) continue
const charged = Math.abs(tx.amount)
// Same currency only. A EUR receipt against a SEK charge is not a
// disagreement about the number, it is a different number: converting one
// to the other is a guess and this code does not guess.
if (
doc.amount != null &&
(doc.currency ?? 'SEK') === (tx.currency ?? 'SEK') &&
Math.abs(doc.amount - charged) <= charged * AMOUNT_TOLERANCE
) {
return true
}
if (!doc.vendor) continue
const similarity = calculateMerchantSimilarity(
doc.vendor,
tx.merchant_name || tx.description || '',
)
if (similarity < FETCH_VENDOR_SIMILARITY) continue
// No date on the receipt is missing evidence, not contrary evidence: the
// vendor agreeing is enough to look inside the file.
if (!doc.date || !tx.date) return true
if (daysBetween(doc.date, tx.date) <= FETCH_DATE_WINDOW_DAYS) return true
}
for (const tx of retrievedFor) {
if (!doc.date || !tx.date) continue
if (daysBetween(doc.date, tx.date) <= FETCH_DATE_WINDOW_DAYS) return true
}
return false
}
/**
* What identifies one purchase's paperwork, for deciding whether to fetch it.
*
* 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 neither the filename nor the message id identifies anything.
* The vendor, the total and the date do.
*
* The date is what keeps a subscription working. Anthropic bills the same
* amount every month, and without a date every month after the first would be
* treated as a duplicate of it and suppressed forever: a worse failure than
* the duplicates this key exists to prevent, because it is permanent and
* silent. Two documents for one purchase share a date; June and July do not.
*
* Without a vendor there is nothing to anchor an amount to, so those fall back
* to the message and the file. The filename alone is not enough: half the
* world's billing systems attach "invoice.pdf".
*/
export function receiptIdentity(doc: {
vendor: string | null
amount: number | null
currency: string | null
date?: string | null
attachmentName?: string | null
messageId?: string | null
}): string {
const vendor = normalizeForMatch(doc.vendor ?? '').trim()
if (vendor && doc.amount != null) {
// Öre, not floating point: 0.1 + 0.2 must not read as a different total.
const amount = Math.round(doc.amount * 100) / 100
return `v1::${vendor}::${amount}::${(doc.currency ?? 'SEK').toLowerCase()}::${doc.date ?? ''}`
}
return `v1::file::${(doc.messageId ?? '').toLowerCase()}::${(doc.attachmentName ?? '').toLowerCase()}`
}