Files
accounted/lib/receipts/receipt-matcher.ts
T

289 lines
8.5 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.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
// Matching configuration
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
/**
* 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 } = calculateMatchConfidence(
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)
}
/**
* Calculate merchant name similarity using Levenshtein distance
*/
function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
// Normalize names
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Check for exact match
if (n1 === n2) return 1
// Check if one contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Check for word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Calculate Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
const similarity = 1 - distance / maxLength
return similarity
}
/**
* Normalize merchant name for comparison
*/
function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
// Create matrix
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
// Initialize first row and column
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
// Fill the matrix
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate overall match confidence and reasons
*/
function calculateMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%)
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
matchReasons.push('Restaurang MCC matchar')
weightedScore += 0.1
}
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
}
/**
* 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,
}
}