Files
accounted/lib/agent-context/underlag-candidates.ts
T
43386b4852 feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its
backfill-by-document_id: a user photographs a receipt into WhatsApp, answers
the bot's questions, then opens the app, clicks the bank transaction and asks
the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we
are holding, then asked everything again.

WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id
(process-inbound.ts passes uploadAndExtract's matchedTransactionId as
undefined) nor transactions.document_id (that mirror is written by the manual
match route). Only TransactionMatchPicker fills either column. So the underlag
list comes back empty, and a backfill that keys on document_id has nothing to
key on.

Unmatched, unconsumed inbox items are now scored against the transaction with
the same pure scorer the picker uses and the strongest few are surfaced as
TROLIGT UNDERLAG, carrying their captured chat answers.

Proposals only: nothing writes matched_transaction_id, and the prompt tells
the agent to get the match confirmed and to book only against a confirmed one.
Setting the link at intake above a confidence bar is the obvious alternative
and is deliberately left open.

An uncomparable amount (cross-currency with no rate) disqualifies a candidate,
because calculateMatchConfidence drops the amount signal in that case and date
+ merchant alone then score a confident match nobody checked the sums for.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:07:30 +02:00

207 lines
7.6 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 {
amountVarianceForMatch,
calculateMatchConfidence,
calculateMerchantSimilarity,
} from '@/lib/documents/core-receipt-matcher'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
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
/** 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
}
/** 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,
total: extracted?.totals?.total ?? null,
vat: extracted?.totals?.vatAmount ?? null,
currency: (extracted?.invoice?.currency || 'SEK').toUpperCase(),
}
}
/**
* 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 a total carries no signal the
// matcher can use; scoring it returns noise dressed as confidence.
if (!sig.date && sig.total == null) continue
const amountVariance = amountVarianceForMatch(
sig.total,
sig.currency,
// No stored SEK value on the inbox item, so cross-currency pairs are
// deliberately not comparable and the matcher drops the amount signal
// rather than matching 750 EUR to 750 SEK.
null,
tx.amount,
txCurrency,
txSek,
)
// 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 dateVariance = sig.date
? Math.abs((new Date(sig.date).getTime() - txDateMs) / (1000 * 60 * 60 * 24))
: Number.POSITIVE_INFINITY
const similarity = sig.supplier ? calculateMerchantSimilarity(sig.supplier, txMerchant) : 0
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
similarity,
)
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,
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[])
}