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