Files
accounted/extensions/general/inbox-smart-match/lib/process-match.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

215 lines
7.2 KiB
TypeScript

/**
* Core matching flow — deterministic narrowing + LLM call + persistence +
* processing_history audit events. Called from both the classify handler and
* the transaction-sync retroactive handler.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem } from '@/types'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { fetchCandidateTransactions, type ExtractedDocument } from './fetch-candidates'
import { matchReceiptToCandidate } from './match-receipt'
export interface MatchContext {
supabase: SupabaseClient
companyId: string
userId: string
extensionId: string
triggerReason: 'classified' | 'transaction_synced'
}
export interface MatchOutcome {
status: 'matched' | 'no_match' | 'pending_transaction' | 'skipped'
transactionId: string | null
confidence: number
reasoning: string
}
/**
* Process a single classified-receipt inbox item through the matcher pipeline.
* Writes match fields + appends processing_history. Swallows internal errors
* so one failing receipt doesn't break the whole event handler.
*/
export async function processInboxItemMatch(
ctx: MatchContext,
item: InvoiceInboxItem
): Promise<MatchOutcome> {
const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}`
// Match both receipts and supplier invoices — both have comparable anchors
// (date, amount, counterparty, currency) and the downstream LLM prompt is
// shape-agnostic.
if (item.document_type !== 'receipt' && item.document_type !== 'supplier_invoice') {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
if (item.status !== 'ready') {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
if (!item.extracted_data) {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
const correlationId = item.correlation_id ?? crypto.randomUUID()
// If we just minted a fresh correlation_id (legacy row predating the column),
// persist it so retries reuse the same thread through processing_history.
if (!item.correlation_id) {
const { error: corrError } = await ctx.supabase
.from('invoice_inbox_items')
.update({ correlation_id: correlationId })
.eq('id', item.id)
if (corrError) {
console.error(`${tag} — failed to persist correlation_id:`, corrError)
// non-fatal — we still proceed with matching under the in-memory ID
}
}
const extracted = item.extracted_data as unknown as ExtractedDocument
const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted)
// Append DeterministicMatch event — records that the narrowing ran
let deterministicEventId: string
try {
deterministicEventId = await appendProcessingHistory({
companyId: ctx.companyId,
correlationId,
aggregateType: 'MatchProposal',
aggregateId: item.id,
eventType: 'MatchAttemptedDeterministic',
payload: {
inbox_item_id: item.id,
candidate_count: candidates.length,
candidate_ids: candidates.map((c) => c.id),
window_days: 7,
trigger: ctx.triggerReason,
},
actor: { type: 'system', id: ctx.extensionId },
occurredAt: new Date(),
})
} catch (err) {
console.error(`${tag} — failed to append MatchAttemptedDeterministic:`, err)
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
}
// No candidates → mark pending, wait for bank sync
if (candidates.length === 0) {
await ctx.supabase
.from('invoice_inbox_items')
.update({
match_method: 'pending_transaction',
match_confidence: null,
matched_transaction_id: null,
match_reasoning: 'Inväntar matchande banktransaktion',
})
.eq('id', item.id)
return {
status: 'pending_transaction',
transactionId: null,
confidence: 0,
reasoning: 'Inväntar matchande banktransaktion',
}
}
// LLM chooses among candidates
let llm
try {
llm = await matchReceiptToCandidate({ extracted, candidates })
} catch (err) {
console.error(`${tag} — LLM matcher failed:`, err)
// Don't overwrite existing state on LLM failure; just log and exit
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
}
// Record the LLM attempt in processing_history
try {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId,
causationId: deterministicEventId,
aggregateType: 'MatchProposal',
aggregateId: item.id,
eventType: 'MatchAttemptedLlm',
payload: {
inbox_item_id: item.id,
matched: llm.matched,
chosen_transaction_id: llm.transactionId,
confidence: llm.confidence,
llm_input_tokens: llm.usage.inputTokens,
llm_output_tokens: llm.usage.outputTokens,
candidate_count: candidates.length,
},
actor: { type: 'llm', id: 'match_receipt' },
occurredAt: new Date(),
})
} catch (err) {
console.error(`${tag} — failed to append MatchAttemptedLlm:`, err)
}
// Persist match. The (company_id, matched_transaction_id) partial unique
// index means a concurrent second inbox item trying to claim the same
// transaction will get a 23505 — we catch that and downgrade this one
// to pending_transaction instead of overwriting the winner.
if (llm.matched && llm.transactionId) {
const { error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: llm.transactionId,
match_confidence: llm.confidence,
match_method: 'llm',
match_reasoning: llm.reasoning,
})
.eq('id', item.id)
if (updateError) {
const code = (updateError as { code?: string }).code
if (code === '23505') {
// Another receipt won the race for this transaction.
await ctx.supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: null,
match_method: 'pending_transaction',
match_confidence: null,
match_reasoning: 'Transaktionen matchades först till ett annat kvitto',
})
.eq('id', item.id)
return {
status: 'pending_transaction',
transactionId: null,
confidence: 0,
reasoning: 'Transaktionen matchades först till ett annat kvitto',
}
}
console.error(`${tag} — failed to persist match:`, updateError)
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
}
return {
status: 'matched',
transactionId: llm.transactionId,
confidence: llm.confidence,
reasoning: llm.reasoning,
}
}
// LLM said no match among the candidates — record explanatory reasoning
await ctx.supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: null,
match_confidence: llm.confidence,
match_method: 'pending_transaction',
match_reasoning: llm.reasoning || 'AI kunde inte hitta matchande transaktion bland kandidaterna',
})
.eq('id', item.id)
return {
status: 'no_match',
transactionId: null,
confidence: llm.confidence,
reasoning: llm.reasoning,
}
}