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();
});
}
}
}
+45
View File
@@ -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; }
}
+62
View File
@@ -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";
}
+37
View File
@@ -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})";
}
+47
View File
@@ -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();
}
}
}
}
+16
View File
@@ -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>();
}
+73
View File
@@ -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>();
}
+44
View File
@@ -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; }
}
+43
View File
@@ -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.00000.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; }
}
+86
View File
@@ -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}";
}
}
}
+49
View File
@@ -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";
}
+16
View File
@@ -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>