feat: add recurring places and transaction pagination
Build MoneyMap image / build-and-push (push) Failing after 4s

This commit is contained in:
aj
2026-08-17 06:42:08 -04:00
parent 2356b7d5c9
commit 1cc0a355f2
175 changed files with 34411 additions and 34039 deletions
+276 -227
View File
@@ -1,227 +1,276 @@
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();
});
}
}
}
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>();
public DbSet<RecurringPlace> RecurringPlaces => Set<RecurringPlace>();
public DbSet<Category> Categories => Set<Category>();
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();
});
// ---------- RECURRING PLACE ----------
modelBuilder.Entity<RecurringPlace>(e =>
{
e.Property(x => x.Frequency).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(500);
// Relationships
e.HasOne(x => x.Merchant)
.WithMany(m => m.RecurringPlaces)
.HasForeignKey(x => x.MerchantId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
e.HasOne(x => x.Card)
.WithMany(c => c.RecurringPlaces)
.HasForeignKey(x => x.CardId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
e.HasOne(x => x.Category)
.WithMany(c => c.RecurringPlaces)
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
e.HasIndex(x => new { x.MerchantId, x.CardId, x.Frequency });
e.HasIndex(x => x.NextDueDate);
});
// ---------- CATEGORY ----------
modelBuilder.Entity<Category>(e =>
{
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.Property(x => x.Type).HasMaxLength(50);
// Parent-child relationship for hierarchical categories
e.HasOne(x => x.ParentCategory)
.WithMany(c => c.Children)
.HasForeignKey(x => x.ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
e.HasIndex(x => new { x.Name, x.Type });
e.HasIndex(x => x.ParentCategoryId);
});
}
}
}