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

View File

@@ -19,6 +19,7 @@ namespace MoneyMap.Data
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
public DbSet<Merchant> Merchants => Set<Merchant>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -74,6 +75,13 @@ namespace MoneyMap.Data
.HasForeignKey(x => x.AccountId)
.OnDelete(DeleteBehavior.Restrict)
.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 ----------
@@ -159,11 +167,33 @@ namespace MoneyMap.Data
.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 ----------
// Fast filtering by date/amount/category
modelBuilder.Entity<Transaction>().HasIndex(x => x.Date);
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -98,6 +98,27 @@ namespace MoneyMap.Migrations
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")
@@ -303,9 +324,8 @@ namespace MoneyMap.Migrations
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("Merchant")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
@@ -336,6 +356,8 @@ namespace MoneyMap.Migrations
b.HasIndex("Date");
b.HasIndex("MerchantId");
b.HasIndex("TransferToAccountId");
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
@@ -403,20 +425,24 @@ namespace MoneyMap.Migrations
b.Property<string>("Category")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Merchant")
.HasColumnType("nvarchar(max)");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MerchantId");
b.ToTable("CategoryMappings");
});
@@ -475,6 +501,11 @@ namespace MoneyMap.Migrations
.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");
@@ -483,6 +514,8 @@ namespace MoneyMap.Migrations
b.Navigation("Card");
b.Navigation("Merchant");
b.Navigation("TransferToAccount");
});
@@ -510,6 +543,16 @@ namespace MoneyMap.Migrations
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");
@@ -526,6 +569,13 @@ namespace MoneyMap.Migrations
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
{
b.Navigation("CategoryMappings");
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Navigation("LineItems");

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>();
}

View File

@@ -29,8 +29,10 @@ public class Transaction
[MaxLength(100)]
public string Category { get; set; } = string.Empty;
[MaxLength(100)]
public string? Merchant { get; set; }
// Merchant relationship
[ForeignKey(nameof(Merchant))]
public int? MerchantId { get; set; }
public Merchant? Merchant { get; set; }
public string Notes { get; set; } = string.Empty;

View File

@@ -100,15 +100,15 @@
{
<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, '@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)
{
<span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span>
}
@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>
<form method="post" asp-page-handler="DeleteMapping" asp-route-id="@mapping.Id"

View File

@@ -17,11 +17,13 @@ namespace MoneyMap.Pages
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
private readonly IMerchantService _merchantService;
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer)
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
{
_db = db;
_categorizer = categorizer;
_merchantService = merchantService;
}
public List<CategoryGroup> CategoryGroups { get; set; } = new();
@@ -55,11 +57,17 @@ namespace MoneyMap.Pages
return Page();
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(model.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
}
var mapping = new CategoryMapping
{
Category = model.Category.Trim(),
Pattern = model.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(),
MerchantId = merchantId,
Priority = model.Priority
};
@@ -86,9 +94,15 @@ namespace MoneyMap.Pages
return RedirectToPage();
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(model.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
}
mapping.Category = model.Category.Trim();
mapping.Pattern = model.Pattern.Trim();
mapping.Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim();
mapping.MerchantId = merchantId;
mapping.Priority = model.Priority;
await _db.SaveChangesAsync();
@@ -124,7 +138,7 @@ namespace MoneyMap.Pages
{
Category = m.Category,
Pattern = m.Pattern,
Merchant = m.Merchant,
Merchant = m.Merchant?.Name,
Priority = m.Priority
}).ToList();
@@ -176,14 +190,24 @@ namespace MoneyMap.Pages
_db.CategoryMappings.RemoveRange(existingMappings);
}
// Add new mappings
var newMappings = importData.Select(m => new CategoryMapping
// Add new mappings (create merchants first if needed)
var newMappings = new List<CategoryMapping>();
foreach (var item in importData)
{
Category = m.Category.Trim(),
Pattern = m.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(m.Merchant) ? null : m.Merchant.Trim(),
Priority = m.Priority
}).ToList();
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(item.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant);
}
newMappings.Add(new CategoryMapping
{
Category = item.Category.Trim(),
Pattern = item.Pattern.Trim(),
MerchantId = merchantId,
Priority = item.Priority
});
}
_db.CategoryMappings.AddRange(newMappings);
await _db.SaveChangesAsync();
@@ -208,7 +232,11 @@ namespace MoneyMap.Pages
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
.GroupBy(m => m.Category)

