Add receipt auto-mapping functionality

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 <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-12 13:07:35 -04:00
parent c0a6b1690f
commit 5511709a86
5 changed files with 257 additions and 5 deletions

View File

@@ -73,10 +73,20 @@
<!-- Receipts List -->
<div class="card shadow-sm">
<div class="card-header">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>All Receipts</strong>
<span class="text-muted">- @Model.Receipts.Count total</span>
</div>
@if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)))
{
<form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;">
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
🔗 Auto-Map Unmapped Receipts
</button>
</form>
}
</div>
<div class="card-body p-0">
@if (Model.Receipts.Any())
{

View File

@@ -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<ReceiptRow> Receipts { get; set; } = new();
@@ -117,6 +119,32 @@ namespace MoneyMap.Pages
return RedirectToPage();
}
public async Task<IActionResult> 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

View File

@@ -32,6 +32,7 @@ builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>(
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
// AI categorization service

View File

@@ -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<ReceiptParseResult> 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<IReceiptAutoMapper>();
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)

View File

@@ -0,0 +1,194 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services
{
public interface IReceiptAutoMapper
{
Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId);
Task<BulkAutoMapResult> 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<ReceiptAutoMapResult> 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<BulkAutoMapResult> 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<List<Transaction>> 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<Transaction>();
}
// 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<Transaction> 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<Transaction> 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
}
}