From de5ee33a77a612cba705b2a104ddd649dbd864b8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 11 Jan 2026 16:54:15 -0500 Subject: [PATCH] Feature: Add ReviewReceipts page for receipt-to-transaction mapping New page shows unmapped receipts with scored transaction candidates, allowing manual mapping or bulk auto-map. Displays confidence scores and match quality indicators. Also shows recently mapped receipts for verification with unmap option. Co-Authored-By: Claude Opus 4.5 --- MoneyMap/Pages/Receipts.cshtml | 3 + MoneyMap/Pages/ReviewReceipts.cshtml | 283 ++++++++++++++++++++++++ MoneyMap/Pages/ReviewReceipts.cshtml.cs | 195 ++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 MoneyMap/Pages/ReviewReceipts.cshtml create mode 100644 MoneyMap/Pages/ReviewReceipts.cshtml.cs diff --git a/MoneyMap/Pages/Receipts.cshtml b/MoneyMap/Pages/Receipts.cshtml index 6e4f88f..04a6872 100644 --- a/MoneyMap/Pages/Receipts.cshtml +++ b/MoneyMap/Pages/Receipts.cshtml @@ -7,6 +7,9 @@

Receipts

+ + Review Mappings + diff --git a/MoneyMap/Pages/ReviewReceipts.cshtml b/MoneyMap/Pages/ReviewReceipts.cshtml new file mode 100644 index 0000000..d991ae3 --- /dev/null +++ b/MoneyMap/Pages/ReviewReceipts.cshtml @@ -0,0 +1,283 @@ +@page +@model MoneyMap.Pages.ReviewReceiptsModel +@{ + ViewData["Title"] = "Review Receipts"; +} + +
+

Review Receipts

