using Microsoft.EntityFrameworkCore; using MoneyMap.Models; namespace MoneyMap.Data { public class MoneyMapContext : DbContext { public MoneyMapContext(DbContextOptions options) : base(options) { } public DbSet Cards => Set(); public DbSet Accounts => Set(); public DbSet Transactions => Set(); public DbSet Transfers => Set(); public DbSet Receipts => Set(); public DbSet ReceiptParseLogs => Set(); public DbSet ReceiptLineItems => Set(); public DbSet CategoryMappings => Set(); public DbSet Merchants => Set(); public DbSet Budgets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // ---------- CARD ---------- modelBuilder.Entity(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(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(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(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(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); e.Property(x => x.ParseStatus).HasDefaultValue(ReceiptParseStatus.NotRequested); e.HasIndex(x => x.ParseStatus); // Receipt can optionally 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) .IsRequired(false); }); // ---------- RECEIPT PARSE LOG ---------- modelBuilder.Entity(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(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(e => { e.Property(x => x.Name).HasMaxLength(100).IsRequired(); e.HasIndex(x => x.Name).IsUnique(); }); // ---------- CATEGORY MAPPING ---------- modelBuilder.Entity(e => { e.Property(x => x.Category).HasMaxLength(100).IsRequired(); e.Property(x => x.Pattern).HasMaxLength(200).IsRequired(); e.Property(x => x.Confidence).HasColumnType("decimal(5,4)"); // 0.0000 to 1.0000 e.Property(x => x.CreatedBy).HasMaxLength(50); // 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().HasIndex(x => x.Date); modelBuilder.Entity().HasIndex(x => x.Amount); modelBuilder.Entity().HasIndex(x => x.Category); modelBuilder.Entity().HasIndex(x => x.MerchantId); // Composite indexes for common query patterns modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Category }); modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Date }); modelBuilder.Entity().HasIndex(x => new { x.MerchantId, x.Date }); modelBuilder.Entity().HasIndex(x => new { x.CardId, x.Date }); // Receipt duplicate detection and lookup modelBuilder.Entity().HasIndex(x => x.FileHashSha256); modelBuilder.Entity().HasIndex(x => new { x.TransactionId, x.ReceiptDate }); // ---------- BUDGET ---------- modelBuilder.Entity(e => { e.Property(x => x.Category).HasMaxLength(100); e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); e.Property(x => x.Notes).HasMaxLength(500); // Only one active budget per category per period // Null category = total budget, so we use a filtered unique index e.HasIndex(x => new { x.Category, x.Period }) .HasFilter("[IsActive] = 1") .IsUnique(); }); } } }