The previous commit had the model changes but the database wasn't properly migrated. This commit adds the missing migrations to make TransactionId nullable in the Receipts table. Changes: - Updated MoneyMapContext to make Transaction relationship optional for Receipt (IsRequired(false)) - Created additional migrations to properly handle: - Making TransactionId column nullable - Updating the unique index to handle NULL values with WHERE clause - Applied all migrations to database successfully The Receipts page should now work correctly for uploading unmapped receipts without a TransactionId. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
203 lines
8.9 KiB
C#
203 lines
8.9 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 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 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);
|
||
}
|
||
}
|
||
}
|