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:
612
MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs
generated
Normal file
612
MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user