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:
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
194
MoneyMap/Services/ReceiptAutoMapper.cs
Normal file
194
MoneyMap/Services/ReceiptAutoMapper.cs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user