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>
This commit is contained in:
Mattsson
2026-08-30 23:52:29 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 84ba8323b4
commit 516e8b62ff
14 changed files with 493 additions and 19 deletions
+42 -4
View File
@@ -17,11 +17,14 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { Loader2, Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import {
FALLBACK_CONFIDENCE_FACTOR,
amountVarianceForMatch,
bestProminentAmountVariance,
calculateMatchConfidence,
calculateMerchantSimilarity,
} from '@/lib/documents/core-receipt-matcher'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { roundOre } from '@/lib/money'
import type { InvoiceExtractionResult } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -132,6 +135,17 @@ export default function TransactionMatchPicker({
const total = extractedData?.totals?.total ?? null
const receiptCurrency = (extractedData?.invoice?.currency ?? 'SEK').toUpperCase()
const supplier = extractedData?.supplier?.name ?? null
// Non-invoice documents (bankintyg, avtal) have no total but often show the
// money amount anyway; those feed a discounted amount fallback below.
const prominentAmounts = useMemo(
() =>
total == null
? (extractedData?.prominentAmounts ?? []).filter(
(a) => Number.isFinite(a.amount) && a.amount !== 0,
)
: [],
[extractedData, total],
)
// SEK value of the underlag total. For a SEK underlag that's the total
// itself; for a foreign one it needs the fetched FX rate.
@@ -254,7 +268,7 @@ export default function TransactionMatchPicker({
// Currency-aware variance: null when uncomparable, which makes the
// matcher drop the amount signal instead of matching 750 EUR to 750 SEK.
const amountVariance = amountVarianceForMatch(
let amountVariance = amountVarianceForMatch(
total,
receiptCurrency,
receiptSek,
@@ -263,17 +277,36 @@ export default function TransactionMatchPicker({
txSek,
)
// No total (bankintyg, avtal): fall back to the closest prominent
// amount, discounted below so a printed figure never presents as the
// certainty a real total gives.
const fallbackMatch =
total == null && prominentAmounts.length > 0
? bestProminentAmountVariance(
prominentAmounts,
receiptCurrency,
tx.amount,
txCurrency,
txSek,
)
: null
if (fallbackMatch) amountVariance = fallbackMatch.variance
const dateVariance = Math.abs(
(new Date(tx.date).getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24),
)
const merchant = tx.merchant_name || tx.description || ''
const similarity = supplier ? calculateMerchantSimilarity(supplier, merchant) : 0
const { confidence, matchReasons } = calculateMatchConfidence(
const scoredMatch = calculateMatchConfidence(
dateVariance,
amountVariance,
similarity,
MATCH_DATE_TOLERANCE_DAYS,
)
const matchReasons = scoredMatch.matchReasons
const confidence = fallbackMatch
? roundOre(scoredMatch.confidence * FALLBACK_CONFIDENCE_FACTOR)
: scoredMatch.confidence
return {
id: tx.id,
@@ -289,7 +322,7 @@ export default function TransactionMatchPicker({
})
scored.sort((a, b) => b.confidence - a.confidence)
return scored
}, [rawRows, invoiceDate, total, receiptCurrency, receiptSek, supplier])
}, [rawRows, invoiceDate, total, prominentAmounts, receiptCurrency, receiptSek, supplier])
// Instant client-side narrowing while the debounced server query catches up.
const filtered = useMemo(() => {
@@ -344,7 +377,7 @@ export default function TransactionMatchPicker({
{/* Underlag reference: what we're matching against, so a currency or
amount mismatch with a candidate is obvious at a glance. */}
{(total != null || supplier) && (
{(total != null || prominentAmounts.length > 0 || supplier) && (
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className="text-muted-foreground shrink-0">Underlag</span>
{supplier && <span className="font-medium truncate">{supplier}</span>}
@@ -359,6 +392,11 @@ export default function TransactionMatchPicker({
)}
</span>
)}
{total == null && prominentAmounts.length > 0 && (
<span className="tabular-nums font-medium shrink-0">
{prominentAmounts.map((a) => formatCurrency(a.amount, receiptCurrency)).join(' · ')}
</span>
)}
{hasInvoiceDate && rawInvoiceDate && (
<span className="text-muted-foreground tabular-nums shrink-0">
{formatDate(rawInvoiceDate)}