+
+ @if (Model.UnmappedReceipts.Any()) + { +
+ +
+ } + Back to Receipts +
+
+ +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + +} + + +
+
+ Unmapped Receipts Needing Review + @Model.UnmappedReceipts.Count +
+
+ @if (Model.UnmappedReceipts.Any()) + { + @foreach (var item in Model.UnmappedReceipts) + { + var receipt = item.Receipt; + var scoreClass = item.BestScore >= 0.85 ? "bg-success" : + item.BestScore >= 0.50 ? "bg-warning text-dark" : "bg-secondary"; +
+
+ +
+
+ @if (receipt.ContentType.StartsWith("image/")) + { + 🖼️ + } + else + { + 📄 + } +
+
@receipt.FileName
+
+ Uploaded: @receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt") +
+ @if (!string.IsNullOrWhiteSpace(receipt.Merchant)) + { +
Merchant: @receipt.Merchant
+ } + @if (receipt.ReceiptDate.HasValue) + { +
Date: @receipt.ReceiptDate.Value.ToString("yyyy-MM-dd")
+ } + @if (receipt.DueDate.HasValue) + { +
Due: @receipt.DueDate.Value.ToString("yyyy-MM-dd")
+ } + @if (receipt.Total.HasValue) + { +
Total: @receipt.Total.Value.ToString("C")
+ } + +
+
+
+ + +
+ @if (item.ScoredCandidates.Any()) + { +
+ Best Match: @(item.BestScore.ToString("P0")) + @if (item.BestScore >= 0.85) + { + High confidence - will auto-map + } + else if (item.BestScore >= 0.50) + { + Medium confidence - LLM will review + } + else + { + Low confidence - manual review needed + } +
+
+ + + + + + + + + + + + @foreach (var candidate in item.ScoredCandidates) + { + var rowClass = candidate.Score >= 0.85 ? "table-success" : + candidate.Score >= 0.50 ? "table-warning" : ""; + + + + + + + + } + +
ScoreDateAmountNameAction
+ = 0.50 ? "bg-warning text-dark" : "bg-secondary")"> + @(candidate.Score.ToString("P0")) + + @candidate.Transaction.Date.ToString("yyyy-MM-dd")@candidate.Transaction.Amount.ToString("C") +
@candidate.Transaction.Name
+ @if (candidate.Transaction.Merchant != null) + { + @candidate.Transaction.Merchant.Name + } +
+
+ + + +
+ View +
+
+ } + else + { +
+ No matching transactions found within the date range. +
+ } +
+ + +
+
+
+
+ } + } + else + { +
+ No unmapped receipts need review. All parsed receipts are mapped! +
+ } +
+
+ + +
+
+ Recently Mapped (Last 7 Days) + @Model.RecentlyMappedReceipts.Count +
+
+ @if (Model.RecentlyMappedReceipts.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.RecentlyMappedReceipts) + { + var receipt = item.Receipt; + var transaction = receipt.Transaction; + + // Calculate if amounts match + var amountMatch = receipt.Total.HasValue && transaction != null + ? Math.Abs((double)(receipt.Total.Value - Math.Abs(transaction.Amount)) / (double)receipt.Total.Value) <= 0.02 + : false; + var dateMatch = receipt.ReceiptDate.HasValue && transaction != null + ? Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays) <= 3 + : false; + + + + + + + + } + +
ReceiptMapped ToMatchActions
+
+ @if (receipt.ContentType.StartsWith("image/")) + { + 🖼️ + } + else + { + 📄 + } +
+
@receipt.FileName
+ + @(receipt.Merchant ?? "Unknown") | + @(receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "No date") | + @(receipt.Total?.ToString("C") ?? "No amount") + +
+
+
+ @if (transaction != null) + { +
@transaction.Name
+ + @transaction.Date.ToString("yyyy-MM-dd") | + @transaction.Amount.ToString("C") + @if (transaction.Merchant != null) + { + | @transaction.Merchant.Name + } + + } +
+ @if (amountMatch && dateMatch) + { + Good + } + else if (amountMatch || dateMatch) + { + Partial + } + else + { + Check + } + + + View + +
+ + +
+
+
+ } + else + { +
+ No receipts mapped in the last 7 days. +
+ } +
+
diff --git a/MoneyMap/Pages/ReviewReceipts.cshtml.cs b/MoneyMap/Pages/ReviewReceipts.cshtml.cs new file mode 100644 index 0000000..178e1af --- /dev/null +++ b/MoneyMap/Pages/ReviewReceipts.cshtml.cs @@ -0,0 +1,195 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Pages +{ + public class ReviewReceiptsModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + private readonly IReceiptAutoMapper _autoMapper; + + public ReviewReceiptsModel( + MoneyMapContext db, + IReceiptManager receiptManager, + IReceiptAutoMapper autoMapper) + { + _db = db; + _receiptManager = receiptManager; + _autoMapper = autoMapper; + } + + public List UnmappedReceipts { get; set; } = new(); + public List RecentlyMappedReceipts { get; set; } = new(); + + [TempData] + public string? Message { get; set; } + + [TempData] + public bool IsSuccess { get; set; } + + public async Task OnGetAsync() + { + await LoadReceiptsAsync(); + } + + public async Task OnPostAutoMapAsync(long receiptId) + { + var result = await _autoMapper.AutoMapReceiptAsync(receiptId); + + switch (result.Status) + { + case AutoMapStatus.Success: + Message = $"Receipt auto-mapped to transaction #{result.TransactionId}."; + IsSuccess = true; + break; + case AutoMapStatus.AlreadyMapped: + Message = "Receipt is already mapped."; + IsSuccess = false; + break; + case AutoMapStatus.MultipleMatches: + Message = $"Found {result.MultipleMatches.Count} potential matches. Please select manually."; + IsSuccess = false; + break; + case AutoMapStatus.NoMatch: + Message = "No matching transaction found."; + IsSuccess = false; + break; + case AutoMapStatus.NotParsed: + Message = "Receipt has not been parsed yet. Parse it first."; + IsSuccess = false; + break; + default: + Message = result.Message ?? "Auto-mapping failed."; + IsSuccess = false; + break; + } + + return RedirectToPage(); + } + + public async Task OnPostMapToTransactionAsync(long receiptId, long transactionId) + { + if (transactionId <= 0) + { + Message = "Please select a transaction."; + IsSuccess = false; + return RedirectToPage(); + } + + var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId); + + if (success) + { + Message = $"Receipt mapped to transaction #{transactionId}."; + IsSuccess = true; + } + else + { + Message = "Failed to map receipt."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task OnPostUnmapAsync(long receiptId) + { + var success = await _receiptManager.UnmapReceiptAsync(receiptId); + + if (success) + { + Message = "Receipt unmapped. You can now re-run auto-mapping."; + IsSuccess = true; + } + else + { + Message = "Failed to unmap receipt."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task OnPostAutoMapAllAsync() + { + var result = await _autoMapper.AutoMapUnmappedReceiptsAsync(); + + if (result.MappedCount > 0) + { + Message = $"Auto-mapped {result.MappedCount} receipt(s). " + + $"{result.NoMatchCount} had no match. " + + $"{result.MultipleMatchesCount} need manual review."; + IsSuccess = true; + } + else if (result.TotalProcessed == 0) + { + Message = "No unmapped receipts to process."; + IsSuccess = false; + } + else + { + Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no match. " + + $"{result.MultipleMatchesCount} need manual review."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + private async Task LoadReceiptsAsync() + { + // Get unmapped receipts with their scored candidates + var unmapped = await _db.Receipts + .Where(r => r.TransactionId == null) + .Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null) + .OrderByDescending(r => r.UploadedAtUtc) + .Take(50) + .ToListAsync(); + + foreach (var receipt in unmapped) + { + var candidates = await _autoMapper.GetScoredCandidatesAsync(receipt.Id); + + UnmappedReceipts.Add(new ReceiptReviewItem + { + Receipt = receipt, + ScoredCandidates = candidates.Take(5).ToList(), + BestScore = candidates.FirstOrDefault()?.Score ?? 0 + }); + } + + // Get recently mapped receipts (last 7 days) for verification + var sevenDaysAgo = DateTime.UtcNow.AddDays(-7); + var recentlyMapped = await _db.Receipts + .Include(r => r.Transaction) + .ThenInclude(t => t!.Merchant) + .Where(r => r.TransactionId != null) + .Where(r => r.UploadedAtUtc >= sevenDaysAgo) + .OrderByDescending(r => r.UploadedAtUtc) + .Take(20) + .ToListAsync(); + + foreach (var receipt in recentlyMapped) + { + RecentlyMappedReceipts.Add(new ReceiptReviewItem + { + Receipt = receipt, + ScoredCandidates = new List(), + BestScore = 0 + }); + } + } + + public class ReceiptReviewItem + { + public required Receipt Receipt { get; set; } + public List ScoredCandidates { get; set; } = new(); + public double BestScore { get; set; } + } + } +}