From 865195ad16ac0c83508a4c77a1f10febf68d36aa Mon Sep 17 00:00:00 2001
From: AJ Isaacs
Date: Sun, 18 Jan 2026 20:49:31 -0500
Subject: [PATCH] Feature: Save AI parsing notes with receipt
Store user-provided parsing notes in the database so they persist
across parsing attempts. Notes are displayed in Receipt Information
and pre-populated in the textarea for future parses.
Co-Authored-By: Claude Opus 4.5
---
...9005858_AddReceiptParsingNotes.Designer.cs | 661 ++++++++++++++++++
.../20260119005858_AddReceiptParsingNotes.cs | 29 +
.../MoneyMapContextModelSnapshot.cs | 4 +
MoneyMap/Models/Receipt.cs | 4 +
MoneyMap/Pages/ViewReceipt.cshtml | 11 +
MoneyMap/Pages/ViewReceipt.cshtml.cs | 10 +-
MoneyMap/Services/AIReceiptParser.cs | 300 ++++----
7 files changed, 893 insertions(+), 126 deletions(-)
create mode 100644 MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs
create mode 100644 MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs
diff --git a/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs
new file mode 100644
index 0000000..9db6538
--- /dev/null
+++ b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs
@@ -0,0 +1,661 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using MoneyMap.Data;
+
+#nullable disable
+
+namespace MoneyMap.Migrations
+{
+ [DbContext(typeof(MoneyMapContext))]
+ [Migration("20260119005858_AddReceiptParsingNotes")]
+ partial class AddReceiptParsingNotes
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("MoneyMap.Models.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountType")
+ .HasColumnType("int");
+
+ b.Property("Institution")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Last4")
+ .IsRequired()
+ .HasMaxLength(4)
+ .HasColumnType("nvarchar(4)");
+
+ b.Property("Nickname")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Institution", "Last4", "Owner");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Budget", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Category")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("Notes")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Period")
+ .HasColumnType("int");
+
+ b.Property("StartDate")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category", "Period")
+ .IsUnique()
+ .HasFilter("[IsActive] = 1");
+
+ b.ToTable("Budgets");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Card", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("Issuer")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Last4")
+ .IsRequired()
+ .HasMaxLength(4)
+ .HasColumnType("nvarchar(4)");
+
+ b.Property("Nickname")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.HasIndex("Issuer", "Last4", "Owner");
+
+ b.ToTable("Cards");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Confidence")
+ .HasColumnType("decimal(5,4)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("MerchantId")
+ .HasColumnType("int");
+
+ b.Property("Pattern")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Priority")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MerchantId");
+
+ b.ToTable("CategoryMappings");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Merchants");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ContentType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)")
+ .HasDefaultValue("application/octet-stream");
+
+ b.Property("Currency")
+ .HasMaxLength(8)
+ .HasColumnType("nvarchar(8)");
+
+ b.Property("DueDate")
+ .HasColumnType("datetime2");
+
+ b.Property("FileHashSha256")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(260)
+ .HasColumnType("nvarchar(260)");
+
+ b.Property("FileSizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("Merchant")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParsingNotes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ReceiptDate")
+ .HasColumnType("datetime2");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Subtotal")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Tax")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Total")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("TransactionId")
+ .HasColumnType("bigint");
+
+ b.Property("UploadedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FileHashSha256");
+
+ b.HasIndex("TransactionId", "FileHashSha256")
+ .IsUnique()
+ .HasFilter("[TransactionId] IS NOT NULL");
+
+ b.HasIndex("TransactionId", "ReceiptDate");
+
+ b.ToTable("Receipts");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("nvarchar(300)");
+
+ b.Property("LineNumber")
+ .HasColumnType("int");
+
+ b.Property("LineTotal")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Quantity")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("ReceiptId")
+ .HasColumnType("bigint");
+
+ b.Property("Sku")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Unit")
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("UnitPrice")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Voided")
+ .HasColumnType("bit");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReceiptId", "LineNumber");
+
+ b.ToTable("ReceiptLineItems");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CompletedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Confidence")
+ .HasColumnType("decimal(5,4)");
+
+ b.Property("Error")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ExtractedTextPath")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Model")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ProviderJobId")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RawProviderPayloadJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ReceiptId")
+ .HasColumnType("bigint");
+
+ b.Property("StartedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Success")
+ .HasColumnType("bit");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReceiptId", "StartedAtUtc");
+
+ b.ToTable("ReceiptParseLogs");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountId")
+ .HasColumnType("int");
+
+ b.Property("Amount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("CardId")
+ .HasColumnType("int");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Date")
+ .HasColumnType("datetime2");
+
+ b.Property("Last4")
+ .HasMaxLength(4)
+ .HasColumnType("nvarchar(4)");
+
+ b.Property("Memo")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)")
+ .HasDefaultValue("");
+
+ b.Property("MerchantId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TransactionType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("TransferToAccountId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Amount");
+
+ b.HasIndex("Category");
+
+ b.HasIndex("Date");
+
+ b.HasIndex("MerchantId");
+
+ b.HasIndex("TransferToAccountId");
+
+ b.HasIndex("AccountId", "Category");
+
+ b.HasIndex("AccountId", "Date");
+
+ b.HasIndex("CardId", "Date");
+
+ b.HasIndex("MerchantId", "Date");
+
+ b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
+ .IsUnique()
+ .HasFilter("[CardId] IS NOT NULL");
+
+ b.ToTable("Transactions");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Date")
+ .HasColumnType("datetime2");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("DestinationAccountId")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("OriginalTransactionId")
+ .HasColumnType("bigint");
+
+ b.Property("SourceAccountId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Date");
+
+ b.HasIndex("DestinationAccountId");
+
+ b.HasIndex("OriginalTransactionId");
+
+ b.HasIndex("SourceAccountId");
+
+ b.ToTable("Transfers");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Card", b =>
+ {
+ b.HasOne("MoneyMap.Models.Account", "Account")
+ .WithMany("Cards")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("Account");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
+ {
+ b.HasOne("MoneyMap.Models.Merchant", "Merchant")
+ .WithMany("CategoryMappings")
+ .HasForeignKey("MerchantId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Merchant");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
+ {
+ b.HasOne("MoneyMap.Models.Transaction", "Transaction")
+ .WithMany("Receipts")
+ .HasForeignKey("TransactionId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Transaction");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
+ {
+ b.HasOne("MoneyMap.Models.Receipt", "Receipt")
+ .WithMany("LineItems")
+ .HasForeignKey("ReceiptId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Receipt");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
+ {
+ b.HasOne("MoneyMap.Models.Receipt", "Receipt")
+ .WithMany("ParseLogs")
+ .HasForeignKey("ReceiptId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Receipt");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
+ {
+ b.HasOne("MoneyMap.Models.Account", "Account")
+ .WithMany("Transactions")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("MoneyMap.Models.Card", "Card")
+ .WithMany("Transactions")
+ .HasForeignKey("CardId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("MoneyMap.Models.Merchant", "Merchant")
+ .WithMany("Transactions")
+ .HasForeignKey("MerchantId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
+ .WithMany()
+ .HasForeignKey("TransferToAccountId");
+
+ b.Navigation("Account");
+
+ b.Navigation("Card");
+
+ b.Navigation("Merchant");
+
+ b.Navigation("TransferToAccount");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
+ {
+ b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
+ .WithMany("DestinationTransfers")
+ .HasForeignKey("DestinationAccountId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
+ .WithMany()
+ .HasForeignKey("OriginalTransactionId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("MoneyMap.Models.Account", "SourceAccount")
+ .WithMany("SourceTransfers")
+ .HasForeignKey("SourceAccountId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("DestinationAccount");
+
+ b.Navigation("OriginalTransaction");
+
+ b.Navigation("SourceAccount");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Account", b =>
+ {
+ b.Navigation("Cards");
+
+ b.Navigation("DestinationTransfers");
+
+ b.Navigation("SourceTransfers");
+
+ b.Navigation("Transactions");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Card", b =>
+ {
+ b.Navigation("Transactions");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
+ {
+ b.Navigation("CategoryMappings");
+
+ b.Navigation("Transactions");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
+ {
+ b.Navigation("LineItems");
+
+ b.Navigation("ParseLogs");
+ });
+
+ modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
+ {
+ b.Navigation("Receipts");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs
new file mode 100644
index 0000000..b3a521b
--- /dev/null
+++ b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace MoneyMap.Migrations
+{
+ ///
+ public partial class AddReceiptParsingNotes : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ParsingNotes",
+ table: "Receipts",
+ type: "nvarchar(2000)",
+ maxLength: 2000,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ParsingNotes",
+ table: "Receipts");
+ }
+ }
+}
diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs
index 2280f3b..4bdd898 100644
--- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs
+++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs
@@ -236,6 +236,10 @@ namespace MoneyMap.Migrations
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
+ b.Property("ParsingNotes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
b.Property("ReceiptDate")
.HasColumnType("datetime2");
diff --git a/MoneyMap/Models/Receipt.cs b/MoneyMap/Models/Receipt.cs
index 5580550..3f4006e 100644
--- a/MoneyMap/Models/Receipt.cs
+++ b/MoneyMap/Models/Receipt.cs
@@ -51,6 +51,10 @@ public class Receipt
[MaxLength(8)]
public string? Currency { get; set; }
+ // User notes provided to AI parser
+ [MaxLength(2000)]
+ public string? ParsingNotes { get; set; }
+
// One receipt -> many parse attempts + many line items
public ICollection ParseLogs { get; set; } = new List();
public ICollection LineItems { get; set; } = new List();
diff --git a/MoneyMap/Pages/ViewReceipt.cshtml b/MoneyMap/Pages/ViewReceipt.cshtml
index e99530c..7e900d8 100644
--- a/MoneyMap/Pages/ViewReceipt.cshtml
+++ b/MoneyMap/Pages/ViewReceipt.cshtml
@@ -140,6 +140,12 @@
Uploaded
@Model.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
+
+ @if (!string.IsNullOrWhiteSpace(Model.Receipt.ParsingNotes))
+ {
+ AI Notes
+ @Model.Receipt.ParsingNotes
+ }
@@ -158,6 +164,11 @@
Using: @Model.SelectedModel
Change
+
+
+
+
diff --git a/MoneyMap/Pages/ViewReceipt.cshtml.cs b/MoneyMap/Pages/ViewReceipt.cshtml.cs
index 9460696..0afef18 100644
--- a/MoneyMap/Pages/ViewReceipt.cshtml.cs
+++ b/MoneyMap/Pages/ViewReceipt.cshtml.cs
@@ -39,6 +39,9 @@ namespace MoneyMap.Pages
[TempData]
public string? ErrorMessage { get; set; }
+ [BindProperty]
+ public string? ParsingNotes { get; set; }
+
public async Task OnGetAsync(long id)
{
Receipt = await _db.Receipts
@@ -53,6 +56,9 @@ namespace MoneyMap.Pages
LineItems = Receipt.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new();
ParseLogs = Receipt.ParseLogs?.OrderByDescending(pl => pl.StartedAtUtc).ToList() ?? new();
+ // Load saved parsing notes
+ ParsingNotes = Receipt.ParsingNotes;
+
// Get receipt URL for display - use handler parameter
ReceiptUrl = $"/ViewReceipt/{id}?handler=file";
@@ -98,8 +104,8 @@ namespace MoneyMap.Pages
return RedirectToPage(new { id });
}
- // Use the configured model from settings
- var result = await selectedParser.ParseReceiptAsync(id, SelectedModel);
+ // Use the configured model from settings, pass user notes
+ var result = await selectedParser.ParseReceiptAsync(id, SelectedModel, ParsingNotes);
if (result.IsSuccess)
{
diff --git a/MoneyMap/Services/AIReceiptParser.cs b/MoneyMap/Services/AIReceiptParser.cs
index d569704..3519025 100644
--- a/MoneyMap/Services/AIReceiptParser.cs
+++ b/MoneyMap/Services/AIReceiptParser.cs
@@ -7,7 +7,7 @@ namespace MoneyMap.Services
{
public interface IReceiptParser
{
- Task ParseReceiptAsync(long receiptId, string? model = null);
+ Task ParseReceiptAsync(long receiptId, string? model = null, string? notes = null);
}
public class AIReceiptParser : IReceiptParser
@@ -15,10 +15,7 @@ namespace MoneyMap.Services
private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager;
private readonly IPdfToImageConverter _pdfConverter;
- private readonly OpenAIVisionClient _openAIClient;
- private readonly ClaudeVisionClient _claudeClient;
- private readonly OllamaVisionClient _ollamaClient;
- private readonly LlamaCppVisionClient _llamaCppClient;
+ private readonly IAIVisionClientResolver _clientResolver;
private readonly IMerchantService _merchantService;
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
@@ -29,10 +26,7 @@ namespace MoneyMap.Services
MoneyMapContext db,
IReceiptManager receiptManager,
IPdfToImageConverter pdfConverter,
- OpenAIVisionClient openAIClient,
- ClaudeVisionClient claudeClient,
- OllamaVisionClient ollamaClient,
- LlamaCppVisionClient llamaCppClient,
+ IAIVisionClientResolver clientResolver,
IMerchantService merchantService,
IServiceProvider serviceProvider,
IConfiguration configuration,
@@ -41,17 +35,14 @@ namespace MoneyMap.Services
_db = db;
_receiptManager = receiptManager;
_pdfConverter = pdfConverter;
- _openAIClient = openAIClient;
- _claudeClient = claudeClient;
- _ollamaClient = ollamaClient;
- _llamaCppClient = llamaCppClient;
+ _clientResolver = clientResolver;
_merchantService = merchantService;
_serviceProvider = serviceProvider;
_configuration = configuration;
_logger = logger;
}
- public async Task ParseReceiptAsync(long receiptId, string? model = null)
+ public async Task ParseReceiptAsync(long receiptId, string? model = null, string? notes = null)
{
var receipt = await _db.Receipts
.Include(r => r.Transaction)
@@ -60,17 +51,13 @@ namespace MoneyMap.Services
if (receipt == null)
return ReceiptParseResult.Failure("Receipt not found.");
- var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
- var isLlamaCpp = selectedModel.StartsWith("llamacpp:");
- var isOllama = selectedModel.StartsWith("ollama:");
- var isClaude = selectedModel.StartsWith("claude-");
- var provider = isLlamaCpp ? "LlamaCpp" : (isOllama ? "Ollama" : (isClaude ? "Anthropic" : "OpenAI"));
-
var filePath = _receiptManager.GetReceiptPhysicalPath(receipt);
-
if (!File.Exists(filePath))
return ReceiptParseResult.Failure("Receipt file not found on disk.");
+ var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
+ var (client, provider) = _clientResolver.Resolve(selectedModel);
+
var parseLog = new ReceiptParseLog
{
ReceiptId = receiptId,
@@ -82,130 +69,153 @@ namespace MoneyMap.Services
try
{
- string base64Data;
- string mediaType;
-
- if (receipt.ContentType == "application/pdf")
- {
- base64Data = await _pdfConverter.ConvertFirstPageToBase64Async(filePath);
- mediaType = "image/png";
- }
- else
- {
- var fileBytes = await File.ReadAllBytesAsync(filePath);
- base64Data = Convert.ToBase64String(fileBytes);
- mediaType = receipt.ContentType;
- }
-
- // Build prompt
- var promptText = await LoadPromptTemplateAsync();
- var transactionName = receipt.Transaction?.Name;
- if (!string.IsNullOrWhiteSpace(transactionName))
- {
- promptText += $"\n\nNote: This transaction was recorded as \"{transactionName}\" in the bank statement, which may help identify the merchant if the receipt is unclear.";
- }
- promptText += "\n\nRespond ONLY with valid JSON, no other text.";
-
- // Call appropriate vision API
- IAIVisionClient client = isLlamaCpp ? _llamaCppClient
- : isOllama ? _ollamaClient
- : isClaude ? _claudeClient
- : _openAIClient;
+ var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
+ var promptText = await BuildPromptAsync(receipt, notes);
var visionResult = await client.AnalyzeImageAsync(base64Data, mediaType, promptText, selectedModel);
if (!visionResult.IsSuccess)
{
- parseLog.Error = visionResult.ErrorMessage;
- parseLog.CompletedAtUtc = DateTime.UtcNow;
- _db.ReceiptParseLogs.Add(parseLog);
- await _db.SaveChangesAsync();
+ await SaveParseLogAsync(parseLog, visionResult.ErrorMessage);
return ReceiptParseResult.Failure(visionResult.ErrorMessage!);
}
- // Parse the JSON response
- var parseData = string.IsNullOrWhiteSpace(visionResult.Content)
- ? new ParsedReceiptData()
- : JsonSerializer.Deserialize(visionResult.Content, new JsonSerializerOptions
- {
- PropertyNameCaseInsensitive = true
- }) ?? new ParsedReceiptData();
-
- // Update receipt with parsed data
- receipt.Merchant = parseData.Merchant;
- receipt.Total = parseData.Total;
- receipt.Subtotal = parseData.Subtotal;
- receipt.Tax = parseData.Tax;
- receipt.ReceiptDate = parseData.ReceiptDate;
- receipt.DueDate = parseData.DueDate;
-
- // Update transaction merchant if extracted and transaction doesn't have one
- if (receipt.Transaction != null &&
- !string.IsNullOrWhiteSpace(parseData.Merchant) &&
- receipt.Transaction.MerchantId == null)
- {
- var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant);
- receipt.Transaction.MerchantId = merchantId;
- }
-
- // Remove existing line items
- var existingItems = await _db.ReceiptLineItems
- .Where(li => li.ReceiptId == receiptId)
- .ToListAsync();
- _db.ReceiptLineItems.RemoveRange(existingItems);
-
- // Add new line items
- var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem
- {
- ReceiptId = receiptId,
- LineNumber = index + 1,
- Description = item.Description,
- Sku = item.Upc,
- Quantity = item.Quantity,
- UnitPrice = item.UnitPrice,
- LineTotal = item.LineTotal,
- Voided = item.Voided
- }).ToList();
-
- _db.ReceiptLineItems.AddRange(lineItems);
+ var parseData = ParseResponse(visionResult.Content);
+ await ApplyParseResultAsync(receipt, receiptId, parseData, notes);
parseLog.Success = true;
- parseLog.CompletedAtUtc = DateTime.UtcNow;
parseLog.Confidence = parseData.Confidence;
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
+ await SaveParseLogAsync(parseLog);
- _db.ReceiptParseLogs.Add(parseLog);
- await _db.SaveChangesAsync();
+ await TryAutoMapReceiptAsync(receipt, receiptId);
- // Attempt auto-mapping after successful parse
- if (!receipt.TransactionId.HasValue)
- {
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var autoMapper = scope.ServiceProvider.GetRequiredService();
- await autoMapper.AutoMapReceiptAsync(receiptId);
- _logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message);
- }
- }
-
- return ReceiptParseResult.Success($"Parsed {lineItems.Count} line items from receipt.");
+ var lineCount = parseData.LineItems.Count;
+ return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt.");
}
catch (Exception ex)
{
- parseLog.Error = ex.Message;
- parseLog.CompletedAtUtc = DateTime.UtcNow;
- _db.ReceiptParseLogs.Add(parseLog);
- await _db.SaveChangesAsync();
-
+ await SaveParseLogAsync(parseLog, ex.Message);
_logger.LogError(ex, "Error parsing receipt {ReceiptId}: {Message}", receiptId, ex.Message);
return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}");
}
}
+ private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
+ {
+ if (receipt.ContentType == "application/pdf")
+ {
+ var base64 = await _pdfConverter.ConvertFirstPageToBase64Async(filePath);
+ return (base64, "image/png");
+ }
+
+ var fileBytes = await File.ReadAllBytesAsync(filePath);
+ return (Convert.ToBase64String(fileBytes), receipt.ContentType);
+ }
+
+ private async Task BuildPromptAsync(Receipt receipt, string? userNotes = null)
+ {
+ var promptText = await LoadPromptTemplateAsync();
+
+ var transactionName = receipt.Transaction?.Name;
+ if (!string.IsNullOrWhiteSpace(transactionName))
+ {
+ promptText += $"\n\nNote: This transaction was recorded as \"{transactionName}\" in the bank statement, which may help identify the merchant if the receipt is unclear.";
+ }
+
+ var parsingNotes = _configuration["AI:ReceiptParsingNotes"];
+ if (!string.IsNullOrWhiteSpace(parsingNotes))
+ {
+ promptText += $"\n\nAdditional notes: {parsingNotes}";
+ }
+
+ if (!string.IsNullOrWhiteSpace(userNotes))
+ {
+ promptText += $"\n\nUser notes for this receipt: {userNotes}";
+ }
+
+ promptText += "\n\nRespond ONLY with valid JSON, no other text.";
+ return promptText;
+ }
+
+ private static ParsedReceiptData ParseResponse(string? content)
+ {
+ if (string.IsNullOrWhiteSpace(content))
+ return new ParsedReceiptData();
+
+ return JsonSerializer.Deserialize(content, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new ParsedReceiptData();
+ }
+
+ private async Task ApplyParseResultAsync(Receipt receipt, long receiptId, ParsedReceiptData parseData, string? notes)
+ {
+ // Update receipt fields
+ receipt.ParsingNotes = notes;
+ receipt.Merchant = parseData.Merchant;
+ receipt.Total = parseData.Total;
+ receipt.Subtotal = parseData.Subtotal;
+ receipt.Tax = parseData.Tax;
+ receipt.ReceiptDate = parseData.ReceiptDate;
+ receipt.DueDate = parseData.DueDate;
+
+ // Update transaction merchant if needed
+ if (receipt.Transaction != null &&
+ !string.IsNullOrWhiteSpace(parseData.Merchant) &&
+ receipt.Transaction.MerchantId == null)
+ {
+ var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant);
+ receipt.Transaction.MerchantId = merchantId;
+ }
+
+ // Replace line items
+ var existingItems = await _db.ReceiptLineItems
+ .Where(li => li.ReceiptId == receiptId)
+ .ToListAsync();
+ _db.ReceiptLineItems.RemoveRange(existingItems);
+
+ var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem
+ {
+ ReceiptId = receiptId,
+ LineNumber = index + 1,
+ Description = item.Description,
+ Sku = item.Upc,
+ Quantity = item.Quantity,
+ UnitPrice = item.UnitPrice,
+ LineTotal = item.LineTotal,
+ Voided = item.Voided
+ }).ToList();
+
+ _db.ReceiptLineItems.AddRange(lineItems);
+ await _db.SaveChangesAsync();
+ }
+
+ private async Task SaveParseLogAsync(ReceiptParseLog parseLog, string? error = null)
+ {
+ parseLog.Error = error;
+ parseLog.CompletedAtUtc = DateTime.UtcNow;
+ _db.ReceiptParseLogs.Add(parseLog);
+ await _db.SaveChangesAsync();
+ }
+
+ private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId)
+ {
+ if (receipt.TransactionId.HasValue)
+ return;
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var autoMapper = scope.ServiceProvider.GetRequiredService();
+ await autoMapper.AutoMapReceiptAsync(receiptId);
+ _logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message);
+ }
+ }
+
private async Task LoadPromptTemplateAsync()
{
if (_promptTemplate != null)
@@ -221,6 +231,48 @@ namespace MoneyMap.Services
}
}
+ ///
+ /// Resolves the appropriate AI vision client based on model name.
+ ///
+ public interface IAIVisionClientResolver
+ {
+ (IAIVisionClient Client, string Provider) Resolve(string model);
+ }
+
+ public class AIVisionClientResolver : IAIVisionClientResolver
+ {
+ private readonly OpenAIVisionClient _openAIClient;
+ private readonly ClaudeVisionClient _claudeClient;
+ private readonly OllamaVisionClient _ollamaClient;
+ private readonly LlamaCppVisionClient _llamaCppClient;
+
+ public AIVisionClientResolver(
+ OpenAIVisionClient openAIClient,
+ ClaudeVisionClient claudeClient,
+ OllamaVisionClient ollamaClient,
+ LlamaCppVisionClient llamaCppClient)
+ {
+ _openAIClient = openAIClient;
+ _claudeClient = claudeClient;
+ _ollamaClient = ollamaClient;
+ _llamaCppClient = llamaCppClient;
+ }
+
+ public (IAIVisionClient Client, string Provider) Resolve(string model)
+ {
+ if (model.StartsWith("llamacpp:"))
+ return (_llamaCppClient, "LlamaCpp");
+
+ if (model.StartsWith("ollama:"))
+ return (_ollamaClient, "Ollama");
+
+ if (model.StartsWith("claude-"))
+ return (_claudeClient, "Anthropic");
+
+ return (_openAIClient, "OpenAI");
+ }
+ }
+
public class ParsedReceiptData
{
public string? Merchant { get; set; }