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,9 +73,19 @@
|
|||||||
|
|
||||||
<!-- Receipts List -->
|
<!-- Receipts List -->
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<strong>All Receipts</strong>
|
<div>
|
||||||
<span class="text-muted">- @Model.Receipts.Count total</span>
|
<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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (Model.Receipts.Any())
|
@if (Model.Receipts.Any())
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ namespace MoneyMap.Pages
|
|||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IReceiptManager _receiptManager;
|
private readonly IReceiptManager _receiptManager;
|
||||||
|
private readonly IReceiptAutoMapper _autoMapper;
|
||||||
|
|
||||||
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager)
|
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_receiptManager = receiptManager;
|
_receiptManager = receiptManager;
|
||||||
|
_autoMapper = autoMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ReceiptRow> Receipts { get; set; } = new();
|
public List<ReceiptRow> Receipts { get; set; } = new();
|
||||||
@@ -117,6 +119,32 @@ namespace MoneyMap.Pages
|
|||||||
return RedirectToPage();
|
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()
|
private async Task LoadReceiptsAsync()
|
||||||
{
|
{
|
||||||
var receipts = await _db.Receipts
|
var receipts = await _db.Receipts
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>(
|
|||||||
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||||
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||||
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||||
|
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||||
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
||||||
|
|
||||||
// AI categorization service
|
// AI categorization service
|
||||||
|
|||||||
@@ -27,19 +27,22 @@ namespace MoneyMap.Services
|
|||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IMerchantService _merchantService;
|
private readonly IMerchantService _merchantService;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
public OpenAIReceiptParser(
|
public OpenAIReceiptParser(
|
||||||
MoneyMapContext db,
|
MoneyMapContext db,
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
IMerchantService merchantService)
|
IMerchantService merchantService,
|
||||||
|
IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_merchantService = merchantService;
|
_merchantService = merchantService;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId)
|
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId)
|
||||||
@@ -138,6 +141,22 @@ namespace MoneyMap.Services
|
|||||||
_db.ReceiptParseLogs.Add(parseLog);
|
_db.ReceiptParseLogs.Add(parseLog);
|
||||||
await _db.SaveChangesAsync();
|
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.");
|
return ReceiptParseResult.Success($"Parsed {lineItems.Count} line items from receipt.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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