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:
@@ -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 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);
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public enum AccountType
|
||||
{
|
||||
Checking,
|
||||
Savings,
|
||||
Other
|
||||
}
|
||||
|
||||
public class Account
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Institution { get; set; } = string.Empty; // e.g., "Chase", "Wells Fargo"
|
||||
|
||||
[Required]
|
||||
[MaxLength(4)]
|
||||
public string Last4 { get; set; } = string.Empty; // Last 4 digits of account number
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Owner { get; set; } = string.Empty; // Account holder name
|
||||
|
||||
public AccountType AccountType { get; set; } = AccountType.Checking;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? Nickname { get; set; } // Optional friendly name like "Emergency Fund"
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<Card> Cards { get; set; } = new List<Card>(); // Cards linked to this account
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<Transfer> SourceTransfers { get; set; } = new List<Transfer>();
|
||||
public ICollection<Transfer> DestinationTransfers { get; set; } = new List<Transfer>();
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||
? $"{Institution} {Last4} ({AccountType})"
|
||||
: $"{Nickname} ({Institution} {Last4})";
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace MoneyMap.Models.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Complete financial audit response for AI analysis.
|
||||
/// </summary>
|
||||
public class FinancialAuditResponse
|
||||
{
|
||||
public DateTime GeneratedAt { get; set; }
|
||||
public DateTime PeriodStart { get; set; }
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
|
||||
public AuditSummary Summary { get; set; } = new();
|
||||
public List<BudgetStatusDto> Budgets { get; set; } = new();
|
||||
public List<CategorySpendingDto> SpendingByCategory { get; set; } = new();
|
||||
public List<MerchantSpendingDto> TopMerchants { get; set; } = new();
|
||||
public List<MonthlyTrendDto> MonthlyTrends { get; set; } = new();
|
||||
public List<AccountSummaryDto> Accounts { get; set; } = new();
|
||||
public List<AuditFlagDto> Flags { get; set; } = new();
|
||||
public List<TransactionDto>? Transactions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-level financial statistics for the audit period.
|
||||
/// </summary>
|
||||
public class AuditSummary
|
||||
{
|
||||
public int TotalTransactions { get; set; }
|
||||
public decimal TotalIncome { get; set; }
|
||||
public decimal TotalExpenses { get; set; }
|
||||
public decimal NetCashFlow { get; set; }
|
||||
public decimal AverageDailySpend { get; set; }
|
||||
public int DaysInPeriod { get; set; }
|
||||
public int UncategorizedTransactions { get; set; }
|
||||
public decimal UncategorizedAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Budget status with period information.
|
||||
/// </summary>
|
||||
public class BudgetStatusDto
|
||||
{
|
||||
public int BudgetId { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
public string Period { get; set; } = "";
|
||||
public decimal Limit { get; set; }
|
||||
public decimal Spent { get; set; }
|
||||
public decimal Remaining { get; set; }
|
||||
public decimal PercentUsed { get; set; }
|
||||
public bool IsOverBudget { get; set; }
|
||||
public string PeriodRange { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending breakdown by category with optional budget correlation.
|
||||
/// </summary>
|
||||
public class CategorySpendingDto
|
||||
{
|
||||
public string Category { get; set; } = "";
|
||||
public decimal TotalSpent { get; set; }
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal PercentOfTotal { get; set; }
|
||||
public decimal AverageTransaction { get; set; }
|
||||
public decimal? BudgetLimit { get; set; }
|
||||
public decimal? BudgetRemaining { get; set; }
|
||||
public bool? IsOverBudget { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending patterns by merchant.
|
||||
/// </summary>
|
||||
public class MerchantSpendingDto
|
||||
{
|
||||
public string MerchantName { get; set; } = "";
|
||||
public string? Category { get; set; }
|
||||
public decimal TotalSpent { get; set; }
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal AverageTransaction { get; set; }
|
||||
public DateTime FirstTransaction { get; set; }
|
||||
public DateTime LastTransaction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly income/expense/net trends.
|
||||
/// </summary>
|
||||
public class MonthlyTrendDto
|
||||
{
|
||||
public string Month { get; set; } = "";
|
||||
public int Year { get; set; }
|
||||
public decimal Income { get; set; }
|
||||
public decimal Expenses { get; set; }
|
||||
public decimal NetCashFlow { get; set; }
|
||||
public int TransactionCount { get; set; }
|
||||
public Dictionary<string, decimal> TopCategories { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-account transaction summary.
|
||||
/// </summary>
|
||||
public class AccountSummaryDto
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountName { get; set; } = "";
|
||||
public string Institution { get; set; } = "";
|
||||
public string AccountType { get; set; } = "";
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal TotalDebits { get; set; }
|
||||
public decimal TotalCredits { get; set; }
|
||||
public decimal NetFlow { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI-friendly flag highlighting potential issues or observations.
|
||||
/// </summary>
|
||||
public class AuditFlagDto
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Severity { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
public object? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified transaction for export.
|
||||
/// </summary>
|
||||
public class TransactionDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Memo { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? MerchantName { get; set; }
|
||||
public string AccountName { get; set; } = "";
|
||||
public string? CardLabel { get; set; }
|
||||
public bool IsTransfer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public enum BudgetPeriod
|
||||
{
|
||||
Weekly = 0,
|
||||
Monthly = 1,
|
||||
Yearly = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a spending budget for a category or total spending.
|
||||
/// When Category is null, this is a "Total" budget that tracks all spending.
|
||||
/// </summary>
|
||||
public class Budget
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category this budget applies to.
|
||||
/// Null means this is a total spending budget (all categories combined).
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The budget limit amount (positive value).
|
||||
/// </summary>
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The time period for this budget (Weekly, Monthly, Yearly).
|
||||
/// </summary>
|
||||
public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly;
|
||||
|
||||
/// <summary>
|
||||
/// When the budget becomes effective. Used to calculate period boundaries.
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this budget is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional notes about this budget.
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Computed properties
|
||||
[NotMapped]
|
||||
public bool IsTotalBudget => Category == null;
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayName => Category ?? "Total Spending";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public class Card
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC, Discover
|
||||
|
||||
[Required]
|
||||
[MaxLength(4)]
|
||||
public string Last4 { get; set; } = string.Empty; // "1234"
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Owner { get; set; } = string.Empty;
|
||||
|
||||
// Link to the account this card draws from/pays to
|
||||
[ForeignKey(nameof(Account))]
|
||||
public int? AccountId { get; set; }
|
||||
public Account? Account { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? Nickname { get; set; } // Optional friendly name
|
||||
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||
? $"{Issuer} {Last4}"
|
||||
: $"{Nickname} ({Issuer} {Last4})";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace MoneyMap.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a mapping rule that associates transaction name patterns with categories.
|
||||
/// Used for automatic categorization of transactions during import.
|
||||
/// </summary>
|
||||
public class CategoryMapping
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The category to assign when a transaction matches the pattern.
|
||||
/// </summary>
|
||||
public required string Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The pattern to match against transaction names (case-insensitive contains).
|
||||
/// </summary>
|
||||
public required string Pattern { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority mappings are checked first. Default is 0.
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Optional merchant to auto-assign when this pattern matches.
|
||||
/// </summary>
|
||||
public int? MerchantId { get; set; }
|
||||
public Merchant? Merchant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AI confidence score when this rule was created by AI (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public decimal? Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created this rule: "User" or "AI".
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this rule was created.
|
||||
/// </summary>
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace MoneyMap.Models.Dashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// Statistics displayed on the dashboard.
|
||||
/// </summary>
|
||||
public record DashboardStats(
|
||||
int TotalTransactions = 0,
|
||||
int Credits = 0,
|
||||
int Debits = 0,
|
||||
int Uncategorized = 0,
|
||||
int Receipts = 0,
|
||||
int Cards = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Row representing spending in a category.
|
||||
/// </summary>
|
||||
public class TopCategoryRow
|
||||
{
|
||||
public string Category { get; set; } = "";
|
||||
public decimal TotalSpend { get; set; }
|
||||
public int Count { get; set; }
|
||||
public decimal PercentageOfTotal { get; set; }
|
||||
public decimal AveragePerTransaction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row representing a recent transaction.
|
||||
/// </summary>
|
||||
public class RecentTransactionRow
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Memo { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
public string CardLabel { get; set; } = "";
|
||||
public int ReceiptCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spending trends over time.
|
||||
/// </summary>
|
||||
public class SpendTrends
|
||||
{
|
||||
public List<string> Labels { get; set; } = new();
|
||||
public List<decimal> DebitsAbs { get; set; } = new();
|
||||
public List<decimal> Credits { get; set; } = new();
|
||||
public List<decimal> Net { get; set; } = new();
|
||||
public List<decimal> RunningBalance { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete dashboard data package.
|
||||
/// </summary>
|
||||
public class DashboardData
|
||||
{
|
||||
public required DashboardStats Stats { get; init; }
|
||||
public required List<TopCategoryRow> TopCategories { get; init; }
|
||||
public required List<RecentTransactionRow> RecentTransactions { get; init; }
|
||||
public required SpendTrends Trends { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Context for transaction import operations, containing payment selection mode and available options.
|
||||
/// </summary>
|
||||
public class ImportContext
|
||||
{
|
||||
public required PaymentSelectMode PaymentMode { get; init; }
|
||||
public int? SelectedCardId { get; init; }
|
||||
public int? SelectedAccountId { get; init; }
|
||||
public required List<Card> AvailableCards { get; init; }
|
||||
public required List<Account> AvailableAccounts { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how to determine the payment method for imported transactions.
|
||||
/// </summary>
|
||||
public enum PaymentSelectMode
|
||||
{
|
||||
/// <summary>Auto-detect from memo or filename.</summary>
|
||||
Auto,
|
||||
/// <summary>Use a specific card for all transactions.</summary>
|
||||
Card,
|
||||
/// <summary>Use a specific account for all transactions.</summary>
|
||||
Account
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an import operation, showing counts of processed transactions.
|
||||
/// </summary>
|
||||
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for import operation result with success/failure state.
|
||||
/// </summary>
|
||||
public class ImportOperationResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public ImportResult? Data { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static ImportOperationResult Success(ImportResult data) =>
|
||||
new() { IsSuccess = true, Data = data };
|
||||
|
||||
public static ImportOperationResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for preview operation result with success/failure state.
|
||||
/// </summary>
|
||||
public class PreviewOperationResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public List<TransactionPreview>? Data { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static PreviewOperationResult Success(List<TransactionPreview> data) =>
|
||||
new() { IsSuccess = true, Data = data };
|
||||
|
||||
public static PreviewOperationResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview of a transaction before import, with duplicate detection info.
|
||||
/// </summary>
|
||||
public class TransactionPreview
|
||||
{
|
||||
public required Transaction Transaction { get; init; }
|
||||
public bool IsDuplicate { get; init; }
|
||||
public required string PaymentMethodLabel { get; init; }
|
||||
public string? SuggestedCategory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User's selection for payment method during import confirmation.
|
||||
/// </summary>
|
||||
public class PaymentSelection
|
||||
{
|
||||
public int? AccountId { get; set; }
|
||||
public int? CardId { get; set; }
|
||||
public string? Category { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key for detecting duplicate transactions.
|
||||
/// </summary>
|
||||
public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int AccountId, int? CardId)
|
||||
{
|
||||
public TransactionKey(Transaction txn)
|
||||
: this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace MoneyMap.Models.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of resolving a payment method (card or account) for a transaction.
|
||||
/// </summary>
|
||||
public class PaymentResolutionResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public int? CardId { get; init; }
|
||||
public int? AccountId { get; init; }
|
||||
public string? Last4 { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result when a card is used.
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) =>
|
||||
new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result when a direct account transaction (no card).
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult SuccessAccount(int accountId, string last4) =>
|
||||
new() { IsSuccess = true, AccountId = accountId, Last4 = last4 };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result with error message.
|
||||
/// </summary>
|
||||
public static PaymentResolutionResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MoneyMap.Models.Import;
|
||||
|
||||
public class TransactionCsvRow
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Transaction { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Memo { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using CsvHelper.Configuration;
|
||||
|
||||
namespace MoneyMap.Models.Import;
|
||||
|
||||
public sealed class TransactionCsvRowMap : ClassMap<TransactionCsvRow>
|
||||
{
|
||||
public TransactionCsvRowMap(bool hasCategory)
|
||||
{
|
||||
Map(m => m.Date).Name("Date");
|
||||
Map(m => m.Transaction).Name("Transaction");
|
||||
Map(m => m.Name).Name("Name");
|
||||
Map(m => m.Memo).Name("Memo");
|
||||
Map(m => m.Amount).Name("Amount");
|
||||
|
||||
if (hasCategory)
|
||||
{
|
||||
Map(m => m.Category).Name("Category");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (hasCategory)
|
||||
{
|
||||
Map(m => m.Category).Name("Category");
|
||||
}
|
||||
else
|
||||
{
|
||||
Map(m => m.Category).Constant(string.Empty).Optional();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public class Merchant
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public enum ReceiptParseStatus
|
||||
{
|
||||
NotRequested = 0,
|
||||
Queued = 1,
|
||||
Parsing = 2,
|
||||
Completed = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
|
||||
public class Receipt
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
// Link to transaction (nullable to support unmapped receipts)
|
||||
public long? TransactionId { get; set; }
|
||||
public Transaction? Transaction { get; set; }
|
||||
|
||||
// File metadata
|
||||
[MaxLength(260)]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(100)]
|
||||
public string ContentType { get; set; } = "application/octet-stream";
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string StoragePath { get; set; } = string.Empty; // \\barge.lan\receipts\...\ or blob key
|
||||
|
||||
public long FileSizeBytes { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string FileHashSha256 { get; set; } = string.Empty; // for dedupe
|
||||
|
||||
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Parsed header fields (optional, set by parser job)
|
||||
[MaxLength(200)]
|
||||
public string? Merchant { get; set; }
|
||||
|
||||
public DateTime? ReceiptDate { get; set; }
|
||||
|
||||
public DateTime? DueDate { get; set; } // For bills - payment due date
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal? Subtotal { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal? Tax { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal? Total { get; set; }
|
||||
|
||||
[MaxLength(8)]
|
||||
public string? Currency { get; set; }
|
||||
|
||||
// User notes provided to AI parser
|
||||
[MaxLength(2000)]
|
||||
public string? ParsingNotes { get; set; }
|
||||
|
||||
// Parse queue status
|
||||
public ReceiptParseStatus ParseStatus { get; set; } = ReceiptParseStatus.NotRequested;
|
||||
|
||||
// One receipt -> many parse attempts + many line items
|
||||
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
||||
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
[Index(nameof(ReceiptId), nameof(LineNumber))]
|
||||
public class ReceiptLineItem
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
public long ReceiptId { get; set; }
|
||||
public Receipt Receipt { get; set; } = null!;
|
||||
|
||||
public int LineNumber { get; set; }
|
||||
|
||||
[MaxLength(300)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
// ReceiptLineItem
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (ea, lb, gal, etc.)
|
||||
/// </summary>
|
||||
[MaxLength(16)]
|
||||
public string? Unit { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal? LineTotal { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? Sku { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
public bool Voided { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
[Index(nameof(ReceiptId), nameof(StartedAtUtc))]
|
||||
public class ReceiptParseLog
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
public long ReceiptId { get; set; }
|
||||
public Receipt Receipt { get; set; } = null!;
|
||||
|
||||
// Provider metadata as strings for flexibility
|
||||
[MaxLength(50)]
|
||||
public string Provider { get; set; } = string.Empty; // e.g., "OpenAI", "Azure", "Google", "Tesseract"
|
||||
|
||||
[MaxLength(100)]
|
||||
public string Model { get; set; } = string.Empty; // e.g., "gpt-4o-mini"
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? ProviderJobId { get; set; }
|
||||
|
||||
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
public bool Success { get; set; }
|
||||
|
||||
// ReceiptParseLog
|
||||
|
||||
[Column(TypeName = "decimal(5,4)")]
|
||||
public decimal? Confidence { get; set; } // 0.0000–0.9999 is plenty
|
||||
|
||||
// Store full provider JSON payload for re-parsing/debug (keep out of hot paths)
|
||||
public string RawProviderPayloadJson { get; set; } = "{}";
|
||||
|
||||
// Optional extracted text path if you persist a .txt alongside the image/PDF
|
||||
[MaxLength(1024)]
|
||||
public string? ExtractedTextPath { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(AccountId), nameof(CardId), IsUnique = true)]
|
||||
public class Transaction
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
[MaxLength(20)]
|
||||
public string TransactionType { get; set; } = string.Empty; // "DEBIT"/"CREDIT" if present
|
||||
|
||||
[MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string Memo { get; set; } = string.Empty;
|
||||
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Amount { get; set; } // negative = debit, positive = credit
|
||||
|
||||
[MaxLength(100)]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
// Merchant relationship
|
||||
[ForeignKey(nameof(Merchant))]
|
||||
public int? MerchantId { get; set; }
|
||||
public Merchant? Merchant { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
// Primary container: Every transaction belongs to an Account (the source of CSV)
|
||||
[Required]
|
||||
[ForeignKey(nameof(Account))]
|
||||
public int AccountId { get; set; }
|
||||
public Account Account { get; set; } = null!;
|
||||
|
||||
// Optional: Card used for this transaction (if it was a card payment)
|
||||
[ForeignKey(nameof(Card))]
|
||||
public int? CardId { get; set; }
|
||||
public Card? Card { get; set; }
|
||||
|
||||
// Optional: For transfers between accounts, this links to the destination account
|
||||
// This transaction is the "source" side of the transfer (debit)
|
||||
// The matching transaction in the destination account has this AccountId as its TransferToAccountId
|
||||
[ForeignKey(nameof(TransferToAccount))]
|
||||
public int? TransferToAccountId { get; set; }
|
||||
public Account? TransferToAccount { get; set; }
|
||||
|
||||
[MaxLength(4)]
|
||||
public string? Last4 { get; set; } // parsed from Memo if present
|
||||
|
||||
public ICollection<Receipt> Receipts { get; set; } = new List<Receipt>();
|
||||
|
||||
[NotMapped] public bool IsCredit => Amount > 0;
|
||||
[NotMapped] public bool IsDebit => Amount < 0;
|
||||
[NotMapped] public bool IsTransfer => TransferToAccountId.HasValue;
|
||||
|
||||
[NotMapped]
|
||||
public string PaymentMethodLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
// Handle transfers
|
||||
if (IsTransfer)
|
||||
{
|
||||
var toLabel = TransferToAccount?.DisplayLabel ?? "Unknown";
|
||||
return $"Transfer → {toLabel}";
|
||||
}
|
||||
|
||||
// If card was used, show just the card (since account is implied)
|
||||
if (Card != null)
|
||||
return Card.DisplayLabel;
|
||||
|
||||
// Direct account transaction (no card)
|
||||
return Account?.DisplayLabel ?? $"···· {Last4}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public class Transfer
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column(TypeName = "decimal(18,2)")]
|
||||
public decimal Amount { get; set; } // Always positive
|
||||
|
||||
[MaxLength(500)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
// Source account (where money comes from) - nullable for "Unknown"
|
||||
[ForeignKey(nameof(SourceAccount))]
|
||||
public int? SourceAccountId { get; set; }
|
||||
public Account? SourceAccount { get; set; }
|
||||
|
||||
// Destination account (where money goes to) - nullable for "Unknown"
|
||||
[ForeignKey(nameof(DestinationAccount))]
|
||||
public int? DestinationAccountId { get; set; }
|
||||
public Account? DestinationAccount { get; set; }
|
||||
|
||||
// Optional link to original transaction if imported from CSV
|
||||
[ForeignKey(nameof(OriginalTransaction))]
|
||||
public long? OriginalTransactionId { get; set; }
|
||||
public Transaction? OriginalTransaction { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[NotMapped]
|
||||
public string SourceLabel => SourceAccount != null
|
||||
? $"{SourceAccount.Institution} {SourceAccount.Last4}"
|
||||
: "Unknown";
|
||||
|
||||
[NotMapped]
|
||||
public string DestinationLabel => DestinationAccount != null
|
||||
? $"{DestinationAccount.Institution} {DestinationAccount.Last4}"
|
||||
: "Unknown";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.11" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user