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 MoneyMapContext _db;
|
||||||
private readonly IReceiptManager _receiptManager;
|
private readonly IReceiptManager _receiptManager;
|
||||||
private readonly IReceiptAutoMapper _autoMapper;
|
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;
|
_db = db;
|
||||||
_receiptManager = receiptManager;
|
_receiptManager = receiptManager;
|
||||||
_autoMapper = autoMapper;
|
_autoMapper = autoMapper;
|
||||||
|
_receiptMatchingService = receiptMatchingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ReceiptRow> Receipts { get; set; } = new();
|
public List<ReceiptRow> Receipts { get; set; } = new();
|
||||||
@@ -242,161 +244,36 @@ namespace MoneyMap.Pages
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Load matching transactions for each unmapped receipt
|
// Load matching transactions for each unmapped receipt
|
||||||
var transactionsWithReceiptsList = await _db.Receipts
|
var transactionsWithReceipts = await _receiptMatchingService.GetTransactionIdsWithReceiptsAsync();
|
||||||
.Where(r => r.TransactionId != null)
|
|
||||||
.Select(r => r.TransactionId!.Value)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var transactionsWithReceipts = new HashSet<long>(transactionsWithReceiptsList);
|
|
||||||
|
|
||||||
var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList();
|
var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList();
|
||||||
|
|
||||||
foreach (var receipt in unmappedReceipts)
|
foreach (var receipt in unmappedReceipts)
|
||||||
{
|
{
|
||||||
var matches = await FindMatchingTransactionsForReceipt(receipt, transactionsWithReceipts);
|
var criteria = new ReceiptMatchCriteria
|
||||||
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
|
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
ReceiptDate = receipt.ReceiptDate,
|
||||||
Date = t.Date,
|
DueDate = receipt.DueDate,
|
||||||
Name = t.Name,
|
Total = receipt.Total,
|
||||||
Amount = t.Amount,
|
MerchantName = receipt.Merchant,
|
||||||
MerchantName = t.Merchant?.Name,
|
ExcludeTransactionIds = transactionsWithReceipts
|
||||||
PaymentMethod = t.PaymentMethodLabel,
|
|
||||||
IsExactAmount = false,
|
|
||||||
IsCloseAmount = false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Amount matching flags
|
var matches = await _receiptMatchingService.FindMatchingTransactionsAsync(criteria);
|
||||||
if (receipt.Total.HasValue)
|
|
||||||
|
// Convert TransactionMatch to TransactionOption
|
||||||
|
ReceiptTransactionMatches[receipt.Id] = matches.Select(m => new TransactionOption
|
||||||
{
|
{
|
||||||
var receiptTotal = Math.Round(Math.Abs(receipt.Total.Value), 2);
|
Id = m.Id,
|
||||||
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
Date = m.Date,
|
||||||
option.IsExactAmount = transactionAmount == receiptTotal;
|
Name = m.Name,
|
||||||
var tolerance = receiptTotal * 0.10m;
|
Amount = m.Amount,
|
||||||
option.IsCloseAmount = !option.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance;
|
MerchantName = m.MerchantName,
|
||||||
}
|
PaymentMethod = m.PaymentMethod,
|
||||||
|
IsExactAmount = m.IsExactAmount,
|
||||||
return option;
|
IsCloseAmount = m.IsCloseAmount
|
||||||
}).ToList();
|
}).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
|
public class ReceiptRow
|
||||||
|
|||||||
Reference in New Issue
Block a user