Files
accounted/lib/agent-context/underlag-candidates.ts
T
MattssonandClaude Fable 5 1a3686dd45 feat(inbox): promote a single prominent amount into the editable total (#2073)
* 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>
2026-08-31 11:40:30 +02:00

279 lines
11 KiB
TypeScript

/**
* Probable underlag for a bank transaction, among the inbox items nobody has
* matched to anything yet.
*
* Why this is needed at all: every surface finds underlag through
* invoice_inbox_items.matched_transaction_id, and WhatsApp intake never writes
* it. process-inbound.ts calls uploadAndExtract with its matchedTransactionId
* argument undefined, so upload-and-extract.ts inserts NULL, and it does not
* set transactions.document_id either (that mirror is written by the manual
* match route). Only TransactionMatchPicker fills either column. So a user who
* photographs a receipt into WhatsApp, opens the app and clicks the bank
* transaction has an underlag that no lookup can reach: the assistant reports
* "UNDERLAG: saknas" about a receipt we are holding, and asks again for answers
* they already gave in chat.
*
* Candidates are PROPOSALS, never links. Nothing here writes
* matched_transaction_id; the caller surfaces the candidate and a human
* confirms it, so the existing "a person links the underlag" step stays intact
* rather than being quietly automated.
*
* Core lib: must not import from @/extensions. The scoring half is pure so the
* ranking is unit-testable without a database.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
CONVERTED_AMOUNT_TOLERANCE_PERCENT,
DATE_TOLERANCE_DAYS,
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 { InboxChannelContext, InvoiceExtractionResult } from '@/types'
/**
* Confidence floor for surfacing an unmatched item as a probable underlag.
* Deliberately above core-receipt-matcher's MIN_MATCH_CONFIDENCE (0.4): that
* floor governs a picker where a human reads a ranked list and judges, whereas
* a candidate named here is read by an agent that will reason from it. A wrong
* receipt on the wrong transaction is a mis-booking, so this surface trades
* recall for precision and leaves the rest to the picker.
*/
export const CANDIDATE_MIN_CONFIDENCE = 0.6
/** How many probable-underlag candidates to surface at most. */
export const CANDIDATE_LIMIT = 3
/**
* How many still-unmatched items to score. Bounded rather than paginated:
* candidates are ranked by confidence and only the strongest few are used, so
* reading deeper into an old backlog cannot change the answer for a recent
* transaction.
*/
const CANDIDATE_SCAN_LIMIT = 50
export interface UnderlagCandidate {
inbox_item_id: string
document_id: string | null
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
vat_amount: number | null
currency: string | null
/** 0-1 from the shared receipt matcher. */
confidence: number
/**
* Where the amount signal came from: an invoice-style total, or the
* prominent-amounts fallback for non-invoice documents (bankintyg, avtal).
* Consumers that act with less human scrutiny (the nightly receipt hunt)
* must treat 'prominent' as weaker evidence or exclude it.
*/
amountSource: 'total' | 'prominent'
/** Swedish reasons the match scored, for display. */
matchReasons: string[]
/** Answers already captured for this item, so they travel with it. */
channelContext: InboxChannelContext | null
}
/** The transaction fields the scorer needs. */
export interface CandidateTransaction {
id: string
date: string | null
description: string | null
merchant_name?: string | null
amount: number | null
currency: string | null
amount_sek?: number | null
exchange_rate?: number | null
}
interface ScorableItem {
id: string
document_id: string | null
extracted_data: InvoiceExtractionResult | null
channel_context: InboxChannelContext | null
/**
* The receipt's total in kronor, when a caller has resolved a rate for it.
*
* Left undefined by every surface that has not, which keeps the old
* behaviour exactly: a cross-currency pair stays incomparable rather than
* being scored on date and merchant alone.
*/
sek_total?: number | null
}
/** Pull the fields the matcher needs out of an extraction blob. */
function extractionSignals(extracted: InvoiceExtractionResult | null | undefined) {
return {
supplier: extracted?.supplier?.name?.trim() || null,
date: extracted?.invoice?.invoiceDate ?? null,
// A total promoted from the document's single prominent amount
// (totalSource 'prominent') exists for the editable TOTALT field, not as
// invoice-grade evidence: score it through the fallback path below (the
// prominentAmounts list still carries it), which keeps the discount, the
// date guard, and the hunt's amountSource exclusion intact. A user-edited
// total has the stamp cleared and counts at full weight.
total: extracted?.totalSource === 'prominent' ? null : (extracted?.totals?.total ?? null),
vat: extracted?.totals?.vatAmount ?? null,
currency: (extracted?.invoice?.currency || 'SEK').toUpperCase(),
// Non-invoice documents (bankintyg, avtal) carry no total but often show
// the money amount anyway; the extractor lists those here.
prominentAmounts: (extracted?.prominentAmounts ?? []).filter(
(a) => Number.isFinite(a.amount) && a.amount !== 0,
),
}
}
/**
* Score unmatched items against a transaction and return the strongest few.
* Pure: the DB read is the caller's job.
*/
export function scoreUnderlagCandidates(
tx: CandidateTransaction,
items: ScorableItem[],
): UnderlagCandidate[] {
if (tx.amount == null || !tx.date) return []
const txCurrency = (tx.currency ?? 'SEK').toUpperCase()
const txSek =
txCurrency === 'SEK'
? tx.amount
: resolveSekAmount(tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate)
const txDateMs = new Date(tx.date).getTime()
const txMerchant = tx.merchant_name || tx.description || ''
const scored: UnderlagCandidate[] = []
for (const item of items) {
const sig = extractionSignals(item.extracted_data)
// An extraction with neither a date nor any amount carries no signal the
// matcher can use; scoring it returns noise dressed as confidence.
if (!sig.date && sig.total == null && sig.prominentAmounts.length === 0) continue
let amountVariance = amountVarianceForMatch(
sig.total,
sig.currency,
// A SEK value only when someone resolved a rate for this receipt.
// Without one the pair stays incomparable, rather than matching 750 EUR
// to 750 SEK.
item.sek_total ?? null,
tx.amount,
txCurrency,
txSek,
)
const dateVariance = sig.date
? Math.abs((new Date(sig.date).getTime() - txDateMs) / (1000 * 60 * 60 * 24))
: Number.POSITIVE_INFINITY
// A document with no invoice-style total (bankintyg, avtal: documentKind
// "other") but visible amounts falls back to the closest prominent
// amount. Two guards keep this precision-first: the document's date must
// agree within the normal tolerance (an avtal listing 349 kr must not
// match every future 349 kr charge from the same counterparty on amount +
// merchant alone), and the confidence is discounted below so a fallback
// can never present as certainty.
const fallbackMatch =
sig.total == null && amountVariance == null && dateVariance <= DATE_TOLERANCE_DAYS
? bestProminentAmountVariance(
sig.prominentAmounts,
sig.currency,
tx.amount,
txCurrency,
txSek,
)
: null
if (fallbackMatch) amountVariance = fallbackMatch.variance
// No comparable amount means no candidate. calculateMatchConfidence drops
// the amount signal when it cannot normalise the currencies, which leaves
// date + merchant carrying the whole normalised score: a same-day receipt
// from the same merchant then scores 1.0 without anyone having checked
// that the sums agree. That is a fair ranking hint in the picker, where a
// human reads both amounts, but here it would hand the agent a "certain"
// underlag whose total is in another currency. Those still reach the user
// through the picker; they are just not proposed.
if (amountVariance == null) continue
const similarity = sig.supplier ? calculateMerchantSimilarity(sig.supplier, txMerchant) : 0
// A converted total is judged against the wider bar, because the rate
// spread is a known error rather than a disagreement about the sum.
const scoredMatch = calculateMatchConfidence(
dateVariance,
amountVariance,
similarity,
undefined,
sig.currency !== txCurrency && item.sek_total != null
? CONVERTED_AMOUNT_TOLERANCE_PERCENT
: undefined,
)
let confidence = scoredMatch.confidence
let matchReasons = scoredMatch.matchReasons
if (fallbackMatch) {
confidence = roundOre(confidence * FALLBACK_CONFIDENCE_FACTOR)
// Name the figure that matched. A bare "Exakt belopp" would reach the
// agent while total_amount stays null: certainty without a number the
// agent or the user could check against the document.
const label = fallbackMatch.label ? ` (${fallbackMatch.label})` : ''
matchReasons = matchReasons.map((reason) =>
reason.startsWith('Exakt belopp') || reason.startsWith('Belopp ±')
? `${reason} i dokumentet: ${fallbackMatch.amount.toLocaleString('sv-SE')} ${sig.currency}${label}`
: reason,
)
}
if (confidence < CANDIDATE_MIN_CONFIDENCE) continue
scored.push({
inbox_item_id: item.id,
document_id: item.document_id,
merchant_name: sig.supplier,
receipt_date: sig.date,
total_amount: sig.total,
vat_amount: sig.vat,
currency: sig.currency,
confidence,
amountSource: fallbackMatch ? 'prominent' : 'total',
matchReasons,
channelContext: item.channel_context ?? null,
})
}
scored.sort((a, b) => b.confidence - a.confidence)
return scored.slice(0, CANDIDATE_LIMIT)
}
/**
* Find probable underlag for a transaction among the company's unconsumed
* inbox items.
*
* Only unconsumed items are considered: one already booked, already turned into
* a supplier invoice, or already matched elsewhere belongs to a different
* economic event, and proposing it here would invite a double booking.
*/
export async function findUnderlagCandidates(
supabase: SupabaseClient,
companyId: string,
tx: CandidateTransaction,
): Promise<UnderlagCandidate[]> {
if (tx.amount == null || !tx.date) return []
const { data, error } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, extracted_data, channel_context')
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.is('created_journal_entry_id', null)
.is('created_supplier_invoice_id', null)
.not('document_id', 'is', null)
.order('created_at', { ascending: false })
.limit(CANDIDATE_SCAN_LIMIT)
if (error || !data) return []
return scoreUnderlagCandidates(tx, data as unknown as ScorableItem[])
}