Files
accounted/extensions/general/inbox-smart-match/lib/match-receipt.ts
T
Jakob WennbergandClaude Opus 4.7 b5df2fb292 feat: invoice-inbox polish + SIE source voucher traceability (#299)
* fix: consolidate commit_journal_entry to single 4-arg signature

Replaces the phantom-overload drop migration with an idempotent consolidation
that leaves only the 4-arg-with-defaults signature, callable with either 2 or
4 named args. Fixes the "Could not choose the best candidate function"
ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a
4-arg version alongside the existing 2-arg one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: preserve SIE source voucher identity on journal entries

Adds source_voucher_series / source_voucher_number columns to journal_entries
so per-verifikat traceability survives the importer's skip-empty-voucher
logic. The SIE importer populates the original series/number even when
skipped vouchers cause gnubok's target numbering to drift from the source
file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik.

- Migration adds columns + partial index + extends immutability trigger
- importVouchers() records rawSeries/rawNumber per voucher
- JournalEntry type + test fixtures gain the new fields
- Bookkeeping detail page surfaces "Ursprungligt verifikat" when present

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: polish invoice-inbox workspace for production use

- Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload
  so HEIC/high-res phone photos don't fail with the 5 MB cap
- Swedish error mapping: toSwedishInboxError translates Bedrock /
  infrastructure errors to Swedish sentences stored in error_message
- History timeline endpoint (GET /items/:id/history) returns the
  processing_history events correlated to the inbox item
- Workspace UI: inline diagnostic timeline inside the convert dialog,
  same-email row grouping ("+N dokument" chip), inferred-VAT affordance
  with "needs review" signalling, Riksbanken exchange-rate prefill for
  foreign-currency invoices so the supplier-invoice create path populates
  *_sek audit columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extend inbox-smart-match to supplier invoices

Both receipts and supplier invoices expose structurally identical match
anchors (date, amount, currency, counterparty name) so the matcher can
reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared
extractor across ReceiptExtractionResult / InvoiceExtractionResult, and
updates the event handlers to process supplier_invoice items alongside
receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and
loosened the date-window heuristic since invoice payments can lag behind
the invoice date by weeks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop unused category selector from TransactionForm

The manual "Lägg till transaktion" dialog predates the current categorization
flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The
category dropdown here never drove journal-entry creation — onSubmit fanned
it out to CreateTransactionInput.category, which is optional. Removes the
dropdown, the unused watch() hook, and the categories lookup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): restore drop-phantom file and rebump timestamps

Supabase branch DB failed with PK violation on schema_migrations because
my two migrations collided with timestamps already on main:
  20260421120000 → journal_entries_with_related_rpc (PR #298)
  20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296)

Rebumped to 20260421140000 and 20260421150000 so each migration has a
unique version (Supabase uses only the 14-digit prefix as the PK).

Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload
migration I had deleted — CLAUDE.md rule #5 forbids modifying existing
migrations. My consolidate migration is still compatible: drop_phantom
drops the 4-arg overload (no-op where absent), then consolidate recreates
it with defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox-smart-match): anchor invoices on dueDate with wider window

The original ±7d window around invoiceDate filtered out all real payments
for invoices with standard 30–60 day terms — the matcher would see zero
candidates before the LLM was called, making the supplier-invoice matcher
effectively dead.

New anchor selection:
- Receipts: receipt date ±7 days (unchanged; paid on the spot)
- Invoices with dueDate: dueDate ±14 days (covers early/late payments)
- Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms)

MatchAnchors now carries windowDaysBefore/After so the window can vary per
document shape. Added three getMatchAnchors tests asserting window sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:33:38 +02:00

201 lines
6.5 KiB
TypeScript

