From f5aef547cc19f095123cfd4a568148e8c32e38c3 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 25 Oct 2025 22:53:20 -0400 Subject: [PATCH] Feature: add TransactionService and ReceiptMatchingService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new services to extract business logic from PageModels: - TransactionService: Handles core transaction operations including duplicate detection, retrieval, and deletion - ReceiptMatchingService: Implements intelligent receipt-to-transaction matching using date, merchant, and amount scoring Both services follow the established service layer pattern with interfaces for dependency injection and improved testability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- MoneyMap/Program.cs | 2 + MoneyMap/Services/ReceiptMatchingService.cs | 237 ++++++++++++++++++++ MoneyMap/Services/TransactionService.cs | 79 +++++++ 3 files changed, 318 insertions(+) create mode 100644 MoneyMap/Services/ReceiptMatchingService.cs create mode 100644 MoneyMap/Services/TransactionService.cs diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index aa95896..e0ddf28 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -24,6 +24,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Dashboard services builder.Services.AddScoped(); diff --git a/MoneyMap/Services/ReceiptMatchingService.cs b/MoneyMap/Services/ReceiptMatchingService.cs new file mode 100644 index 0000000..a08510d --- /dev/null +++ b/MoneyMap/Services/ReceiptMatchingService.cs @@ -0,0 +1,237 @@ +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; } +} diff --git a/MoneyMap/Services/TransactionService.cs b/MoneyMap/Services/TransactionService.cs new file mode 100644 index 0000000..3ab7f94 --- /dev/null +++ b/MoneyMap/Services/TransactionService.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for core transaction operations including duplicate detection, +/// retrieval, and deletion. +/// +public interface ITransactionService +{ + /// + /// Checks if a transaction is a duplicate based on date, amount, name, memo, + /// account, and card. + /// + Task IsDuplicateAsync(Transaction transaction); + + /// + /// Gets a transaction by ID with optional related data. + /// + Task GetTransactionByIdAsync(long id, bool includeRelated = false); + + /// + /// Deletes a transaction and all related data (receipts, parse logs, line items). + /// + Task DeleteTransactionAsync(long id); +} + +public class TransactionService : ITransactionService +{ + private readonly MoneyMapContext _db; + + public TransactionService(MoneyMapContext db) + { + _db = db; + } + + public async Task IsDuplicateAsync(Transaction transaction) + { + return await _db.Transactions.AnyAsync(t => + t.Date == transaction.Date && + t.Amount == transaction.Amount && + t.Name == transaction.Name && + t.Memo == transaction.Memo && + t.AccountId == transaction.AccountId && + t.CardId == transaction.CardId); + } + + public async Task GetTransactionByIdAsync(long id, bool includeRelated = false) + { + var query = _db.Transactions.AsQueryable(); + + if (includeRelated) + { + query = query + .Include(t => t.Card) + .ThenInclude(c => c!.Account) + .Include(t => t.Account) + .Include(t => t.TransferToAccount) + .Include(t => t.Merchant) + .Include(t => t.Receipts) + .ThenInclude(r => r.LineItems); + } + + return await query.FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task DeleteTransactionAsync(long id) + { + var transaction = await _db.Transactions.FindAsync(id); + if (transaction == null) + return false; + + _db.Transactions.Remove(transaction); + await _db.SaveChangesAsync(); + return true; + } +}