Files
accounted/lib/documents/document-matcher.ts
T
Jakob Wennberg 39e407644d feat: unified document inbox, full BAS 2026, and document-transaction matching
- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:59:02 +01:00

323 lines
11 KiB
TypeScript

/**
* Document-to-Transaction Matcher
*
* Pure matching logic that works from extracted data already stored on inbox items.
* Zero AI or extension dependencies — works entirely from structured data.
*
* Matching passes by document type:
*
* Supplier invoices:
* 1. Payment reference exact match → 0.98
* 2. Exact amount + bankgiro → 0.92
* 3. Exact amount + date ±5 days → 0.85
* 4. Fuzzy amount + supplier name → 0.70
*
* Receipts:
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
} from './core-receipt-matcher'
export type DocumentMatchMethod =
| 'payment_reference'
| 'amount_date'
| 'amount_merchant'
| 'receipt_match'
export interface DocumentMatchResult {
transactionId: string
confidence: number
method: DocumentMatchMethod
matchReasons: string[]
}
/**
* Match a single inbox item to the best candidate transaction.
*
* If `candidateTransactions` is not provided, fetches unbooked expense
* transactions within ±7 days of the document date.
*/
export async function matchDocumentToTransactions(
supabase: SupabaseClient,
userId: string,
inboxItem: InvoiceInboxItem,
candidateTransactions?: Transaction[]
): Promise<DocumentMatchResult | null> {
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
// Only match supplier invoices and receipts
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
console.log(`${tag} — skipped (unsupported document type)`)
return null
}
if (!inboxItem.extracted_data) {
console.log(`${tag} — skipped (no extracted_data)`)
return null
}
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, userId, inboxItem))
console.log(`${tag}${transactions.length} candidate transactions`)
if (transactions.length === 0) {
console.log(`${tag} — no candidates, aborting`)
return null
}
let result: DocumentMatchResult | null = null
if (inboxItem.document_type === 'supplier_invoice') {
result = matchSupplierInvoiceDocument(inboxItem, transactions)
} else if (inboxItem.document_type === 'receipt') {
result = matchReceiptDocument(inboxItem, transactions)
}
if (result) {
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
} else {
console.log(`${tag} — no match found`)
}
return result
}
/**
* Fetch unbooked expense transactions within ±7 days of the document date.
*/
async function fetchCandidateTransactions(
supabase: SupabaseClient,
userId: string,
inboxItem: InvoiceInboxItem
): Promise<Transaction[]> {
const docDate = getDocumentDate(inboxItem)
if (!docDate) return []
const startDate = new Date(docDate)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(docDate)
endDate.setDate(endDate.getDate() + 7)
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
.gte('date', startDate.toISOString().split('T')[0])
.lte('date', endDate.toISOString().split('T')[0])
.order('date', { ascending: false })
if (error || !data) return []
return data as Transaction[]
}
/**
* Extract the most relevant date from an inbox item's extracted data.
*/
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
const data = inboxItem.extracted_data as Record<string, unknown> | null
if (!data) return null
if (inboxItem.document_type === 'supplier_invoice') {
const extraction = data as unknown as InvoiceExtractionResult
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
}
if (inboxItem.document_type === 'receipt') {
const extraction = data as unknown as ReceiptExtractionResult
return extraction.receipt?.date ?? null
}
return null
}
/**
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
*/
function matchSupplierInvoiceDocument(
inboxItem: InvoiceInboxItem,
transactions: Transaction[]
): DocumentMatchResult | null {
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
if (!extraction) return null
const invoiceTotal = extraction.totals?.total
if (invoiceTotal == null || invoiceTotal === 0) {
console.log(`${tag} — no invoice total in extracted data`)
return null
}
const paymentRef = extraction.invoice?.paymentReference
const bankgiro = extraction.supplier?.bankgiro
const plusgiro = extraction.supplier?.plusgiro
const supplierName = extraction.supplier?.name
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
let bestMatch: DocumentMatchResult | null = null
for (const tx of transactions) {
const txAmount = Math.abs(tx.amount)
const txDesc = (tx.description || '').toLowerCase()
const txRef = tx.reference || ''
// Pass 1: Payment reference exact match → 0.98
if (paymentRef && txRef) {
const normTxRef = txRef.replace(/\D/g, '')
const normPayRef = paymentRef.replace(/\D/g, '')
if (normTxRef && normPayRef && normTxRef === normPayRef) {
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
return {
transactionId: tx.id,
confidence: 0.98,
method: 'payment_reference',
matchReasons: ['Betalningsreferens matchar'],
}
}
}
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
if (amountMatch) {
const bgNorm = bankgiro?.replace(/\D/g, '')
const pgNorm = plusgiro?.replace(/\D/g, '')
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
if (hasBgMatch || hasPgMatch) {
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
return {
transactionId: tx.id,
confidence: 0.92,
method: 'payment_reference',
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
}
}
}
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
// Invoices are often paid early or a few days late, so we use a 14-day window.
if (amountMatch && dueDate) {
const txDate = new Date(tx.date)
const docDate = new Date(dueDate)
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays <= 14) {
// Higher confidence for close dates, lower for wider window
const confidence = diffDays <= 5 ? 0.85 : 0.75
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
const candidate: DocumentMatchResult = {
transactionId: tx.id,
confidence,
method: 'amount_date',
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
}
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
bestMatch = candidate
}
}
}
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
if (fuzzyAmountMatch && supplierName) {
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
if (nameInDesc) {
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
const candidate: DocumentMatchResult = {
transactionId: tx.id,
confidence: 0.70,
method: 'amount_merchant',
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
}
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
bestMatch = candidate
}
}
}
}
return bestMatch
}
/**
* Match a receipt inbox item to transactions using weighted scoring.
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
*/
function matchReceiptDocument(
inboxItem: InvoiceInboxItem,
transactions: Transaction[]
): DocumentMatchResult | null {
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
if (!extraction) return null
const receiptTotal = extraction.totals?.total
const receiptDate = extraction.receipt?.date
const merchantName = extraction.merchant?.name
if (receiptTotal == null || receiptTotal === 0) {
console.log(`${tag} — no receipt total in extracted data`)
return null
}
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
let bestMatch: DocumentMatchResult | null = null
for (const tx of transactions) {
if (tx.receipt_id) continue // Skip already matched
const txAmount = Math.abs(tx.amount)
const txDate = new Date(tx.date)
// Calculate date variance
const dateVariance = receiptDate
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
: 3 // Default to tolerance boundary if no date
if (dateVariance > 3) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
if (amountVariance > 0.05) continue // Skip if >5% off
// Calculate merchant similarity
const txMerchant = tx.merchant_name || tx.description || ''
const merchantSimilarity = merchantName
? calculateMerchantSimilarity(merchantName, txMerchant)
: 0
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity
)
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
bestMatch = {
transactionId: tx.id,
confidence,
method: 'receipt_match',
matchReasons,
}
}
}
return bestMatch
}