* 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>
215 lines
7.2 KiB
TypeScript
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,
|
|
}
|
|
}
|