/**
* Text-only LLM matcher — decides which candidate bank transaction (if any)
* corresponds to a classified receipt. Uses Bedrock Converse with structured
* tool output. No image input: the receipt is already represented by the
* extracted data, and candidates are pure text.
*/
import {
BedrockRuntimeClient,
ConverseCommand,
type ContentBlock,
type Message,
type ToolConfiguration,
} from '@aws-sdk/client-bedrock-runtime'
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
import { getMatchAnchors } from './fetch-candidates'
export interface ReceiptMatchResult {
matched: boolean
transactionId: string | null
confidence: number // 0..1
reasoning: string
usage: { inputTokens: number; outputTokens: number }
}
let _client: BedrockRuntimeClient | null = null
function getClient(): BedrockRuntimeClient {
if (!_client) {
_client = new BedrockRuntimeClient({
region: process.env.AWS_REGION || 'eu-north-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
}
return _client
}
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
Du får:
- Dokumentdata (handlare/leverantör, belopp, valuta, datum) från AI-extraktion
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar dokumentet.
Resonera utifrån:
- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor)
- Datum: för kvitton bokförs banktransaktionen ofta 0-3 dagar efter köpet; för leverantörsfakturor kan betalningen ske flera dagar till veckor efter fakturadatum
- Handlare/leverantör: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
Om inget förslag är trovärdigt — returnera matched=false.
Anropa ALLTID verktyget match_receipt med resultatet.
Motivering ska vara kort, på svenska och förklara varför transaktionen valdes.`
const MATCH_TOOL: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'match_receipt',
description: 'Returnera vilken kandidat-transaktion som matchar kvittot',
inputSchema: {
json: {
type: 'object',
required: ['matched', 'confidence', 'reasoning'],
properties: {
matched: {
type: 'boolean',
description: 'true om en kandidat matchar, false annars',
},
transaction_id: {
type: ['string', 'null'],
description: 'id för den matchande kandidaten (null om matched=false)',
},
confidence: {
type: 'integer',
minimum: 0,
maximum: 100,
description: 'Säkerhet 0-100. Sätt lågt när matched=false.',
},
reasoning: {
type: 'string',
description: '1-2 meningar på svenska som förklarar beslutet.',
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
export interface MatchReceiptInput {
extracted: ExtractedDocument
candidates: CandidateTransaction[]
}
/**
* Call Bedrock to choose the best matching transaction.
* Returns a neutral result (matched=false) if the model doesn't find a fit or
* the tool schema is missing from the response.
*/
export async function matchReceiptToCandidate(
input: MatchReceiptInput
): Promise<ReceiptMatchResult> {
const anchors = getMatchAnchors(input.extracted)
const receiptBrief = {
merchant: anchors?.counterpartyName ?? null,
amount: anchors?.amount ?? null,
currency: anchors?.currency ?? 'SEK',
date: anchors?.date ?? null,
vat_amount: input.extracted.totals?.vatAmount ?? null,
}
const candidateLines = input.candidates.map((c) => ({
id: c.id,
date: c.date,
description: c.description,
amount: c.amount,
amount_sek: c.amount_sek,
currency: c.currency,
merchant_name: c.merchant_name,
}))
const userPrompt = `Dokument:
${JSON.stringify(receiptBrief, null, 2)}
Kandidat-transaktioner:
${JSON.stringify(candidateLines, null, 2)}
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
const messages: Message[] = [
{
role: 'user',
content: [{ text: userPrompt }],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '1024', 10)
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: MATCH_TOOL,
inferenceConfig: { maxTokens, temperature: 0 },
})
const response = await getClient().send(command)
const usage = {
inputTokens: response.usage?.inputTokens ?? 0,
outputTokens: response.usage?.outputTokens ?? 0,
}
const outputMessage = response.output?.message
if (!outputMessage?.content) {
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget LLM-svar', usage }
}
const toolUseBlock = outputMessage.content.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget verktygsanrop', usage }
}
const raw = toolUseBlock.toolUse.input as Record<string, unknown>
const matched = Boolean(raw.matched)
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
const transactionId = matched
? (input.candidates.find((c) => c.id === rawId)?.id ?? null)
: null
const confidenceRaw = Number(raw.confidence)
const confidence =
isFinite(confidenceRaw)
? Math.min(1, Math.max(0, confidenceRaw / 100))
: 0
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
// If LLM said matched but we can't resolve the transaction_id to a candidate,
// degrade gracefully to unmatched so downstream isn't left dangling.
if (matched && !transactionId) {
return {
matched: false,
transactionId: null,
confidence: 0,
reasoning: reasoning || 'LLM angav ogiltigt transaction_id',
usage,
}
}
return { matched, transactionId, confidence, reasoning, usage }
}