Add merchant field to transactions and category mappings

This feature enables easy filtering and identification of transactions by merchant name:
- Added Merchant column to Transaction model (nullable, max 100 chars)
- Added Merchant field to CategoryMapping model
- Modified ITransactionCategorizer to return CategorizationResult (category + merchant)
- Updated auto-categorization logic to assign merchant from category mappings
- Updated category mappings UI to include merchant field in add/edit forms
- Added merchant filter dropdown to transactions page with full pagination support
- Updated receipt parser to set transaction merchant from parsed receipt data
- Created two database migrations for the schema changes
- Updated helper methods to support merchant names in default mappings

Benefits:
- Consistent merchant naming across variant patterns (e.g., "Walmart" for all "WAL-MART*" patterns)
- Easy filtering by merchant on transactions page
- No CSV changes required - merchant is derived from category mapping patterns
- Receipt parsing can also populate merchant field automatically

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-12 03:21:31 -04:00
parent ecb7851a62
commit 675ffa6509
14 changed files with 1276 additions and 20 deletions

View File

@@ -0,0 +1,543 @@
// <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("20251012071124_AddMerchantToTransactions")]
partial class AddMerchantToTransactions
{
/// <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.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<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();
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.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<string>("Merchant")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
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("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()
.HasColumnType("nvarchar(max)");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
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)
.IsRequired();
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.Account", "TransferToAccount")
.WithMany()
.HasForeignKey("TransferToAccountId");
b.Navigation("Account");
b.Navigation("Card");
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.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 AddMerchantToTransactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Merchant",
table: "Transactions",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Merchant",
table: "Transactions");
}
}
}

View File

@@ -0,0 +1,546 @@
// <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("20251012071155_AddMerchantToCategoryMappings")]
partial class AddMerchantToCategoryMappings
{
/// <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.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<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();
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.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<string>("Merchant")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
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("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()
.HasColumnType("nvarchar(max)");
b.Property<string>("Merchant")
.HasColumnType("nvarchar(max)");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
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)
.IsRequired();
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.Account", "TransferToAccount")
.WithMany()
.HasForeignKey("TransferToAccountId");
b.Navigation("Account");
b.Navigation("Card");
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.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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class AddMerchantToCategoryMappings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Merchant",
table: "CategoryMappings",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Merchant",
table: "CategoryMappings");
}
}
}

View File

@@ -303,6 +303,10 @@ namespace MoneyMap.Migrations
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("Merchant")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -401,6 +405,9 @@ namespace MoneyMap.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Merchant")
.HasColumnType("nvarchar(max)");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -514,7 +521,6 @@ namespace MoneyMap.Migrations
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.Navigation("Transactions");

View File

@@ -29,6 +29,9 @@ public class Transaction
[MaxLength(100)]
public string Category { get; set; } = string.Empty;
[MaxLength(100)]
public string? Merchant { get; set; }
public string Notes { get; set; } = string.Empty;
// Primary container: Every transaction belongs to an Account (the source of CSV)

View File

