refactor: extract Models and Data into MoneyMap.Core shared library

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:16:33 -04:00
parent d831991ad0
commit 3deca29f05
23 changed files with 59 additions and 7 deletions
+227
View File
@@ -0,0 +1,227 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Models;
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>();
public DbSet<Budget> Budgets => Set<Budget>();
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);
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<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();
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 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);
// Composite indexes for common query patterns
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Category });
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.AccountId, x.Date });
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.MerchantId, x.Date });
modelBuilder.Entity<Transaction>().HasIndex(x => new { x.CardId, x.Date });
// Receipt duplicate detection and lookup
modelBuilder.Entity<Receipt>().HasIndex(x => x.FileHashSha256);
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
// ---------- BUDGET ----------
modelBuilder.Entity<Budget>(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();
});
}
}
}