Files
MoneyMap/MoneyMap.Core/Services/ReceiptMatchingService.cs
T
2026-04-20 18:18:20 -04:00

238 lines
8.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services;
/// <summary>
/// Service for matching receipts to transactions based on date, merchant, and amount.
/// </summary>
public interface IReceiptMatchingService
{
/// <summary>
/// Finds matching transactions for a receipt based on date range, merchant name,
/// and amount tolerance. Returns transactions sorted by relevance.
/// </summary>
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
/// <summary>
/// Gets a set of transaction IDs that already have receipts mapped to them.
/// </summary>
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
}
public class ReceiptMatchingService : IReceiptMatchingService
{
private readonly MoneyMapContext _db;
public ReceiptMatchingService(MoneyMapContext db)
{
_db = db;
}
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
{
var transactionIds = await _db.Receipts
.Where(r => r.TransactionId != null)
.Select(r => r.TransactionId!.Value)
.ToListAsync();
return new HashSet<long>(transactionIds);
}
public async Task<List<TransactionMatch>> 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<Transaction> ApplyDateFilter(IQueryable<Transaction> 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<Transaction> SortByMerchantRelevance(List<Transaction> 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<Transaction> FilterByAmountTolerance(List<Transaction> 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<TransactionMatch> ConvertToMatches(List<Transaction> 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<List<TransactionMatch>> GetFallbackMatches(HashSet<long> 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();
}
}
/// <summary>
/// Criteria for matching receipts to transactions.
/// </summary>
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<long> ExcludeTransactionIds { get; set; } = new();
}
/// <summary>
/// Represents a transaction that matches a receipt, with scoring information.
/// </summary>
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; }
}