Files
accounted/extensions/general/receipt-ocr/lib/receipt-matcher.ts
T
Jakob WennbergandClaude Opus 4.6 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

187 lines
5.8 KiB
TypeScript

/**
* Receipt Matcher - Fuzzy matching between receipts and transactions
*
* Uses date variance, amount tolerance, and merchant name similarity
* to find potential transaction matches for receipts.
*
* Core matching utilities (levenshtein, merchant similarity) are imported
* from @/lib/documents/core-receipt-matcher and re-exported for backward compat.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
levenshteinDistance,
normalizeMerchantName,
DATE_TOLERANCE_DAYS,
AMOUNT_TOLERANCE_PERCENT,
MIN_MATCH_CONFIDENCE,
} from '@/lib/documents/core-receipt-matcher'
// Re-export core functions for backward compatibility
export { calculateMerchantSimilarity, levenshteinDistance, normalizeMerchantName }
/**
* Find potential transaction matches for a receipt
*/
export function findTransactionMatches(
receipt: Receipt,
transactions: Transaction[]
): ReceiptMatchCandidate[] {
if (!receipt.receipt_date || !receipt.total_amount) {
return []
}
const receiptDate = new Date(receipt.receipt_date)
const receiptAmount = Math.abs(receipt.total_amount)
const merchantName = (receipt.merchant_name || '').toLowerCase()
const candidates: ReceiptMatchCandidate[] = []
for (const transaction of transactions) {
// Skip already matched transactions
if (transaction.receipt_id) continue
// Only match expense transactions (negative amounts)
if (transaction.amount >= 0) continue
const transactionDate = new Date(transaction.date)
const transactionAmount = Math.abs(transaction.amount)
// Calculate date variance
const dateVariance = Math.abs(
(receiptDate.getTime() - transactionDate.getTime()) / (1000 * 60 * 60 * 24)
)
// Skip if outside date tolerance
if (dateVariance > DATE_TOLERANCE_DAYS) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptAmount - transactionAmount) / receiptAmount
// Skip if outside amount tolerance (unless it's a foreign receipt with currency conversion)
if (amountVariance > AMOUNT_TOLERANCE_PERCENT && !receipt.is_foreign_merchant) continue
if (amountVariance > 0.15) continue // Hard limit even for foreign
// Calculate merchant similarity
const transactionMerchant = (transaction.merchant_name || transaction.description || '').toLowerCase()
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
// Calculate overall confidence
const { confidence, matchReasons } = calculateReceiptMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity,
receipt,
transaction
)
if (confidence >= MIN_MATCH_CONFIDENCE) {
candidates.push({
transaction,
confidence,
matchReasons,
dateVariance,
amountVariance,
})
}
}
// Sort by confidence (highest first)
return candidates.sort((a, b) => b.confidence - a.confidence)
}
/**
* Wrapper around core calculateMatchConfidence that adds receipt-specific
* MCC bonus logic for backward compatibility.
*/
function calculateReceiptMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const result = calculateMatchConfidence(dateVariance, amountVariance, merchantSimilarity)
// Bonus for MCC match (receipt-specific: restaurant MCC codes)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
result.matchReasons.push('Restaurang MCC matchar')
result.confidence = Math.round((result.confidence + 0.1) * 100) / 100
}
}
return result
}
/**
* Get unmatched transactions for a user
* (transactions without linked receipts)
*/
export function filterUnmatchedTransactions(transactions: Transaction[]): Transaction[] {
return transactions.filter((t) => !t.receipt_id && t.amount < 0)
}
/**
* Get unmatched receipts for a user
* (confirmed receipts without linked transactions)
*/
export function filterUnmatchedReceipts(receipts: Receipt[]): Receipt[] {
return receipts.filter((r) => !r.matched_transaction_id && r.status === 'confirmed')
}
/**
* Auto-match receipts to transactions
* Returns matches with confidence >= threshold
*/
export function autoMatchReceipts(
receipts: Receipt[],
transactions: Transaction[],
confidenceThreshold: number = 0.8
): Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> {
const results: Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> = []
for (const receipt of receipts) {
if (receipt.matched_transaction_id) continue
const matches = findTransactionMatches(receipt, transactions)
const bestMatch = matches[0]
if (bestMatch && bestMatch.confidence >= confidenceThreshold) {
results.push({ receipt, match: bestMatch })
// Mark the transaction as used so it's not matched again
// (Note: this mutates the transaction, caller should be aware)
}
}
return results
}
/**
* Calculate match statistics for queue display
*/
export function calculateMatchStats(
receipts: Receipt[],
transactions: Transaction[]
): {
unmatchedReceiptsCount: number
unmatchedTransactionsCount: number
pendingReviewCount: number
matchedCount: number
} {
const unmatchedReceipts = filterUnmatchedReceipts(receipts)
const unmatchedTransactions = filterUnmatchedTransactions(transactions)
const pendingReview = receipts.filter((r) => r.status === 'extracted')
const matched = receipts.filter((r) => r.matched_transaction_id)
return {
unmatchedReceiptsCount: unmatchedReceipts.length,
unmatchedTransactionsCount: unmatchedTransactions.length,
pendingReviewCount: pendingReview.length,
matchedCount: matched.length,
}
}