@@ -100,12 +100,16 @@
{
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted" style="cursor: pointer;"
onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority)">
onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority, '@Html.Raw((mapping.Merchant ?? "").Replace("'", "\\'"))')">
@if (mapping.Priority > 0)
{
<span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span>
}
@mapping.Pattern
@if (!string.IsNullOrWhiteSpace(mapping.Merchant))
{
<span class="text-primary ms-1" title="Merchant: @mapping.Merchant">→ @mapping.Merchant</span>
}
</span>
<form method="post" asp-page-handler="DeleteMapping" asp-route-id="@mapping.Id"
onsubmit="return confirm('Delete pattern \'@mapping.Pattern\'?')" class="d-inline">
@@ -164,6 +168,12 @@ else
</div>
</div>
<div class="mb-3">
<label for="addMerchant" class="form-label">Merchant Name (Optional)</label>
<input name="model.Merchant" id="addMerchant" class="form-control" placeholder="e.g., Target" />
<div class="form-text">Friendly name to assign to matching transactions (e.g., "Walmart" instead of "WAL-MART #1234")</div>
</div>
<div class="mb-3">
<label for="addPattern" class="form-label">Pattern</label>
<input name="model.Pattern" id="addPattern" class="form-control" placeholder="e.g., TARGET.COM" />
@@ -213,6 +223,12 @@ else
</div>
</div>
<div class="mb-3">
<label for="editMerchant" class="form-label">Merchant Name (Optional)</label>
<input name="model.Merchant" id="editMerchant" class="form-control" placeholder="e.g., Target" />
<div class="form-text">Friendly name to assign to matching transactions (e.g., "Walmart" instead of "WAL-MART #1234")</div>
</div>
<div class="mb-3">
<label for="editPattern" class="form-label">Pattern</label>
<input name="model.Pattern" id="editPattern" class="form-control" />
@@ -268,11 +284,13 @@ else
{
"category": "Groceries",
"pattern": "WALMART",
"merchant": "Walmart",
"priority": 0
},
{
"category": "Gas & Auto",
"pattern": "SHELL",
"merchant": "Shell",
"priority": 100
}
]</pre>
@@ -289,13 +307,14 @@ else
@section Scripts {
<script>
function openEditModal(id, category, pattern, priority) {
console.log('Opening modal for:', id, category, pattern, priority);
function openEditModal(id, category, pattern, priority, merchant) {
console.log('Opening modal for:', id, category, pattern, priority, merchant);
document.getElementById('editId').value = id;
document.getElementById('editCategory').value = category;
document.getElementById('editPattern').value = pattern;
document.getElementById('editPriority').value = priority;
document.getElementById('editMerchant').value = merchant || '';
var modalElement = document.getElementById('editModal');
if (modalElement && typeof bootstrap !== 'undefined') {

View File

@@ -59,6 +59,7 @@ namespace MoneyMap.Pages
{
Category = model.Category.Trim(),
Pattern = model.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(),
Priority = model.Priority
};
@@ -87,6 +88,7 @@ namespace MoneyMap.Pages
mapping.Category = model.Category.Trim();
mapping.Pattern = model.Pattern.Trim();
mapping.Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim();
mapping.Priority = model.Priority;
await _db.SaveChangesAsync();
@@ -122,6 +124,7 @@ namespace MoneyMap.Pages
{
Category = m.Category,
Pattern = m.Pattern,
Merchant = m.Merchant,
Priority = m.Priority
}).ToList();
@@ -178,6 +181,7 @@ namespace MoneyMap.Pages
{
Category = m.Category.Trim(),
Pattern = m.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(m.Merchant) ? null : m.Merchant.Trim(),
Priority = m.Priority
}).ToList();
@@ -234,6 +238,9 @@ namespace MoneyMap.Pages
[StringLength(100)]
public string Category { get; set; } = "";
[StringLength(100)]
public string? Merchant { get; set; }
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
@@ -250,6 +257,9 @@ namespace MoneyMap.Pages
[StringLength(100)]
public string Category { get; set; } = "";
[StringLength(100)]
public string? Merchant { get; set; }
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
@@ -261,6 +271,7 @@ namespace MoneyMap.Pages
{
public string Category { get; set; } = "";
public string Pattern { get; set; } = "";
public string? Merchant { get; set; }
public int Priority { get; set; } = 0;
}
}

View File

@@ -80,21 +80,22 @@ namespace MoneyMap.Pages
foreach (var txn in transactions)
{
var newCategory = await _categorizer.CategorizeAsync(txn.Name, txn.Amount);
var result = await _categorizer.CategorizeAsync(txn.Name, txn.Amount);
if (string.IsNullOrWhiteSpace(newCategory))
if (string.IsNullOrWhiteSpace(result.Category))
{
noMatch++;
continue;
}
if (txn.Category == newCategory)
if (txn.Category == result.Category && txn.Merchant == result.Merchant)
{
alreadyCorrect++;
continue;
}
txn.Category = newCategory;
txn.Category = result.Category;
txn.Merchant = result.Merchant;
updated++;
}

View File

@@ -13,7 +13,7 @@
<div class="card shadow-sm mb-3">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<div class="col-md-2">
<label for="Category" class="form-label">Category</label>
<select asp-for="Category" class="form-select">
<option value="">All Categories</option>
@@ -23,7 +23,17 @@
}
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<label for="Merchant" class="form-label">Merchant</label>
<select asp-for="Merchant" class="form-select">
<option value="">All Merchants</option>
@foreach (var merchant in Model.AvailableMerchants)
{
<option value="@merchant">@merchant</option>
}
</select>
</div>
<div class="col-md-2">
<label for="CardId" class="form-label">Card</label>
<select asp-for="CardId" class="form-select">
<option value="">All Cards</option>
@@ -45,7 +55,7 @@
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
@if (!string.IsNullOrWhiteSpace(Model.Category) || !string.IsNullOrWhiteSpace(Model.CardId) || Model.StartDate.HasValue || Model.EndDate.HasValue)
@if (!string.IsNullOrWhiteSpace(Model.Category) || !string.IsNullOrWhiteSpace(Model.Merchant) || !string.IsNullOrWhiteSpace(Model.CardId) || Model.StartDate.HasValue || Model.EndDate.HasValue)
{
<div class="mt-2">
<a asp-page="/Transactions" class="btn btn-sm btn-outline-secondary">Clear Filters</a>
@@ -198,6 +208,7 @@ else
asp-page="/Transactions"
asp-route-pageNumber="@(Model.PageNumber - 1)"
asp-route-category="@Model.Category"
asp-route-merchant="@Model.Merchant"
asp-route-cardId="@Model.CardId"
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
@@ -213,6 +224,7 @@ else
asp-page="/Transactions"
asp-route-pageNumber="1"
asp-route-category="@Model.Category"
asp-route-merchant="@Model.Merchant"
asp-route-cardId="@Model.CardId"
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
@@ -230,6 +242,7 @@ else
asp-page="/Transactions"
asp-route-pageNumber="@i"
asp-route-category="@Model.Category"
asp-route-merchant="@Model.Merchant"
asp-route-cardId="@Model.CardId"
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
@@ -247,6 +260,7 @@ else
asp-page="/Transactions"
asp-route-pageNumber="@Model.TotalPages"
asp-route-category="@Model.Category"
asp-route-merchant="@Model.Merchant"
asp-route-cardId="@Model.CardId"
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
@@ -261,6 +275,7 @@ else
asp-page="/Transactions"
asp-route-pageNumber="@(Model.PageNumber + 1)"
asp-route-category="@Model.Category"
asp-route-merchant="@Model.Merchant"
asp-route-cardId="@Model.CardId"
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">

View File

@@ -22,6 +22,9 @@ namespace MoneyMap.Pages
[BindProperty(SupportsGet = true)]
public string? Category { get; set; }
[BindProperty(SupportsGet = true)]
public string? Merchant { get; set; }
[BindProperty(SupportsGet = true)]
public string? CardId { get; set; }
@@ -40,6 +43,7 @@ namespace MoneyMap.Pages
public List<TransactionRow> Transactions { get; set; } = new();
public List<string> AvailableCategories { get; set; } = new();
public List<string> AvailableMerchants { get; set; } = new();
public List<Card> AvailableCards { get; set; } = new();
public TransactionStats Stats { get; set; } = new();
@@ -65,6 +69,18 @@ namespace MoneyMap.Pages
}
}
if (!string.IsNullOrWhiteSpace(Merchant))
{
if (Merchant == "(blank)")
{
query = query.Where(t => string.IsNullOrWhiteSpace(t.Merchant));
}
else
{
query = query.Where(t => t.Merchant == Merchant);
}
}
if (!string.IsNullOrWhiteSpace(CardId) && int.TryParse(CardId, out int cardIdInt))
{
query = query.Where(t => t.CardId == cardIdInt);
@@ -137,6 +153,14 @@ namespace MoneyMap.Pages
.OrderBy(c => c)
.ToListAsync();
// Get available merchants for filter dropdown
AvailableMerchants = await _db.Transactions
.Where(t => !string.IsNullOrWhiteSpace(t.Merchant))
.Select(t => t.Merchant!)
.Distinct()
.OrderBy(m => m)
.ToListAsync();
// Get available cards for filter dropdown
AvailableCards = await _db.Cards
.OrderBy(c => c.Owner)

View File

@@ -98,8 +98,10 @@ namespace MoneyMap.Pages
{
if (string.IsNullOrWhiteSpace(preview.Transaction.Category))
{
preview.SuggestedCategory = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount);
preview.Transaction.Category = preview.SuggestedCategory ?? "";
var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount);
preview.Transaction.Category = categorizationResult.Category;
preview.Transaction.Merchant = categorizationResult.Merchant;
preview.SuggestedCategory = categorizationResult.Category;
}
}

