using Microsoft.EntityFrameworkCore; using MoneyMap.Data; using MoneyMap.Models; namespace MoneyMap.Services; /// /// Service for matching receipts to transactions based on date, merchant, and amount. /// public interface IReceiptMatchingService { /// /// Finds matching transactions for a receipt based on date range, merchant name, /// and amount tolerance. Returns transactions sorted by relevance. /// Task> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria); /// /// Gets a set of transaction IDs that already have receipts mapped to them. /// Task> GetTransactionIdsWithReceiptsAsync(); } public class ReceiptMatchingService : IReceiptMatchingService { private readonly MoneyMapContext _db; public ReceiptMatchingService(MoneyMapContext db) { _db = db; } public async Task> GetTransactionIdsWithReceiptsAsync() { var transactionIds = await _db.Receipts .Where(r => r.TransactionId != null) .Select(r => r.TransactionId!.Value) .ToListAsync(); return new HashSet(transactionIds); } public async Task> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria) { var query = _db.Transactions .Include(t => t.Card) .Include(t => t.Account) .Include(t => t.Merchant) .Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id)) .AsQueryable(); // Apply date filtering based on receipt type query = ApplyDateFilter(query, criteria); // Get all candidates within date range var candidates = await query.ToListAsync(); // Sort by merchant/name relevance using word matching if (!string.IsNullOrWhiteSpace(criteria.MerchantName)) { candidates = SortByMerchantRelevance(candidates, criteria.MerchantName); } 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 (criteria.Total.HasValue) { candidates = FilterByAmountTolerance(candidates, criteria.Total.Value); } // Convert to match results with scoring var matches = ConvertToMatches(candidates, criteria); // If no date-filtered matches, fall back to recent transactions if (!matches.Any() && !criteria.ReceiptDate.HasValue) { matches = await GetFallbackMatches(criteria.ExcludeTransactionIds); } return matches; } private static IQueryable ApplyDateFilter(IQueryable query, ReceiptMatchCriteria criteria) { // For bills with due dates: use range from bill date to due date + 5 days // (to account for auto-pay processing delays, weekends, etc.) if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue) { var minDate = criteria.ReceiptDate.Value; var maxDate = criteria.DueDate.Value.AddDays(5); return query.Where(t => t.Date >= minDate && t.Date <= maxDate); } // For regular receipts: use +/- 3 days if (criteria.ReceiptDate.HasValue) { var minDate = criteria.ReceiptDate.Value.AddDays(-3); var maxDate = criteria.ReceiptDate.Value.AddDays(3); return query.Where(t => t.Date >= minDate && t.Date <= maxDate); } return query; } private static List SortByMerchantRelevance(List candidates, string merchantName) { var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); return candidates .OrderByDescending(t => { var merchantNameLower = t.Merchant?.Name?.ToLower() ?? ""; var transactionNameLower = t.Name?.ToLower() ?? ""; // Exact match gets highest score if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower()) return 1000; // Count matching words var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); var transactionWords = transactionNameLower.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(); } private static List FilterByAmountTolerance(List candidates, decimal total) { var receiptTotal = Math.Round(Math.Abs(total), 2); var tolerance = receiptTotal * 0.10m; // 10% tolerance var minAmount = receiptTotal - tolerance; var maxAmount = receiptTotal + tolerance; return candidates .Where(t => { var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); return transactionAmount >= minAmount && transactionAmount <= maxAmount; }) .ToList(); } private static List ConvertToMatches(List candidates, ReceiptMatchCriteria criteria) { return candidates.Select(t => { var match = new TransactionMatch { Id = t.Id, Date = t.Date, Name = t.Name, Amount = t.Amount, MerchantName = t.Merchant?.Name, PaymentMethod = t.PaymentMethodLabel, IsExactAmount = false, IsCloseAmount = false }; // Amount matching flags if (criteria.Total.HasValue) { var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2); var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); match.IsExactAmount = transactionAmount == receiptTotal; var tolerance = receiptTotal * 0.10m; match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance; } return match; }).ToList(); } private async Task> GetFallbackMatches(HashSet excludeIds) { return await _db.Transactions .Include(t => t.Card) .Include(t => t.Account) .Include(t => t.Merchant) .Where(t => !excludeIds.Contains(t.Id)) .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Id) .Take(50) .Select(t => new TransactionMatch { Id = t.Id, Date = t.Date, Name = t.Name, Amount = t.Amount, MerchantName = t.Merchant != null ? t.Merchant.Name : null, PaymentMethod = t.PaymentMethodLabel, IsExactAmount = false, IsCloseAmount = false }) .ToListAsync(); } } /// /// Criteria for matching receipts to transactions. /// public class ReceiptMatchCriteria { public DateTime? ReceiptDate { get; set; } public DateTime? DueDate { get; set; } public decimal? Total { get; set; } public string? MerchantName { get; set; } public HashSet ExcludeTransactionIds { get; set; } = new(); } /// /// Represents a transaction that matches a receipt, with scoring information. /// public class TransactionMatch { public long Id { get; set; } public DateTime Date { get; set; } public string Name { get; set; } = ""; public decimal Amount { get; set; } public string? MerchantName { get; set; } public string PaymentMethod { get; set; } = ""; public bool IsExactAmount { get; set; } public bool IsCloseAmount { get; set; } }