Receipt parser improvements: voided items, UPC, quantity defaults, and model selection

Major improvements to receipt parsing:

Voided Item Handling:
- Add Voided boolean field to ReceiptLineItem model and database
- Never skip any line items - include voided items with voided=true and lineTotal=0.00
- Strong parser hints: "CONTINUE reading", "do NOT stop parsing", "Read ENTIRE receipt"
- Ensures all items after void markers are captured

UPC/Barcode Extraction:
- Extract UPC codes (12-13 digits) and store in Sku field
- Enables price tracking over time even when descriptions change

Quantity Defaults:
- ALWAYS default to 1.0 for ALL retail products (groceries, goods, merchandise)
- Only use null for utility bills, service fees, or taxes
- Emphatic instructions: "MUST be 1.0", "do NOT leave it null"
- Prevents missing quantities on retail items

Model Selection:
- Add AI model dropdown in ViewReceipt UI (gpt-4o-mini vs gpt-4o)
- Update IReceiptParser interface to accept optional model parameter
- Pass selected model through to OpenAI API
- Store model name in parse logs for history tracking
- Allows using smarter model for complex receipts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-19 16:08:56 -04:00
parent d0f4b420f8
commit f09d19ec5c
8 changed files with 698 additions and 31 deletions

View File

@@ -0,0 +1,612 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccountType")
.HasColumnType("int");
b.Property<string>("Institution")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Nickname")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Nickname")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<DateTime?>("DueDate")
.HasColumnType("datetime2");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long?>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.Property<bool>("Voided")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CardId")
.HasColumnType("int");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Last4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int?>("DestinationAccountId")
.HasColumnType("int");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long?>("OriginalTransactionId")
.HasColumnType("bigint");
b.Property<int?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Pattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class AddVoidedToReceiptLineItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Voided",
table: "ReceiptLineItems",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Voided",
table: "ReceiptLineItems");
}
}
}

View File

@@ -230,6 +230,9 @@ namespace MoneyMap.Migrations
b.Property<decimal?>("UnitPrice") b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<bool>("Voided")
.HasColumnType("bit");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber"); b.HasIndex("ReceiptId", "LineNumber");

View File

@@ -39,4 +39,6 @@ public class ReceiptLineItem
[MaxLength(100)] [MaxLength(100)]
public string? Category { get; set; } public string? Category { get; set; }
public bool Voided { get; set; }
} }

View File

@@ -162,6 +162,13 @@
} }
</select> </select>
</div> </div>
<div class="mb-2">
<label for="model" class="form-label small">AI Model</label>
<select name="model" id="model" class="form-select form-select-sm">
<option value="gpt-4o-mini" selected>GPT-4o Mini (Fast & Cheap)</option>
<option value="gpt-4o">GPT-4o (Smarter)</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100"> <button type="submit" class="btn btn-primary btn-sm w-100">
Parse Receipt Parse Receipt
</button> </button>

View File