View File

@@ -99,6 +99,14 @@ namespace MoneyMap.Services
receipt.Tax = parseData.Tax;
receipt.ReceiptDate = parseData.ReceiptDate;
// Update transaction merchant if we extracted one and transaction doesn't have one yet
if (receipt.Transaction != null &&
!string.IsNullOrWhiteSpace(parseData.Merchant) &&
string.IsNullOrWhiteSpace(receipt.Transaction.Merchant))
{
receipt.Transaction.Merchant = parseData.Merchant;
}
// Remove existing line items
var existingItems = await _db.ReceiptLineItems
.Where(li => li.ReceiptId == receiptId)

View File

@@ -15,17 +15,24 @@ namespace MoneyMap.Services
public required string Category { get; set; }
public required string Pattern { get; set; }
public int Priority { get; set; } = 0; // Higher priority = checked first
public string? Merchant { get; set; } // Friendly merchant name to assign to matching transactions
}
// ===== Service Interface =====
public interface ITransactionCategorizer
{
Task<string> CategorizeAsync(string merchantName, decimal? amount = null);
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
Task<List<CategoryMapping>> GetAllMappingsAsync();
Task SeedDefaultMappingsAsync();
}
public class CategorizationResult
{
public string Category { get; set; } = string.Empty;
public string? Merchant { get; set; }
}
// ===== Service Implementation =====
public class TransactionCategorizer : ITransactionCategorizer
@@ -38,10 +45,10 @@ namespace MoneyMap.Services
_db = db;
}
public async Task<string> CategorizeAsync(string merchantName, decimal? amount = null)
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
{
if (string.IsNullOrWhiteSpace(merchantName))
return string.Empty;
return new CategorizationResult();
var merchantUpper = merchantName.ToUpperInvariant();
@@ -59,17 +66,25 @@ namespace MoneyMap.Services
merchantUpper.Contains(m.Pattern.ToUpperInvariant()));
if (gasMapping != null)
return "Convenience Store";
return new CategorizationResult
{
Category = "Convenience Store",
Merchant = gasMapping.Merchant
};
}
// Check each category's patterns
foreach (var mapping in mappings)
{
if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant()))
return mapping.Category;
return new CategorizationResult
{
Category = mapping.Category,
Merchant = mapping.Merchant
};
}
return string.Empty; // No match - needs manual categorization
return new CategorizationResult(); // No match - needs manual categorization
}
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
@@ -213,10 +228,15 @@ namespace MoneyMap.Services
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
{
AddMappings(category, mappings, 0, patterns);
AddMappings(category, mappings, 0, null, patterns);
}
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
{
AddMappings(category, mappings, priority, null, patterns);
}
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, string? merchant, params string[] patterns)
{
foreach (var pattern in patterns)
{
@@ -224,6 +244,7 @@ namespace MoneyMap.Services
{
Category = category,
Pattern = pattern,
Merchant = merchant,
Priority = priority
});
}