diff --git a/MoneyMap/Pages/Receipts.cshtml.cs b/MoneyMap/Pages/Receipts.cshtml.cs index aa5c78d..ee399a5 100644 --- a/MoneyMap/Pages/Receipts.cshtml.cs +++ b/MoneyMap/Pages/Receipts.cshtml.cs @@ -11,12 +11,14 @@ namespace MoneyMap.Pages private readonly MoneyMapContext _db; private readonly IReceiptManager _receiptManager; private readonly IReceiptAutoMapper _autoMapper; + private readonly IReceiptMatchingService _receiptMatchingService; - public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper) + public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService) { _db = db; _receiptManager = receiptManager; _autoMapper = autoMapper; + _receiptMatchingService = receiptMatchingService; } public List Receipts { get; set; } = new(); @@ -242,161 +244,36 @@ namespace MoneyMap.Pages }).ToList(); // Load matching transactions for each unmapped receipt - var transactionsWithReceiptsList = await _db.Receipts - .Where(r => r.TransactionId != null) - .Select(r => r.TransactionId!.Value) - .ToListAsync(); - - var transactionsWithReceipts = new HashSet(transactionsWithReceiptsList); + var transactionsWithReceipts = await _receiptMatchingService.GetTransactionIdsWithReceiptsAsync(); var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList(); foreach (var receipt in unmappedReceipts) { - var matches = await FindMatchingTransactionsForReceipt(receipt, transactionsWithReceipts); - ReceiptTransactionMatches[receipt.Id] = matches; - } - } - - private async Task> FindMatchingTransactionsForReceipt(ReceiptRow receipt, HashSet transactionsWithReceipts) - { - var query = _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .Where(t => !transactionsWithReceipts.Contains(t.Id)) - .AsQueryable(); - - // If receipt has a date, filter by date range - if (receipt.ReceiptDate.HasValue && receipt.DueDate.HasValue) - { - // For bills with due dates: use range from bill date to due date + 5 days - // (to account for auto-pay processing delays, weekends, etc.) - var minDate = receipt.ReceiptDate.Value; - var maxDate = receipt.DueDate.Value.AddDays(5); - query = query.Where(t => t.Date >= minDate && t.Date <= maxDate); - } - else if (receipt.ReceiptDate.HasValue) - { - // For regular receipts: use +/- 3 days (this is the primary filter) - var minDate = receipt.ReceiptDate.Value.AddDays(-3); - var maxDate = receipt.ReceiptDate.Value.AddDays(3); - query = query.Where(t => t.Date >= minDate && t.Date <= maxDate); - } - - // Get all candidates within date range (don't limit yet - we need all matches) - var candidates = await query - .ToListAsync(); - - // Sort by merchant/name relevance using word matching - if (!string.IsNullOrWhiteSpace(receipt.Merchant)) - { - var receiptWords = receipt.Merchant.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - - candidates = candidates - .OrderByDescending(t => - { - var merchantName = t.Merchant?.Name?.ToLower() ?? ""; - var transactionName = t.Name?.ToLower() ?? ""; - - // Exact match - if (merchantName == receipt.Merchant.ToLower() || transactionName == receipt.Merchant.ToLower()) - return 1000; - - // Count matching words - var merchantWords = merchantName.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - var transactionWords = transactionName.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - - var merchantMatches = receiptWords.Count(rw => merchantWords.Any(mw => mw.Contains(rw) || rw.Contains(mw))); - var transactionMatches = receiptWords.Count(rw => transactionWords.Any(tw => tw.Contains(rw) || rw.Contains(tw))); - - // Return the higher match count - return Math.Max(merchantMatches * 10, transactionMatches * 10); - }) - .ThenByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .ToList(); - } - else - { - // No merchant filter, just sort by date - candidates = candidates - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .ToList(); - } - - // Filter by amount (±10% tolerance) if receipt has a total - if (receipt.Total.HasValue) - { - var receiptTotal = Math.Round(Math.Abs(receipt.Total.Value), 2); - var tolerance = receiptTotal * 0.10m; // 10% tolerance - var minAmount = receiptTotal - tolerance; - var maxAmount = receiptTotal + tolerance; - - candidates = candidates - .Where(t => - { - var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); - return transactionAmount >= minAmount && transactionAmount <= maxAmount; - }) - .ToList(); - } - - // Calculate match scores and mark close amount matches - var options = candidates.Select(t => - { - var option = new TransactionOption + var criteria = new ReceiptMatchCriteria { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Amount = t.Amount, - MerchantName = t.Merchant?.Name, - PaymentMethod = t.PaymentMethodLabel, - IsExactAmount = false, - IsCloseAmount = false + ReceiptDate = receipt.ReceiptDate, + DueDate = receipt.DueDate, + Total = receipt.Total, + MerchantName = receipt.Merchant, + ExcludeTransactionIds = transactionsWithReceipts }; - // Amount matching flags - if (receipt.Total.HasValue) + var matches = await _receiptMatchingService.FindMatchingTransactionsAsync(criteria); + + // Convert TransactionMatch to TransactionOption + ReceiptTransactionMatches[receipt.Id] = matches.Select(m => new TransactionOption { - var receiptTotal = Math.Round(Math.Abs(receipt.Total.Value), 2); - var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); - option.IsExactAmount = transactionAmount == receiptTotal; - var tolerance = receiptTotal * 0.10m; - option.IsCloseAmount = !option.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance; - } - - return option; - }).ToList(); - - // If no date-filtered matches, fall back to recent transactions - if (!options.Any() && !receipt.ReceiptDate.HasValue) - { - var fallback = await _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .Where(t => !transactionsWithReceipts.Contains(t.Id)) - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .Take(50) - .Select(t => new TransactionOption - { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Amount = t.Amount, - MerchantName = t.Merchant != null ? t.Merchant.Name : null, - PaymentMethod = t.PaymentMethodLabel - }) - .ToListAsync(); - - options = fallback; + Id = m.Id, + Date = m.Date, + Name = m.Name, + Amount = m.Amount, + MerchantName = m.MerchantName, + PaymentMethod = m.PaymentMethod, + IsExactAmount = m.IsExactAmount, + IsCloseAmount = m.IsCloseAmount + }).ToList(); } - - return options; } public class ReceiptRow