188 lines
5.7 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|