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>
200 lines
8.7 KiB
C#
200 lines
8.7 KiB
C#
using System;
|
||
using System.Text.RegularExpressions;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using MoneyMap.Models;
|
||
using MoneyMap.Services;
|
||
|
||
namespace MoneyMap.Data
|
||
{
|
||
public class MoneyMapContext : DbContext
|
||
{
|
||
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
|
||
|
||
public DbSet<Card> Cards => Set<Card>();
|
||
public DbSet<Account> Accounts => Set<Account>();
|
||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||
public DbSet<Transfer> Transfers => Set<Transfer>();
|
||
public DbSet<Receipt> Receipts => Set<Receipt>();
|
||
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
||
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
||
|
||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||
|
||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||
{
|
||
// ---------- CARD ----------
|
||
modelBuilder.Entity<Card>(e =>
|
||
{
|
||
e.Property(x => x.Issuer).HasMaxLength(100).IsRequired();
|
||
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
|
||
e.Property(x => x.Owner).HasMaxLength(100).IsRequired();
|
||
e.Property(x => x.Nickname).HasMaxLength(50);
|
||
|
||
// Card can be linked to an account (optional - for credit cards without linked account)
|
||
e.HasOne(x => x.Account)
|
||
.WithMany(a => a.Cards)
|
||
.HasForeignKey(x => x.AccountId)
|
||
.OnDelete(DeleteBehavior.Restrict)
|
||
.IsRequired(false);
|
||
|
||
e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner });
|
||
e.HasIndex(x => x.AccountId);
|
||
});
|
||
|
||
// ---------- ACCOUNT ----------
|
||
modelBuilder.Entity<Account>(e =>
|
||
{
|
||
e.Property(x => x.Institution).HasMaxLength(100).IsRequired();
|
||
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
|
||
e.Property(x => x.Owner).HasMaxLength(100).IsRequired();
|
||
e.Property(x => x.Nickname).HasMaxLength(50);
|
||
e.HasIndex(x => new { x.Institution, x.Last4, x.Owner });
|
||
});
|
||
|
||
// ---------- TRANSACTION ----------
|
||
modelBuilder.Entity<Transaction>(e =>
|
||
{
|
||
e.Property(x => x.TransactionType).HasMaxLength(20);
|
||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||
e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty);
|
||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||
e.Property(x => x.Category).HasMaxLength(100);
|
||
e.Property(x => x.Last4).HasMaxLength(4);
|
||
|
||
// Card (optional). If a card is deleted, block delete when txns exist.
|
||
e.HasOne(x => x.Card)
|
||
.WithMany(c => c.Transactions)
|
||
.HasForeignKey(x => x.CardId)
|
||
.OnDelete(DeleteBehavior.Restrict)
|
||
.IsRequired(false);
|
||
|
||
// Account (optional). If an account is deleted, block delete when txns exist.
|
||
e.HasOne(x => x.Account)
|
||
.WithMany(a => a.Transactions)
|
||
.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 ----------
|
||
modelBuilder.Entity<Transfer>(e =>
|
||
{
|
||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||
e.Property(x => x.Description).HasMaxLength(500);
|
||
|
||
// Source account (optional - can be "Unknown")
|
||
e.HasOne(x => x.SourceAccount)
|
||
.WithMany(a => a.SourceTransfers)
|
||
.HasForeignKey(x => x.SourceAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict)
|
||
.IsRequired(false);
|
||
|
||
// Destination account (optional - can be "Unknown")
|
||
e.HasOne(x => x.DestinationAccount)
|
||
.WithMany(a => a.DestinationTransfers)
|
||
.HasForeignKey(x => x.DestinationAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict)
|
||
.IsRequired(false);
|
||
|
||
// Original transaction link (optional)
|
||
e.HasOne(x => x.OriginalTransaction)
|
||
.WithMany()
|
||
.HasForeignKey(x => x.OriginalTransactionId)
|
||
.OnDelete(DeleteBehavior.SetNull)
|
||
.IsRequired(false);
|
||
|
||
e.HasIndex(x => x.Date);
|
||
e.HasIndex(x => x.SourceAccountId);
|
||
e.HasIndex(x => x.DestinationAccountId);
|
||
});
|
||
|
||
// ---------- RECEIPT ----------
|
||
modelBuilder.Entity<Receipt>(e =>
|
||
{
|
||
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
|
||
e.Property(x => x.ContentType).HasMaxLength(100).HasDefaultValue("application/octet-stream");
|
||
e.Property(x => x.StoragePath).HasMaxLength(1024).IsRequired();
|
||
e.Property(x => x.FileHashSha256).HasMaxLength(64).IsRequired();
|
||
|
||
e.Property(x => x.Merchant).HasMaxLength(200);
|
||
e.Property(x => x.Subtotal).HasColumnType("decimal(18,2)");
|
||
e.Property(x => x.Tax).HasColumnType("decimal(18,2)");
|
||
e.Property(x => x.Total).HasColumnType("decimal(18,2)");
|
||
e.Property(x => x.Currency).HasMaxLength(8);
|
||
|
||
// Receipt must belong to a Transaction. If txn is deleted, cascade remove receipts.
|
||
e.HasOne(x => x.Transaction)
|
||
.WithMany(t => t.Receipts)
|
||
.HasForeignKey(x => x.TransactionId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
});
|
||
|
||
// ---------- RECEIPT PARSE LOG ----------
|
||
modelBuilder.Entity<ReceiptParseLog>(e =>
|
||
{
|
||
e.Property(x => x.Provider).HasMaxLength(50).IsRequired();
|
||
e.Property(x => x.Model).HasMaxLength(100).IsRequired();
|
||
e.Property(x => x.ProviderJobId).HasMaxLength(100);
|
||
e.Property(x => x.ExtractedTextPath).HasMaxLength(1024);
|
||
|
||
e.HasOne(x => x.Receipt)
|
||
.WithMany(r => r.ParseLogs)
|
||
.HasForeignKey(x => x.ReceiptId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
});
|
||
|
||
// ---------- RECEIPT LINE ITEM ----------
|
||
modelBuilder.Entity<ReceiptLineItem>(e =>
|
||
{
|
||
e.Property(x => x.Description).HasMaxLength(300).IsRequired();
|
||
e.Property(x => x.Unit).HasMaxLength(16);
|
||
e.Property(x => x.UnitPrice).HasColumnType("decimal(18,4)");
|
||
e.Property(x => x.LineTotal).HasColumnType("decimal(18,2)");
|
||
e.Property(x => x.Sku).HasMaxLength(64);
|
||
e.Property(x => x.Category).HasMaxLength(100);
|
||
|
||
e.HasOne(x => x.Receipt)
|
||
.WithMany(r => r.LineItems)
|
||
.HasForeignKey(x => x.ReceiptId)
|
||
.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 Server–friendly 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);
|
||
}
|
||
}
|
||
}
|