Convert merchant from string to entity with foreign keys

This refactors the merchant field from a simple string column to a normalized
entity with proper foreign key relationships:

**Database Changes:**
- Created Merchant entity/table with unique Name constraint
- Replaced Transaction.Merchant (string) with Transaction.MerchantId (FK)
- Replaced CategoryMapping.Merchant (string) with CategoryMapping.MerchantId (FK)
- Added proper foreign key constraints with SET NULL on delete
- Added indexes on MerchantId columns for performance

**Backend Changes:**
- Created MerchantService for finding/creating merchants
- Updated CategorizationResult to return MerchantId instead of merchant name
- Modified TransactionCategorizer to return MerchantId from pattern matches
- Updated Upload, Recategorize, and CategoryMappings to use merchant service
- Updated OpenAIReceiptParser to create/link merchants from parsed receipts
- Registered IMerchantService in dependency injection

**UI Changes:**
- Updated CategoryMappings UI to handle merchant entities (display as Merchant.Name)
- Updated Transactions page merchant filter to query by merchant entity
- Modified category mapping add/edit/import to create merchants on-the-fly
- Updated JavaScript to pass merchant names for edit modal

**Migration:**
- ConvertMerchantToEntity migration handles schema conversion
- Drops old string columns and creates new FK relationships
- All existing merchant data is lost (acceptable for this refactoring)

**Benefits:**
- Database normalization - merchants stored once, referenced many times
- Referential integrity with foreign keys
- Easier merchant management (rename once, updates everywhere)
- Foundation for future merchant features (logos, categories, etc.)
- Improved query performance with proper indexes