View File

@@ -88,14 +88,14 @@ namespace MoneyMap.Pages
continue;
}
if (txn.Category == result.Category && txn.Merchant == result.Merchant)
if (txn.Category == result.Category && txn.MerchantId == result.MerchantId)
{
alreadyCorrect++;
continue;
}
txn.Category = result.Category;
txn.Merchant = result.Merchant;
txn.MerchantId = result.MerchantId;
updated++;
}

View File

@@ -73,11 +73,11 @@ namespace MoneyMap.Pages
{
if (Merchant == "(blank)")
{
query = query.Where(t => string.IsNullOrWhiteSpace(t.Merchant));
query = query.Where(t => t.MerchantId == null);
}
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();
// Get available merchants for filter dropdown
AvailableMerchants = await _db.Transactions
.Where(t => !string.IsNullOrWhiteSpace(t.Merchant))
.Select(t => t.Merchant!)
.Distinct()
.OrderBy(m => m)
AvailableMerchants = await _db.Merchants
.OrderBy(m => m.Name)
.Select(m => m.Name)
.ToListAsync();
// Get available cards for filter dropdown

View File

@@ -100,7 +100,7 @@ namespace MoneyMap.Pages
{
var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount);
preview.Transaction.Category = categorizationResult.Category;
preview.Transaction.Merchant = categorizationResult.Merchant;
preview.Transaction.MerchantId = categorizationResult.MerchantId;
preview.SuggestedCategory = categorizationResult.Category;
}
}

View File

@@ -24,6 +24,7 @@ builder.Services.AddSession(options =>
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
builder.Services.AddScoped<ICardResolver, CardResolver>();
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
builder.Services.AddScoped<IMerchantService, MerchantService>();
// Dashboard services
builder.Services.AddScoped<IDashboardService, DashboardService>();

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;
}
}
}

View File

@@ -26,17 +26,20 @@ namespace MoneyMap.Services
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly HttpClient _httpClient;
private readonly IMerchantService _merchantService;
public OpenAIReceiptParser(
MoneyMapContext db,
IWebHostEnvironment environment,
IConfiguration configuration,
HttpClient httpClient)
HttpClient httpClient,
IMerchantService merchantService)
{
_db = db;
_environment = environment;
_configuration = configuration;
_httpClient = httpClient;
_merchantService = merchantService;
}
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
if (receipt.Transaction != null &&
!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

View File

@@ -15,7 +15,10 @@ 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
// Merchant relationship
public int? MerchantId { get; set; }
public Models.Merchant? Merchant { get; set; }
}
// ===== Service Interface =====
@@ -30,7 +33,7 @@ namespace MoneyMap.Services
public class CategorizationResult
{
public string Category { get; set; } = string.Empty;
public string? Merchant { get; set; }
public int? MerchantId { get; set; }
}
// ===== Service Implementation =====
@@ -69,7 +72,7 @@ namespace MoneyMap.Services
return new CategorizationResult
{
Category = "Convenience Store",
Merchant = gasMapping.Merchant
MerchantId = gasMapping.MerchantId
};
}
@@ -80,7 +83,7 @@ namespace MoneyMap.Services
return new CategorizationResult
{
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)
{
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)
{
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)
{
@@ -244,7 +242,7 @@ namespace MoneyMap.Services
{
Category = category,
Pattern = pattern,
Merchant = merchant,
MerchantId = null, // Will be set by users via UI
Priority = priority
});
}