- 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>
187 lines
5.8 KiB
TypeScript
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,
|
|
}
|
|
}
|