🤖 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:52:05 -04:00
parent 675ffa6509
commit b1143ad484
15 changed files with 990 additions and 48 deletions
+30
View File
@@ -19,6 +19,7 @@ namespace MoneyMap.Data
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>(); public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>(); public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
public DbSet<Merchant> Merchants => Set<Merchant>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -74,6 +75,13 @@ namespace MoneyMap.Data
.HasForeignKey(x => x.AccountId) .HasForeignKey(x => x.AccountId)
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(false); .IsRequired(false);
// Merchant (optional). If a merchant is deleted, set to null.
e.HasOne(x => x.Merchant)
.WithMany(m => m.Transactions)
.HasForeignKey(x => x.MerchantId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
}); });
// ---------- TRANSFER ---------- // ---------- TRANSFER ----------
@@ -159,11 +167,33 @@ namespace MoneyMap.Data
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// ---------- MERCHANT ----------
modelBuilder.Entity<Merchant>(e =>
{
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
e.HasIndex(x => x.Name).IsUnique();
});
// ---------- CATEGORY MAPPING ----------
modelBuilder.Entity<CategoryMapping>(e =>
{
e.Property(x => x.Category).HasMaxLength(100).IsRequired();
e.Property(x => x.Pattern).HasMaxLength(200).IsRequired();
// Merchant (optional). If a merchant is deleted, set to null.
e.HasOne(x => x.Merchant)
.WithMany(m => m.CategoryMappings)
.HasForeignKey(x => x.MerchantId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
});
// ---------- Extra SQL Serverfriendly indexes ---------- // ---------- Extra SQL Serverfriendly indexes ----------
// Fast filtering by date/amount/category // Fast filtering by date/amount/category
modelBuilder.Entity<Transaction>().HasIndex(x => x.Date); modelBuilder.Entity<Transaction>().HasIndex(x => x.Date);
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount); modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category); modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
} }
} }
} }
@@ -0,0 +1,596 @@
// <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("20251012075038_ConvertMerchantToEntity")]
partial class ConvertMerchantToEntity
{
/// <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<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<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<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)
.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.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,159 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class ConvertMerchantToEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Merchant",
table: "Transactions");
migrationBuilder.DropColumn(
name: "Merchant",
table: "CategoryMappings");
migrationBuilder.AddColumn<int>(
name: "MerchantId",
table: "Transactions",
type: "int",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "Pattern",
table: "CategoryMappings",
type: "nvarchar(200)",
maxLength: 200,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "Category",
table: "CategoryMappings",
type: "nvarchar(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AddColumn<int>(
name: "MerchantId",
table: "CategoryMappings",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "Merchants",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Merchants", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Transactions_MerchantId",
table: "Transactions",
column: "MerchantId");
migrationBuilder.CreateIndex(
name: "IX_CategoryMappings_MerchantId",
table: "CategoryMappings",
column: "MerchantId");
migrationBuilder.CreateIndex(
name: "IX_Merchants_Name",
table: "Merchants",
column: "Name",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_CategoryMappings_Merchants_MerchantId",
table: "CategoryMappings",
column: "MerchantId",
principalTable: "Merchants",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Merchants_MerchantId",
table: "Transactions",
column: "MerchantId",
principalTable: "Merchants",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CategoryMappings_Merchants_MerchantId",
table: "CategoryMappings");
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Merchants_MerchantId",
table: "Transactions");
migrationBuilder.DropTable(
name: "Merchants");
migrationBuilder.DropIndex(
name: "IX_Transactions_MerchantId",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_CategoryMappings_MerchantId",
table: "CategoryMappings");
migrationBuilder.DropColumn(
name: "MerchantId",
table: "Transactions");
migrationBuilder.DropColumn(
name: "MerchantId",
table: "CategoryMappings");
migrationBuilder.AddColumn<string>(
name: "Merchant",
table: "Transactions",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "Pattern",
table: "CategoryMappings",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(200)",
oldMaxLength: 200);
migrationBuilder.AlterColumn<string>(
name: "Category",
table: "CategoryMappings",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(100)",
oldMaxLength: 100);
migrationBuilder.AddColumn<string>(
name: "Merchant",
table: "CategoryMappings",
type: "nvarchar(max)",
nullable: true);
}
}
}
@@ -98,6 +98,27 @@ namespace MoneyMap.Migrations
b.ToTable("Cards"); 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 => modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -303,9 +324,8 @@ namespace MoneyMap.Migrations
.HasColumnType("nvarchar(500)") .HasColumnType("nvarchar(500)")
.HasDefaultValue(""); .HasDefaultValue("");
b.Property<string>("Merchant") b.Property<int?>("MerchantId")
.HasMaxLength(100) .HasColumnType("int");
.HasColumnType("nvarchar(100)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
@@ -336,6 +356,8 @@ namespace MoneyMap.Migrations
b.HasIndex("Date"); b.HasIndex("Date");
b.HasIndex("MerchantId");
b.HasIndex("TransferToAccountId"); b.HasIndex("TransferToAccountId");
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId") b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
@@ -403,20 +425,24 @@ namespace MoneyMap.Migrations
b.Property<string>("Category") b.Property<string>("Category")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Merchant") b.Property<int?>("MerchantId")
.HasColumnType("nvarchar(max)"); .HasColumnType("int");
b.Property<string>("Pattern") b.Property<string>("Pattern")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Priority") b.Property<int>("Priority")
.HasColumnType("int"); .HasColumnType("int");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MerchantId");
b.ToTable("CategoryMappings"); b.ToTable("CategoryMappings");
}); });
@@ -475,6 +501,11 @@ namespace MoneyMap.Migrations
.HasForeignKey("CardId") .HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
.WithMany("Transactions")
.HasForeignKey("MerchantId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MoneyMap.Models.Account", "TransferToAccount") b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
.WithMany() .WithMany()
.HasForeignKey("TransferToAccountId"); .HasForeignKey("TransferToAccountId");
@@ -483,6 +514,8 @@ namespace MoneyMap.Migrations
b.Navigation("Card"); b.Navigation("Card");
b.Navigation("Merchant");
b.Navigation("TransferToAccount"); b.Navigation("TransferToAccount");
}); });
@@ -510,6 +543,16 @@ namespace MoneyMap.Migrations
b.Navigation("SourceAccount"); 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 => modelBuilder.Entity("MoneyMap.Models.Account", b =>
{ {
b.Navigation("Cards"); b.Navigation("Cards");
@@ -526,6 +569,13 @@ namespace MoneyMap.Migrations
b.Navigation("Transactions"); b.Navigation("Transactions");
}); });
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
{
b.Navigation("CategoryMappings");
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b => modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{ {
b.Navigation("LineItems"); b.Navigation("LineItems");
+17
View File
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using MoneyMap.Services;
namespace MoneyMap.Models;
public class Merchant
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
}
+4 -2
View File
@@ -29,8 +29,10 @@ public class Transaction
[MaxLength(100)] [MaxLength(100)]
public string Category { get; set; } = string.Empty; public string Category { get; set; } = string.Empty;
[MaxLength(100)] // Merchant relationship
public string? Merchant { get; set; } [ForeignKey(nameof(Merchant))]
public int? MerchantId { get; set; }
public Merchant? Merchant { get; set; }
public string Notes { get; set; } = string.Empty; public string Notes { get; set; } = string.Empty;
+3 -3
View File
@@ -100,15 +100,15 @@
{ {
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted" style="cursor: pointer;" <span class="text-muted" style="cursor: pointer;"
onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority, '@Html.Raw((mapping.Merchant ?? "").Replace("'", "\\'"))')"> onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority, '@Html.Raw((mapping.Merchant?.Name ?? "").Replace("'", "\\'"))')">
@if (mapping.Priority > 0) @if (mapping.Priority > 0)
{ {
<span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span> <span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span>
} }
@mapping.Pattern @mapping.Pattern
@if (!string.IsNullOrWhiteSpace(mapping.Merchant)) @if (mapping.Merchant != null)
{ {
<span class="text-primary ms-1" title="Merchant: @mapping.Merchant">→ @mapping.Merchant</span> <span class="text-primary ms-1" title="Merchant: @mapping.Merchant.Name">→ @mapping.Merchant.Name</span>
} }
</span> </span>
<form method="post" asp-page-handler="DeleteMapping" asp-route-id="@mapping.Id" <form method="post" asp-page-handler="DeleteMapping" asp-route-id="@mapping.Id"
+40 -12
View File
@@ -17,11 +17,13 @@ namespace MoneyMap.Pages
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer; private readonly ITransactionCategorizer _categorizer;
private readonly IMerchantService _merchantService;
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer) public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
{ {
_db = db; _db = db;
_categorizer = categorizer; _categorizer = categorizer;
_merchantService = merchantService;
} }
public List<CategoryGroup> CategoryGroups { get; set; } = new(); public List<CategoryGroup> CategoryGroups { get; set; } = new();
@@ -55,11 +57,17 @@ namespace MoneyMap.Pages
return Page(); return Page();
} }
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(model.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
}
var mapping = new CategoryMapping var mapping = new CategoryMapping
{ {
Category = model.Category.Trim(), Category = model.Category.Trim(),
Pattern = model.Pattern.Trim(), Pattern = model.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(), MerchantId = merchantId,
Priority = model.Priority Priority = model.Priority
}; };
@@ -86,9 +94,15 @@ namespace MoneyMap.Pages
return RedirectToPage(); return RedirectToPage();
} }
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(model.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
}
mapping.Category = model.Category.Trim(); mapping.Category = model.Category.Trim();
mapping.Pattern = model.Pattern.Trim(); mapping.Pattern = model.Pattern.Trim();
mapping.Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(); mapping.MerchantId = merchantId;
mapping.Priority = model.Priority; mapping.Priority = model.Priority;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -124,7 +138,7 @@ namespace MoneyMap.Pages
{ {
Category = m.Category, Category = m.Category,
Pattern = m.Pattern, Pattern = m.Pattern,
Merchant = m.Merchant, Merchant = m.Merchant?.Name,
Priority = m.Priority Priority = m.Priority
}).ToList(); }).ToList();
@@ -176,14 +190,24 @@ namespace MoneyMap.Pages
_db.CategoryMappings.RemoveRange(existingMappings); _db.CategoryMappings.RemoveRange(existingMappings);
} }
// Add new mappings // Add new mappings (create merchants first if needed)
var newMappings = importData.Select(m => new CategoryMapping var newMappings = new List<CategoryMapping>();
foreach (var item in importData)
{ {
Category = m.Category.Trim(), int? merchantId = null;
Pattern = m.Pattern.Trim(), if (!string.IsNullOrWhiteSpace(item.Merchant))
Merchant = string.IsNullOrWhiteSpace(m.Merchant) ? null : m.Merchant.Trim(), {
Priority = m.Priority merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant);
}).ToList(); }
newMappings.Add(new CategoryMapping
{
Category = item.Category.Trim(),
Pattern = item.Pattern.Trim(),
MerchantId = merchantId,
Priority = item.Priority
});
}
_db.CategoryMappings.AddRange(newMappings); _db.CategoryMappings.AddRange(newMappings);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -208,7 +232,11 @@ namespace MoneyMap.Pages
private async Task LoadDataAsync() private async Task LoadDataAsync()
{ {
var mappings = await _categorizer.GetAllMappingsAsync(); var mappings = await _db.CategoryMappings
.Include(m => m.Merchant)
.OrderBy(m => m.Category)
.ThenByDescending(m => m.Priority)
.ToListAsync();
CategoryGroups = mappings CategoryGroups = mappings
.GroupBy(m => m.Category) .GroupBy(m => m.Category)
+2 -2
View File
@@ -88,14 +88,14 @@ namespace MoneyMap.Pages
continue; continue;
} }
if (txn.Category == result.Category && txn.Merchant == result.Merchant) if (txn.Category == result.Category && txn.MerchantId == result.MerchantId)
{ {
alreadyCorrect++; alreadyCorrect++;
continue; continue;
} }
txn.Category = result.Category; txn.Category = result.Category;
txn.Merchant = result.Merchant; txn.MerchantId = result.MerchantId;
updated++; updated++;
} }
+5 -7
View File
@@ -73,11 +73,11 @@ namespace MoneyMap.Pages
{ {
if (Merchant == "(blank)") if (Merchant == "(blank)")
{ {
query = query.Where(t => string.IsNullOrWhiteSpace(t.Merchant)); query = query.Where(t => t.MerchantId == null);
} }
else else
{ {
query = query.Where(t => t.Merchant == Merchant); query = query.Where(t => t.Merchant != null && t.Merchant.Name == Merchant);
} }
} }
@@ -154,11 +154,9 @@ namespace MoneyMap.Pages
.ToListAsync(); .ToListAsync();
// Get available merchants for filter dropdown // Get available merchants for filter dropdown
AvailableMerchants = await _db.Transactions AvailableMerchants = await _db.Merchants
.Where(t => !string.IsNullOrWhiteSpace(t.Merchant)) .OrderBy(m => m.Name)
.Select(t => t.Merchant!) .Select(m => m.Name)
.Distinct()
.OrderBy(m => m)
.ToListAsync(); .ToListAsync();
// Get available cards for filter dropdown // Get available cards for filter dropdown
+1 -1
View File
@@ -100,7 +100,7 @@ namespace MoneyMap.Pages
{ {
var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount); var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount);
preview.Transaction.Category = categorizationResult.Category; preview.Transaction.Category = categorizationResult.Category;
preview.Transaction.Merchant = categorizationResult.Merchant; preview.Transaction.MerchantId = categorizationResult.MerchantId;
preview.SuggestedCategory = categorizationResult.Category; preview.SuggestedCategory = categorizationResult.Category;
} }
} }
+1
View File
@@ -24,6 +24,7 @@ builder.Services.AddSession(options =>
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>(); builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
builder.Services.AddScoped<ICardResolver, CardResolver>(); builder.Services.AddScoped<ICardResolver, CardResolver>();
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>(); builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
builder.Services.AddScoped<IMerchantService, MerchantService>();
// Dashboard services // Dashboard services
builder.Services.AddScoped<IDashboardService, DashboardService>(); builder.Services.AddScoped<IDashboardService, DashboardService>();
+59
View File
@@ -0,0 +1,59 @@
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services
{
public interface IMerchantService
{
Task<Merchant?> FindByNameAsync(string name);
Task<Merchant> GetOrCreateAsync(string name);
Task<int?> GetOrCreateIdAsync(string? name);
}
public class MerchantService : IMerchantService
{
private readonly MoneyMapContext _db;
public MerchantService(MoneyMapContext db)
{
_db = db;
}
public async Task<Merchant?> FindByNameAsync(string name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
return await _db.Merchants
.FirstOrDefaultAsync(m => m.Name == name.Trim());
}
public async Task<Merchant> GetOrCreateAsync(string name)
{
var trimmedName = name.Trim();
var existing = await _db.Merchants
.FirstOrDefaultAsync(m => m.Name == trimmedName);
if (existing != null)
return existing;
var merchant = new Merchant { Name = trimmedName };
_db.Merchants.Add(merchant);
await _db.SaveChangesAsync();
return merchant;
}
public async Task<int?> GetOrCreateIdAsync(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var merchant = await GetOrCreateAsync(name);
return merchant.Id;
}
}
}
+7 -3
View File
@@ -26,17 +26,20 @@ namespace MoneyMap.Services
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IMerchantService _merchantService;
public OpenAIReceiptParser( public OpenAIReceiptParser(
MoneyMapContext db, MoneyMapContext db,
IWebHostEnvironment environment, IWebHostEnvironment environment,
IConfiguration configuration, IConfiguration configuration,
HttpClient httpClient) HttpClient httpClient,
IMerchantService merchantService)
{ {
_db = db; _db = db;
_environment = environment; _environment = environment;
_configuration = configuration; _configuration = configuration;
_httpClient = httpClient; _httpClient = httpClient;
_merchantService = merchantService;
} }
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId) public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId)
@@ -102,9 +105,10 @@ namespace MoneyMap.Services
// Update transaction merchant if we extracted one and transaction doesn't have one yet // Update transaction merchant if we extracted one and transaction doesn't have one yet
if (receipt.Transaction != null && if (receipt.Transaction != null &&
!string.IsNullOrWhiteSpace(parseData.Merchant) && !string.IsNullOrWhiteSpace(parseData.Merchant) &&
string.IsNullOrWhiteSpace(receipt.Transaction.Merchant)) receipt.Transaction.MerchantId == null)
{ {
receipt.Transaction.Merchant = parseData.Merchant; var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant);
receipt.Transaction.MerchantId = merchantId;
} }
// Remove existing line items // Remove existing line items
+9 -11
View File
@@ -15,7 +15,10 @@ namespace MoneyMap.Services
public required string Category { get; set; } public required string Category { get; set; }
public required string Pattern { get; set; } public required string Pattern { get; set; }
public int Priority { get; set; } = 0; // Higher priority = checked first public int Priority { get; set; } = 0; // Higher priority = checked first
public string? Merchant { get; set; } // Friendly merchant name to assign to matching transactions
// Merchant relationship
public int? MerchantId { get; set; }
public Models.Merchant? Merchant { get; set; }
} }
// ===== Service Interface ===== // ===== Service Interface =====
@@ -30,7 +33,7 @@ namespace MoneyMap.Services
public class CategorizationResult public class CategorizationResult
{ {
public string Category { get; set; } = string.Empty; public string Category { get; set; } = string.Empty;
public string? Merchant { get; set; } public int? MerchantId { get; set; }
} }
// ===== Service Implementation ===== // ===== Service Implementation =====
@@ -69,7 +72,7 @@ namespace MoneyMap.Services
return new CategorizationResult return new CategorizationResult
{ {
Category = "Convenience Store", Category = "Convenience Store",
Merchant = gasMapping.Merchant MerchantId = gasMapping.MerchantId
}; };
} }
@@ -80,7 +83,7 @@ namespace MoneyMap.Services
return new CategorizationResult return new CategorizationResult
{ {
Category = mapping.Category, Category = mapping.Category,
Merchant = mapping.Merchant MerchantId = mapping.MerchantId
}; };
} }
@@ -228,15 +231,10 @@ namespace MoneyMap.Services
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns) private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
{ {
AddMappings(category, mappings, 0, null, patterns); AddMappings(category, mappings, 0, patterns);
} }
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] 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) foreach (var pattern in patterns)
{ {
@@ -244,7 +242,7 @@ namespace MoneyMap.Services
{ {
Category = category, Category = category,
Pattern = pattern, Pattern = pattern,
Merchant = merchant, MerchantId = null, // Will be set by users via UI
Priority = priority Priority = priority
}); });
} }