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:
@@ -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 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
|
||||
|
||||
Reference in New Issue
Block a user