import type { AmazonOrder, YNABTransaction, TransactionMatch, MatchingOptions } from './types'; import { YNABClient } from './ynab-client'; export class TransactionMatcher { private options: MatchingOptions; constructor(options: Partial = {}) { this.options = { dateToleranceDays: options.dateToleranceDays ?? 3, amountToleranceDollars: options.amountToleranceDollars ?? 0.5, }; } /** * Match YNAB transactions with Amazon orders * @param ynabTransactions - Array of YNAB transactions * @param amazonOrders - Array of Amazon orders * @returns Array of transaction matches */ matchTransactions( ynabTransactions: YNABTransaction[], amazonOrders: AmazonOrder[] ): TransactionMatch[] { const matches: TransactionMatch[] = []; const usedOrderIds = new Set(); // Sort both arrays by date for efficient matching const sortedTransactions = [...ynabTransactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() ); const sortedOrders = [...amazonOrders].sort((a, b) => a.orderDate.getTime() - b.orderDate.getTime() ); for (const transaction of sortedTransactions) { const transactionDate = new Date(transaction.date); const transactionAmount = Math.abs(YNABClient.milliunitsToDollars(transaction.amount)); let bestMatch: AmazonOrder | null = null; let bestConfidence = 0; // Find potential matches within date range for (const order of sortedOrders) { // Skip already matched orders if (usedOrderIds.has(order.orderId)) continue; // Check date proximity const daysDifference = this.getDaysDifference(transactionDate, order.orderDate); if (daysDifference > this.options.dateToleranceDays) continue; // Check amount proximity const amountDifference = Math.abs(transactionAmount - order.total); if (amountDifference > this.options.amountToleranceDollars) continue; // Calculate confidence score const confidence = this.calculateConfidence( transactionDate, order.orderDate, transactionAmount, order.total, transaction.payee_name, order ); if (confidence > bestConfidence) { bestConfidence = confidence; bestMatch = order; } } // Create match entry if (bestMatch) { usedOrderIds.add(bestMatch.orderId); } matches.push({ ynabTransaction: transaction, amazonOrder: bestMatch, matchConfidence: bestConfidence, suggestedCategory: null, // Will be filled in by OpenAI status: 'pending', }); } return matches; } /** * Calculate confidence score for a potential match * @param transactionDate - YNAB transaction date * @param orderDate - Amazon order date * @param transactionAmount - YNAB transaction amount in dollars * @param orderAmount - Amazon order amount in dollars * @param payeeName - YNAB payee name * @param order - Amazon order * @returns Confidence score (0-1) */ private calculateConfidence( transactionDate: Date, orderDate: Date, transactionAmount: number, orderAmount: number, payeeName: string | null, order: AmazonOrder ): number { let score = 0; // Amount matching (50% weight) const amountDifference = Math.abs(transactionAmount - orderAmount); const amountScore = Math.max(0, 1 - (amountDifference / this.options.amountToleranceDollars)); score += amountScore * 0.5; // Date matching (30% weight) const daysDifference = this.getDaysDifference(transactionDate, orderDate); const dateScore = Math.max(0, 1 - (daysDifference / this.options.dateToleranceDays)); score += dateScore * 0.3; // Payee name matching (20% weight) if (payeeName) { const normalizedPayee = payeeName.toLowerCase(); if ( normalizedPayee.includes('amazon') || normalizedPayee.includes('amzn') ) { score += 0.2; } } return Math.min(1, score); } /** * Get the difference in days between two dates * @param date1 - First date * @param date2 - Second date * @returns Absolute difference in days */ private getDaysDifference(date1: Date, date2: Date): number { const msPerDay = 24 * 60 * 60 * 1000; return Math.abs((date1.getTime() - date2.getTime()) / msPerDay); } /** * Filter matches by minimum confidence threshold * @param matches - Array of transaction matches * @param minConfidence - Minimum confidence threshold (0-1) * @returns Filtered matches */ filterByConfidence( matches: TransactionMatch[], minConfidence: number ): TransactionMatch[] { return matches.filter((match) => match.matchConfidence >= minConfidence); } /** * Get statistics about the matches * @param matches - Array of transaction matches * @returns Match statistics */ getMatchStatistics(matches: TransactionMatch[]): { total: number; matched: number; unmatched: number; highConfidence: number; mediumConfidence: number; lowConfidence: number; } { const total = matches.length; const matched = matches.filter((m) => m.amazonOrder !== null).length; const unmatched = total - matched; const highConfidence = matches.filter((m) => m.matchConfidence >= 0.8).length; const mediumConfidence = matches.filter( (m) => m.matchConfidence >= 0.5 && m.matchConfidence < 0.8 ).length; const lowConfidence = matches.filter((m) => m.matchConfidence < 0.5).length; return { total, matched, unmatched, highConfidence, mediumConfidence, lowConfidence, }; } }