From 5511709a863143e193e322b0ccbf442cc9e06f56 Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 12 Oct 2025 13:07:35 -0400 Subject: [PATCH] Add receipt auto-mapping functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented automatic mapping of parsed receipts to matching transactions, with both automatic (after parsing) and manual (via button) triggers. New Service - ReceiptAutoMapper: - AutoMapReceiptAsync: Maps a single receipt to a transaction - AutoMapUnmappedReceiptsAsync: Bulk maps all unmapped receipts - FindMatchingTransactionsAsync: Smart matching algorithm using: - Receipt date (+/- 3 days for processing delays) - Merchant name (matches against transaction merchant or name) - Total amount (within $0.10 tolerance) - Excludes transactions that already have receipts - Returns single match, multiple matches, or no match Matching Strategy: - Single match: Automatically maps - Multiple matches: Reports count for manual review - No match: Reports for manual intervention - Not parsed: Skips (requires merchant, date, or total) Integration Points: - OpenAIReceiptParser: Triggers auto-mapping after successful parse (only for unmapped receipts, errors ignored to not fail parse) - Receipts page: Added "Auto-Map Unmapped Receipts" button - Only shows when unmapped parsed receipts exist - Displays detailed results (mapped count, no match, multi-match) This enables a streamlined workflow: 1. Upload receipt → 2. Parse receipt → 3. Auto-map to transaction Users can also trigger bulk auto-mapping for all unmapped receipts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- MoneyMap/Pages/Receipts.cshtml | 16 +- MoneyMap/Pages/Receipts.cshtml.cs | 30 +++- MoneyMap/Program.cs | 1 + MoneyMap/Services/OpenAIReceiptParser.cs | 21 ++- MoneyMap/Services/ReceiptAutoMapper.cs | 194 +++++++++++++++++++++++ 5 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 MoneyMap/Services/ReceiptAutoMapper.cs diff --git a/MoneyMap/Pages/Receipts.cshtml b/MoneyMap/Pages/Receipts.cshtml index f4bc2e3..7fd5fef 100644 --- a/MoneyMap/Pages/Receipts.cshtml +++ b/MoneyMap/Pages/Receipts.cshtml @@ -73,9 +73,19 @@
-
- All Receipts - - @Model.Receipts.Count total +
+
+ All Receipts + - @Model.Receipts.Count total +
+ @if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue))) + { +
+ +
+ }
@if (Model.Receipts.Any()) diff --git a/MoneyMap/Pages/Receipts.cshtml.cs b/MoneyMap/Pages/Receipts.cshtml.cs index b634a6b..06d5bab 100644 --- a/MoneyMap/Pages/Receipts.cshtml.cs +++ b/MoneyMap/Pages/Receipts.cshtml.cs @@ -11,11 +11,13 @@ namespace MoneyMap.Pages { private readonly MoneyMapContext _db; private readonly IReceiptManager _receiptManager; + private readonly IReceiptAutoMapper _autoMapper; - public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager) + public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper) { _db = db; _receiptManager = receiptManager; + _autoMapper = autoMapper; } public List Receipts { get; set; } = new(); @@ -117,6 +119,32 @@ namespace MoneyMap.Pages return RedirectToPage(); } + public async Task OnPostAutoMapUnmappedAsync() + { + var result = await _autoMapper.AutoMapUnmappedReceiptsAsync(); + + if (result.MappedCount > 0) + { + Message = $"Successfully auto-mapped {result.MappedCount} receipt(s). " + + $"{result.NoMatchCount} had no matching transaction. " + + $"{result.MultipleMatchesCount} had multiple potential matches."; + IsSuccess = true; + } + else if (result.TotalProcessed == 0) + { + Message = "No unmapped parsed receipts found to process."; + IsSuccess = false; + } + else + { + Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no matching transaction. " + + $"{result.MultipleMatchesCount} had multiple potential matches."; + IsSuccess = false; + } + + return RedirectToPage(); + } + private async Task LoadReceiptsAsync() { var receipts = await _db.Receipts diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index c2802d1..54c9b80 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -32,6 +32,7 @@ builder.Services.AddScoped( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); // AI categorization service diff --git a/MoneyMap/Services/OpenAIReceiptParser.cs b/MoneyMap/Services/OpenAIReceiptParser.cs index 9bd345f..50d09e6 100644 --- a/MoneyMap/Services/OpenAIReceiptParser.cs +++ b/MoneyMap/Services/OpenAIReceiptParser.cs @@ -27,19 +27,22 @@ namespace MoneyMap.Services private readonly IConfiguration _configuration; private readonly HttpClient _httpClient; private readonly IMerchantService _merchantService; + private readonly IServiceProvider _serviceProvider; public OpenAIReceiptParser( MoneyMapContext db, IWebHostEnvironment environment, IConfiguration configuration, HttpClient httpClient, - IMerchantService merchantService) + IMerchantService merchantService, + IServiceProvider serviceProvider) { _db = db; _environment = environment; _configuration = configuration; _httpClient = httpClient; _merchantService = merchantService; + _serviceProvider = serviceProvider; } public async Task ParseReceiptAsync(long receiptId) @@ -138,6 +141,22 @@ namespace MoneyMap.Services _db.ReceiptParseLogs.Add(parseLog); await _db.SaveChangesAsync(); + // Attempt auto-mapping after successful parse (only if receipt is not already mapped) + if (!receipt.TransactionId.HasValue) + { + try + { + // Use service locator pattern to avoid circular dependency + using var scope = _serviceProvider.CreateScope(); + var autoMapper = scope.ServiceProvider.GetRequiredService(); + await autoMapper.AutoMapReceiptAsync(receiptId); + } + catch + { + // Ignore auto-mapping errors - parsing was successful + } + } + return ReceiptParseResult.Success($"Parsed {lineItems.Count} line items from receipt."); } catch (Exception ex) diff --git a/MoneyMap/Services/ReceiptAutoMapper.cs b/MoneyMap/Services/ReceiptAutoMapper.cs new file mode 100644 index 0000000..5d49c9b --- /dev/null +++ b/MoneyMap/Services/ReceiptAutoMapper.cs @@ -0,0 +1,194 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services +{ + public interface IReceiptAutoMapper + { + Task AutoMapReceiptAsync(long receiptId); + Task AutoMapUnmappedReceiptsAsync(); + } + + public class ReceiptAutoMapper : IReceiptAutoMapper + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + + public ReceiptAutoMapper(MoneyMapContext db, IReceiptManager receiptManager) + { + _db = db; + _receiptManager = receiptManager; + } + + public async Task AutoMapReceiptAsync(long receiptId) + { + var receipt = await _db.Receipts + .Include(r => r.Transaction) + .FirstOrDefaultAsync(r => r.Id == receiptId); + + if (receipt == null) + return ReceiptAutoMapResult.Failure("Receipt not found."); + + // If already mapped, skip + if (receipt.TransactionId.HasValue) + return ReceiptAutoMapResult.AlreadyMapped(receipt.TransactionId.Value); + + // If receipt has not been parsed (no merchant, date, or total), skip + if (string.IsNullOrWhiteSpace(receipt.Merchant) && !receipt.ReceiptDate.HasValue && !receipt.Total.HasValue) + return ReceiptAutoMapResult.NotParsed(); + + // Find matching transactions based on parsed data + var candidateTransactions = await FindMatchingTransactionsAsync(receipt); + + if (candidateTransactions.Count == 0) + return ReceiptAutoMapResult.NoMatch(); + + if (candidateTransactions.Count > 1) + return ReceiptAutoMapResult.MultipleMatches(candidateTransactions); + + // Single match found - auto-map it + var transaction = candidateTransactions[0]; + var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transaction.Id); + + if (success) + return ReceiptAutoMapResult.Success(transaction.Id); + else + return ReceiptAutoMapResult.Failure("Failed to map receipt to transaction."); + } + + public async Task AutoMapUnmappedReceiptsAsync() + { + var unmappedReceipts = await _db.Receipts + .Where(r => r.TransactionId == null) + .Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null) // Only parsed receipts + .ToListAsync(); + + var result = new BulkAutoMapResult(); + + foreach (var receipt in unmappedReceipts) + { + var mapResult = await AutoMapReceiptAsync(receipt.Id); + + if (mapResult.Status == AutoMapStatus.Success) + { + result.MappedCount++; + } + else if (mapResult.Status == AutoMapStatus.MultipleMatches) + { + result.MultipleMatchesCount++; + } + else if (mapResult.Status == AutoMapStatus.NoMatch) + { + result.NoMatchCount++; + } + } + + result.TotalProcessed = unmappedReceipts.Count; + return result; + } + + private async Task> FindMatchingTransactionsAsync(Receipt receipt) + { + var query = _db.Transactions + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Merchant) + .AsQueryable(); + + // Start with date range filter (if we have a receipt date) + if (receipt.ReceiptDate.HasValue) + { + // Allow +/- 3 days for transaction date to account for processing delays + var minDate = receipt.ReceiptDate.Value.AddDays(-3); + var maxDate = receipt.ReceiptDate.Value.AddDays(3); + query = query.Where(t => t.Date >= minDate && t.Date <= maxDate); + } + else + { + // If no receipt date, can't narrow down effectively + return new List(); + } + + // Filter by merchant if available + if (!string.IsNullOrWhiteSpace(receipt.Merchant)) + { + // Try to find matching merchant name + query = query.Where(t => + (t.Merchant != null && t.Merchant.Name.Contains(receipt.Merchant)) || + t.Name.Contains(receipt.Merchant)); + } + + // Get candidates + var candidates = await query.ToListAsync(); + + // If we have a total amount, filter by amount match + if (receipt.Total.HasValue) + { + // Allow for slight variations in amount (e.g., due to rounding) + // Match if transaction amount is within $0.10 of receipt total + var receiptTotal = Math.Abs(receipt.Total.Value); + candidates = candidates + .Where(t => Math.Abs(Math.Abs(t.Amount) - receiptTotal) <= 0.10m) + .ToList(); + } + + // Exclude transactions that already have receipts + var transactionsWithReceipts = await _db.Receipts + .Where(r => r.TransactionId != null) + .Select(r => r.TransactionId!.Value) + .Distinct() + .ToListAsync(); + + candidates = candidates + .Where(t => !transactionsWithReceipts.Contains(t.Id)) + .ToList(); + + return candidates; + } + } + + public class ReceiptAutoMapResult + { + public AutoMapStatus Status { get; init; } + public long? TransactionId { get; init; } + public List MultipleMatches { get; init; } = new(); + public string? Message { get; init; } + + public static ReceiptAutoMapResult Success(long transactionId) => + new() { Status = AutoMapStatus.Success, TransactionId = transactionId }; + + public static ReceiptAutoMapResult AlreadyMapped(long transactionId) => + new() { Status = AutoMapStatus.AlreadyMapped, TransactionId = transactionId }; + + public static ReceiptAutoMapResult NoMatch() => + new() { Status = AutoMapStatus.NoMatch, Message = "No matching transaction found." }; + + public static ReceiptAutoMapResult MultipleMatches(List matches) => + new() { Status = AutoMapStatus.MultipleMatches, MultipleMatches = matches, Message = $"Found {matches.Count} potential matches." }; + + public static ReceiptAutoMapResult NotParsed() => + new() { Status = AutoMapStatus.NotParsed, Message = "Receipt has not been parsed yet." }; + + public static ReceiptAutoMapResult Failure(string message) => + new() { Status = AutoMapStatus.Failed, Message = message }; + } + + public class BulkAutoMapResult + { + public int TotalProcessed { get; set; } + public int MappedCount { get; set; } + public int NoMatchCount { get; set; } + public int MultipleMatchesCount { get; set; } + } + + public enum AutoMapStatus + { + Success, + AlreadyMapped, + NoMatch, + MultipleMatches, + NotParsed, + Failed + } +}