Refactor: use ReceiptMatchingService in Receipts page

Extract 140+ lines of receipt-to-transaction matching logic into ReceiptMatchingService. The PageModel now delegates matching to the service, simplifying the code and improving testability.

The matching algorithm (date filtering, merchant word-based scoring, amount tolerance) remains unchanged but is now centralized in a dedicated service.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-25 22:53:46 -04:00
parent cedfe98789
commit f4f7faaccc

View File

@@ -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<ReceiptRow> 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<long>(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<List<TransactionOption>> FindMatchingTransactionsForReceipt(ReceiptRow receipt, HashSet<long> transactionsWithReceipts)
var criteria = new ReceiptMatchCriteria
{
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
{
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 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;
}
var matches = await _receiptMatchingService.FindMatchingTransactionsAsync(criteria);
return option;
// Convert TransactionMatch to TransactionOption
ReceiptTransactionMatches[receipt.Id] = matches.Select(m => new TransactionOption
{
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();
// 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;
}
return options;
}
public class ReceiptRow