@@ -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;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -88,7 +84,7 @@ namespace MoneyMap.Pages
return File(fileBytes, receipt.ContentType); return File(fileBytes, receipt.ContentType);
} }
public async Task<IActionResult> OnPostParseAsync(long id, string parser) public async Task<IActionResult> OnPostParseAsync(long id, string parser, string? model = null)
{ {
var selectedParser = _parsers.FirstOrDefault(p => p.GetType().Name == parser); var selectedParser = _parsers.FirstOrDefault(p => p.GetType().Name == parser);
@@ -98,7 +94,7 @@ namespace MoneyMap.Pages
return RedirectToPage(new { id }); return RedirectToPage(new { id });
} }
var result = await selectedParser.ParseReceiptAsync(id); var result = await selectedParser.ParseReceiptAsync(id, model);
if (result.IsSuccess) if (result.IsSuccess)
{ {

View File

@@ -10,19 +10,38 @@ Analyze this receipt image and extract the following information as JSON:
"lineItems": [ "lineItems": [
{ {
"description": "item name", "description": "item name",
"quantity": 1.0 (or null), "upc": "1234567890123" (or null if not found),
"quantity": 1.0,
"unitPrice": 0.00 (or null), "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: Extract all line items you can see on the receipt. For each item:
- description: The item or service name - description: The item or service name (include any count/size info in the description itself, like "4CT" or "12 OZ")
- quantity: Only include if this is an actual countable product (like groceries). For services, fees, charges, or taxes, set to null. - 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.
- unitPrice: Price per unit if quantity applies, otherwise null - 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.
- lineTotal: The total amount for this line (required) - 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. 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.

View File

@@ -1,23 +1,15 @@
using System; using ImageMagick;
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 Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using ImageMagick; using System.Text;
using System.Text.Json;
namespace MoneyMap.Services namespace MoneyMap.Services
{ {
public interface IReceiptParser public interface IReceiptParser
{ {
Task<ReceiptParseResult> ParseReceiptAsync(long receiptId); Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null);
} }
public class OpenAIReceiptParser : IReceiptParser public class OpenAIReceiptParser : IReceiptParser
@@ -46,7 +38,7 @@ namespace MoneyMap.Services
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
} }
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId) public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null)
{ {
var receipt = await _db.Receipts var receipt = await _db.Receipts
.Include(r => r.Transaction) .Include(r => r.Transaction)
@@ -67,11 +59,14 @@ namespace MoneyMap.Services
if (!File.Exists(filePath)) if (!File.Exists(filePath))
return ReceiptParseResult.Failure("Receipt file not found on disk."); 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 var parseLog = new ReceiptParseLog
{ {
ReceiptId = receiptId, ReceiptId = receiptId,
Provider = "OpenAI", Provider = "OpenAI",
Model = "gpt-4o-mini", Model = selectedModel,
StartedAtUtc = DateTime.UtcNow, StartedAtUtc = DateTime.UtcNow,
Success = false Success = false
}; };
@@ -97,7 +92,7 @@ namespace MoneyMap.Services
// Call OpenAI Vision API with transaction name context // Call OpenAI Vision API with transaction name context
var transactionName = receipt.Transaction?.Name; 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 // Update receipt with parsed data
receipt.Merchant = parseData.Merchant; receipt.Merchant = parseData.Merchant;
@@ -128,9 +123,11 @@ namespace MoneyMap.Services
ReceiptId = receiptId, ReceiptId = receiptId,
LineNumber = index + 1, LineNumber = index + 1,
Description = item.Description, Description = item.Description,
Sku = item.Upc,
Quantity = item.Quantity, Quantity = item.Quantity,
UnitPrice = item.UnitPrice, UnitPrice = item.UnitPrice,
LineTotal = item.LineTotal LineTotal = item.LineTotal,
Voided = item.Voided
}).ToList(); }).ToList();
_db.ReceiptLineItems.AddRange(lineItems); _db.ReceiptLineItems.AddRange(lineItems);
@@ -220,7 +217,7 @@ namespace MoneyMap.Services
return _promptTemplate; return _promptTemplate;
} }
private async Task<ParsedReceiptData> CallOpenAIVisionAsync(string apiKey, string base64Image, string mediaType, string? transactionName = null) private async Task<ParsedReceiptData> CallOpenAIVisionAsync(string apiKey, string base64Image, string mediaType, string model, string? transactionName = null)
{ {
// Load the prompt template from file // Load the prompt template from file
var promptText = await LoadPromptTemplateAsync(); var promptText = await LoadPromptTemplateAsync();
@@ -235,7 +232,7 @@ namespace MoneyMap.Services
var requestBody = new var requestBody = new
{ {
model = "gpt-4o-mini", model = model,
messages = new[] messages = new[]
{ {
new new
@@ -317,9 +314,11 @@ namespace MoneyMap.Services
public class ParsedLineItem public class ParsedLineItem
{ {
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public string? Upc { get; set; }
public decimal? Quantity { get; set; } public decimal? Quantity { get; set; }
public decimal? UnitPrice { get; set; } public decimal? UnitPrice { get; set; }
public decimal LineTotal { get; set; } public decimal LineTotal { get; set; }
public bool Voided { get; set; }
} }
public class ReceiptParseResult public class ReceiptParseResult