diff --git a/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs new file mode 100644 index 0000000..4fe0847 --- /dev/null +++ b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs @@ -0,0 +1,612 @@ +// +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("20251019194640_AddVoidedToReceiptLineItem")] + partial class AddVoidedToReceiptLineItem + { + /// + 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.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.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("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("TransactionId", "FileHashSha256") + .IsUnique() + .HasFilter("[TransactionId] IS NOT NULL"); + + 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("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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.Services.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.Card", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Cards") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + 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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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/20251019194640_AddVoidedToReceiptLineItem.cs b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.cs new file mode 100644 index 0000000..a8997f1 --- /dev/null +++ b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddVoidedToReceiptLineItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Voided", + table: "ReceiptLineItems", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Voided", + table: "ReceiptLineItems"); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index 070c0c2..d5642d8 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -230,6 +230,9 @@ namespace MoneyMap.Migrations b.Property("UnitPrice") .HasColumnType("decimal(18,4)"); + b.Property("Voided") + .HasColumnType("bit"); + b.HasKey("Id"); b.HasIndex("ReceiptId", "LineNumber"); diff --git a/MoneyMap/Models/ReceiptLineItem.cs b/MoneyMap/Models/ReceiptLineItem.cs index ae1eb3f..16338bb 100644 --- a/MoneyMap/Models/ReceiptLineItem.cs +++ b/MoneyMap/Models/ReceiptLineItem.cs @@ -39,4 +39,6 @@ public class ReceiptLineItem [MaxLength(100)] public string? Category { get; set; } + + public bool Voided { get; set; } } \ No newline at end of file diff --git a/MoneyMap/Pages/ViewReceipt.cshtml b/MoneyMap/Pages/ViewReceipt.cshtml index b15ae3a..938191a 100644 --- a/MoneyMap/Pages/ViewReceipt.cshtml +++ b/MoneyMap/Pages/ViewReceipt.cshtml @@ -162,6 +162,13 @@ } +
+ + +
diff --git a/MoneyMap/Pages/ViewReceipt.cshtml.cs b/MoneyMap/Pages/ViewReceipt.cshtml.cs index 1fe381f..b657018 100644 --- a/MoneyMap/Pages/ViewReceipt.cshtml.cs +++ b/MoneyMap/Pages/ViewReceipt.cshtml.cs @@ -1,7 +1,3 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; @@ -88,7 +84,7 @@ namespace MoneyMap.Pages return File(fileBytes, receipt.ContentType); } - public async Task OnPostParseAsync(long id, string parser) + public async Task OnPostParseAsync(long id, string parser, string? model = null) { var selectedParser = _parsers.FirstOrDefault(p => p.GetType().Name == parser); @@ -98,7 +94,7 @@ namespace MoneyMap.Pages return RedirectToPage(new { id }); } - var result = await selectedParser.ParseReceiptAsync(id); + var result = await selectedParser.ParseReceiptAsync(id, model); if (result.IsSuccess) { diff --git a/MoneyMap/Prompts/ReceiptParserPrompt.txt b/MoneyMap/Prompts/ReceiptParserPrompt.txt index 784faed..15c1866 100644 --- a/MoneyMap/Prompts/ReceiptParserPrompt.txt +++ b/MoneyMap/Prompts/ReceiptParserPrompt.txt @@ -10,19 +10,38 @@ Analyze this receipt image and extract the following information as JSON: "lineItems": [ { "description": "item name", - "quantity": 1.0 (or null), + "upc": "1234567890123" (or null if not found), + "quantity": 1.0, "unitPrice": 0.00 (or null), - "lineTotal": 0.00 + "lineTotal": 0.00, + "voided": false } ] } Extract all line items you can see on the receipt. For each item: -- description: The item or service name -- quantity: Only include if this is an actual countable product (like groceries). For services, fees, charges, or taxes, set to null. -- unitPrice: Price per unit if quantity applies, otherwise null -- lineTotal: The total amount for this line (required) +- description: The item or service name (include any count/size info in the description itself, like "4CT" or "12 OZ") +- upc: The UPC/barcode number if visible (usually a 12-13 digit number near the item). This helps track price changes over time. Set to null if not found. +- quantity: ALWAYS set to 1.0 for ALL retail products (groceries, goods, merchandise, etc.) - this is the default. ONLY use null for utility bills, service fees, or taxes (non-product items). If it's a physical item on a retail receipt, use 1.0. +- unitPrice: Calculate as lineTotal divided by quantity (so usually equals lineTotal for retail items). Set to null only if quantity is null. +- lineTotal: The total amount for this line (the price shown on the receipt, or 0.00 if voided) +- voided: Set to true if this item appears immediately after a "** VOIDED ENTRY **" marker or similar void indicator. Set to false for all other items. -For utility bills, service charges, fees, and taxes - these are NOT products with quantities, so set quantity and unitPrice to null. +CRITICAL - HANDLING VOIDED ITEMS: +- NEVER skip or ignore ANY line items on the receipt +- When you see "** VOIDED ENTRY **" or similar void markers, the item immediately after it is voided +- For voided items: set "voided": true and "lineTotal": 0.00 +- For all other items: set "voided": false +- CONTINUE reading and extracting ALL items that appear after void markers - do NOT stop parsing +- The receipt may have many items listed after a void marker - you MUST include every single one +- Include EVERY line item you can see, whether voided or not + +OTHER IMPORTANT RULES: +- Quantity MUST be 1.0 for ALL physical retail items (groceries, food, household goods, etc.) - do NOT leave it null +- Every item on a grocery/retail receipt gets quantity: 1.0 unless you see explicit indicators like "2 @" or "QTY 3" +- Only utility bills, service charges, fees, or taxes (non-product line items) should have null quantity +- Don't confuse product descriptions (like "4CT BLUE MUF" meaning 4-count muffin package) with quantity fields (like "2 @ $3.99") +- Extract UPC/barcode numbers when visible - they're usually long numeric codes (12-13 digits) +- Read through the ENTIRE receipt from top to bottom - don't stop early If this is a bill (utility, credit card, etc.), look for a due date, payment due date, or deadline and extract it as dueDate. For regular receipts, dueDate should be null. \ No newline at end of file diff --git a/MoneyMap/Services/OpenAIReceiptParser.cs b/MoneyMap/Services/OpenAIReceiptParser.cs index fb1e715..3bfb7d6 100644 --- a/MoneyMap/Services/OpenAIReceiptParser.cs +++ b/MoneyMap/Services/OpenAIReceiptParser.cs @@ -1,23 +1,15 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; +using ImageMagick; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using MoneyMap.Data; using MoneyMap.Models; -using ImageMagick; +using System.Text; +using System.Text.Json; namespace MoneyMap.Services { public interface IReceiptParser { - Task ParseReceiptAsync(long receiptId); + Task ParseReceiptAsync(long receiptId, string? model = null); } public class OpenAIReceiptParser : IReceiptParser @@ -46,7 +38,7 @@ namespace MoneyMap.Services _serviceProvider = serviceProvider; } - public async Task ParseReceiptAsync(long receiptId) + public async Task ParseReceiptAsync(long receiptId, string? model = null) { var receipt = await _db.Receipts .Include(r => r.Transaction) @@ -67,11 +59,14 @@ namespace MoneyMap.Services if (!File.Exists(filePath)) return ReceiptParseResult.Failure("Receipt file not found on disk."); + // Default to gpt-4o-mini if no model specified + var selectedModel = model ?? "gpt-4o-mini"; + var parseLog = new ReceiptParseLog { ReceiptId = receiptId, Provider = "OpenAI", - Model = "gpt-4o-mini", + Model = selectedModel, StartedAtUtc = DateTime.UtcNow, Success = false }; @@ -97,7 +92,7 @@ namespace MoneyMap.Services // Call OpenAI Vision API with transaction name context var transactionName = receipt.Transaction?.Name; - var parseData = await CallOpenAIVisionAsync(apiKey, base64Data, mediaType, transactionName); + var parseData = await CallOpenAIVisionAsync(apiKey, base64Data, mediaType, selectedModel, transactionName); // Update receipt with parsed data receipt.Merchant = parseData.Merchant; @@ -128,9 +123,11 @@ namespace MoneyMap.Services ReceiptId = receiptId, LineNumber = index + 1, Description = item.Description, + Sku = item.Upc, Quantity = item.Quantity, UnitPrice = item.UnitPrice, - LineTotal = item.LineTotal + LineTotal = item.LineTotal, + Voided = item.Voided }).ToList(); _db.ReceiptLineItems.AddRange(lineItems); @@ -220,7 +217,7 @@ namespace MoneyMap.Services return _promptTemplate; } - private async Task CallOpenAIVisionAsync(string apiKey, string base64Image, string mediaType, string? transactionName = null) + private async Task CallOpenAIVisionAsync(string apiKey, string base64Image, string mediaType, string model, string? transactionName = null) { // Load the prompt template from file var promptText = await LoadPromptTemplateAsync(); @@ -235,7 +232,7 @@ namespace MoneyMap.Services var requestBody = new { - model = "gpt-4o-mini", + model = model, messages = new[] { new @@ -317,9 +314,11 @@ namespace MoneyMap.Services public class ParsedLineItem { public string Description { get; set; } = ""; + public string? Upc { get; set; } public decimal? Quantity { get; set; } public decimal? UnitPrice { get; set; } public decimal LineTotal { get; set; } + public bool Voided { get; set; } } public class ReceiptParseResult