ynab-amazon-helper/matcher.ts
2025-11-04 12:27:02 -05:00

188 lines
5.7 KiB
TypeScript

import type { AmazonOrder, YNABTransaction, TransactionMatch, MatchingOptions } from './types';
import { YNABClient } from './ynab-client';
export class TransactionMatcher {
private options: MatchingOptions;
constructor(options: Partial<MatchingOptions> = {}) {
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<string>();
// 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,
};
}
}