Major refactor: Split Cards and Accounts into separate tables
Schema Changes: - Add Account model (Institution, AccountType enum, Last4, Owner, Nickname) - Add Transfer model for tracking money movement between accounts - Update Transaction to support both CardId and AccountId (nullable FKs) - Rename Transaction.CardLast4 → Last4 (works for both cards and accounts) - Add PaymentMethodLabel computed property to Transaction - Create EF Core migration: SplitCardsAndAccounts Data Model Improvements: - Accounts: Checking, Savings, Other types - Transfers: Source/Destination accounts, optional link to original transaction - Transactions can now link to either a Card OR an Account - Transfer categories excluded from spending reports via TransactionFilters UI Pages: - Add Accounts.cshtml - List all bank accounts with transaction counts - Add EditAccount.cshtml - Create/edit bank accounts - Add Accounts link to navigation - Update all references from CardLast4 to Last4 Service Layer Updates: - Update CardResolutionResult to use nullable CardId and renamed Last4 - Update TransactionKey record to include AccountId - Update IsDuplicate check to include both CardId and AccountId - Update all PaymentMethodLabel usage across pages This architecture allows proper separation of credit cards from bank accounts and enables tracking of transfers between accounts without double-counting in spending reports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)"
|
"Bash(git commit:*)",
|
||||||
|
"Bash(dotnet ef migrations add:*)",
|
||||||
|
"Bash(dotnet build)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ namespace MoneyMap.Data
|
|||||||
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
|
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<Card> Cards => Set<Card>();
|
public DbSet<Card> Cards => Set<Card>();
|
||||||
|
public DbSet<Account> Accounts => Set<Account>();
|
||||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||||
|
public DbSet<Transfer> Transfers => Set<Transfer>();
|
||||||
public DbSet<Receipt> Receipts => Set<Receipt>();
|
public DbSet<Receipt> Receipts => Set<Receipt>();
|
||||||
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
|
||||||
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
||||||
@@ -29,6 +31,16 @@ namespace MoneyMap.Data
|
|||||||
e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner });
|
e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- 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 ----------
|
// ---------- TRANSACTION ----------
|
||||||
modelBuilder.Entity<Transaction>(e =>
|
modelBuilder.Entity<Transaction>(e =>
|
||||||
{
|
{
|
||||||
@@ -37,13 +49,53 @@ namespace MoneyMap.Data
|
|||||||
e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty);
|
e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty);
|
||||||
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
|
||||||
e.Property(x => x.Category).HasMaxLength(100);
|
e.Property(x => x.Category).HasMaxLength(100);
|
||||||
e.Property(x => x.CardLast4).HasMaxLength(4);
|
e.Property(x => x.Last4).HasMaxLength(4);
|
||||||
|
|
||||||
// Card (required). If a card is deleted, block delete when txns exist (no cascades).
|
// Card (optional). If a card is deleted, block delete when txns exist.
|
||||||
e.HasOne(x => x.Card)
|
e.HasOne(x => x.Card)
|
||||||
.WithMany(c => c.Transactions)
|
.WithMany(c => c.Transactions)
|
||||||
.HasForeignKey(x => x.CardId)
|
.HasForeignKey(x => x.CardId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 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 ----------
|
// ---------- RECEIPT ----------
|
||||||
|
|||||||
507
MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs
generated
Normal file
507
MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs
generated
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MoneyMap.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(MoneyMapContext))]
|
||||||
|
[Migration("20251010003334_SplitCardsAndAccounts")]
|
||||||
|
partial class SplitCardsAndAccounts
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AccountType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Institution")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Last4")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
|
b.Property<string>("Nickname")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Institution", "Last4", "Owner");
|
||||||
|
|
||||||
|
b.ToTable("Accounts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Issuer")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Last4")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Issuer", "Last4", "Owner");
|
||||||
|
|
||||||
|
b.ToTable("Cards");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)")
|
||||||
|
.HasDefaultValue("application/octet-stream");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("FileHashSha256")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(260)
|
||||||
|
.HasColumnType("nvarchar(260)");
|
||||||
|
|
||||||
|
b.Property<long>("FileSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Merchant")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReceiptDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("StoragePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Subtotal")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Tax")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Total")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<long>("TransactionId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UploadedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TransactionId", "FileHashSha256")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Receipts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.Property<int>("LineNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LineTotal")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<long>("ReceiptId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Sku")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("UnitPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ReceiptId", "LineNumber");
|
||||||
|
|
||||||
|
b.ToTable("ReceiptLineItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Confidence")
|
||||||
|
.HasColumnType("decimal(5,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Error")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ExtractedTextPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderJobId")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("RawProviderPayloadJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<long>("ReceiptId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("Success")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("ReceiptParseLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("CardId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Last4")
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
|
b.Property<string>("Memo")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId");
|
||||||
|
|
||||||
|
b.HasIndex("Amount");
|
||||||
|
|
||||||
|
b.HasIndex("CardId");
|
||||||
|
|
||||||
|
b.HasIndex("Category");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId", "AccountId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("[CardId] IS NOT NULL AND [AccountId] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int?>("DestinationAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<long?>("OriginalTransactionId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.HasIndex("DestinationAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalTransactionId");
|
||||||
|
|
||||||
|
b.HasIndex("SourceAccountId");
|
||||||
|
|
||||||
|
b.ToTable("Transfers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Pattern")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("CategoryMappings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||||
|
.WithMany("Receipts")
|
||||||
|
.HasForeignKey("TransactionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Transaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
|
.WithMany("LineItems")
|
||||||
|
.HasForeignKey("ReceiptId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Receipt");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||||
|
.WithMany("ParseLogs")
|
||||||
|
.HasForeignKey("ReceiptId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Receipt");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||||
|
.WithMany("Transactions")
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||||
|
.WithMany("Transactions")
|
||||||
|
.HasForeignKey("CardId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
|
||||||
|
b.Navigation("Card");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
|
||||||
|
.WithMany("DestinationTransfers")
|
||||||
|
.HasForeignKey("DestinationAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OriginalTransactionId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
|
||||||
|
.WithMany("SourceTransfers")
|
||||||
|
.HasForeignKey("SourceAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("DestinationAccount");
|
||||||
|
|
||||||
|
b.Navigation("OriginalTransaction");
|
||||||
|
|
||||||
|
b.Navigation("SourceAccount");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("DestinationTransfers");
|
||||||
|
|
||||||
|
b.Navigation("SourceTransfers");
|
||||||
|
|
||||||
|
b.Navigation("Transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LineItems");
|
||||||
|
|
||||||
|
b.Navigation("ParseLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Receipts");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs
Normal file
185
MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MoneyMap.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SplitCardsAndAccounts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "CardLast4",
|
||||||
|
table: "Transactions",
|
||||||
|
newName: "Last4");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "CardId",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AccountId",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Accounts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Institution = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
|
||||||
|
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
AccountType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Nickname = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Accounts", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Transfers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
SourceAccountId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
DestinationAccountId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
OriginalTransactionId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Transfers", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Transfers_Accounts_DestinationAccountId",
|
||||||
|
column: x => x.DestinationAccountId,
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Transfers_Accounts_SourceAccountId",
|
||||||
|
column: x => x.SourceAccountId,
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Transfers_Transactions_OriginalTransactionId",
|
||||||
|
column: x => x.OriginalTransactionId,
|
||||||
|
principalTable: "Transactions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_AccountId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "AccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
||||||
|
table: "Transactions",
|
||||||
|
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Accounts_Institution_Last4_Owner",
|
||||||
|
table: "Accounts",
|
||||||
|
columns: new[] { "Institution", "Last4", "Owner" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transfers_Date",
|
||||||
|
table: "Transfers",
|
||||||
|
column: "Date");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transfers_DestinationAccountId",
|
||||||
|
table: "Transfers",
|
||||||
|
column: "DestinationAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transfers_OriginalTransactionId",
|
||||||
|
table: "Transfers",
|
||||||
|
column: "OriginalTransactionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transfers_SourceAccountId",
|
||||||
|
table: "Transfers",
|
||||||
|
column: "SourceAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Transactions_Accounts_AccountId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "AccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Transactions_Accounts_AccountId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Transfers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Accounts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Transactions_AccountId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AccountId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "Last4",
|
||||||
|
table: "Transactions",
|
||||||
|
newName: "CardLast4");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "CardId",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
|
||||||
|
table: "Transactions",
|
||||||
|
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,43 @@ namespace MoneyMap.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AccountType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Institution")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Last4")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
|
b.Property<string>("Nickname")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Institution", "Last4", "Owner");
|
||||||
|
|
||||||
|
b.ToTable("Accounts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -229,16 +266,15 @@ namespace MoneyMap.Migrations
|
|||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("CardId")
|
b.Property<int?>("CardId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("CardLast4")
|
|
||||||
.HasMaxLength(4)
|
|
||||||
.HasColumnType("nvarchar(4)");
|
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -247,6 +283,10 @@ namespace MoneyMap.Migrations
|
|||||||
b.Property<DateTime>("Date")
|
b.Property<DateTime>("Date")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Last4")
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("nvarchar(4)");
|
||||||
|
|
||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -270,6 +310,8 @@ namespace MoneyMap.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId");
|
||||||
|
|
||||||
b.HasIndex("Amount");
|
b.HasIndex("Amount");
|
||||||
|
|
||||||
b.HasIndex("CardId");
|
b.HasIndex("CardId");
|
||||||
@@ -278,12 +320,61 @@ namespace MoneyMap.Migrations
|
|||||||
|
|
||||||
b.HasIndex("Date");
|
b.HasIndex("Date");
|
||||||
|
|
||||||
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
|
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId", "AccountId")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("[CardId] IS NOT NULL AND [AccountId] IS NOT NULL");
|
||||||
|
|
||||||
b.ToTable("Transactions");
|
b.ToTable("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Date")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int?>("DestinationAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<long?>("OriginalTransactionId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.HasIndex("DestinationAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalTransactionId");
|
||||||
|
|
||||||
|
b.HasIndex("SourceAccountId");
|
||||||
|
|
||||||
|
b.ToTable("Transfers");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -343,15 +434,54 @@ namespace MoneyMap.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||||
|
.WithMany("Transactions")
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||||
.WithMany("Transactions")
|
.WithMany("Transactions")
|
||||||
.HasForeignKey("CardId")
|
.HasForeignKey("CardId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
.IsRequired();
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
|
||||||
b.Navigation("Card");
|
b.Navigation("Card");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
|
||||||
|
.WithMany("DestinationTransfers")
|
||||||
|
.HasForeignKey("DestinationAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OriginalTransactionId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
|
||||||
|
.WithMany("SourceTransfers")
|
||||||
|
.HasForeignKey("SourceAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("DestinationAccount");
|
||||||
|
|
||||||
|
b.Navigation("OriginalTransaction");
|
||||||
|
|
||||||
|
b.Navigation("SourceAccount");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("DestinationTransfers");
|
||||||
|
|
||||||
|
b.Navigation("SourceTransfers");
|
||||||
|
|
||||||
|
b.Navigation("Transactions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
|
|||||||
38
MoneyMap/Models/Account.cs
Normal file
38
MoneyMap/Models/Account.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
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<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>();
|
||||||
|
}
|
||||||
@@ -20,12 +20,13 @@ public class ReceiptLineItem
|
|||||||
|
|
||||||
// ReceiptLineItem
|
// ReceiptLineItem
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
public decimal? Quantity { get; set; } // was missing
|
public decimal? Quantity { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit of Measure (ea, lb, gal, etc.)
|
||||||
|
/// </summary>
|
||||||
[MaxLength(16)]
|
[MaxLength(16)]
|
||||||
public string? Unit { get; set; } // ea, lb, gal, etc.
|
public string? Unit { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
[Column(TypeName = "decimal(18,4)")]
|
||||||
public decimal? UnitPrice { get; set; }
|
public decimal? UnitPrice { get; set; }
|
||||||
@@ -37,5 +38,5 @@ public class ReceiptLineItem
|
|||||||
public string? Sku { get; set; }
|
public string? Sku { get; set; }
|
||||||
|
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
public string? Category { get; set; } // optional per-line categorization
|
public string? Category { get; set; }
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ using System.Transactions;
|
|||||||
|
|
||||||
namespace MoneyMap.Models;
|
namespace MoneyMap.Models;
|
||||||
|
|
||||||
[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(CardId), IsUnique = true)]
|
[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(CardId), nameof(AccountId), IsUnique = true)]
|
||||||
public class Transaction
|
public class Transaction
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
@@ -31,16 +31,38 @@ public class Transaction
|
|||||||
|
|
||||||
public string Notes { get; set; } = string.Empty;
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Card link + convenience
|
// Payment method - EITHER Card OR Account (not both)
|
||||||
|
// For credit/debit card transactions
|
||||||
[ForeignKey(nameof(Card))]
|
[ForeignKey(nameof(Card))]
|
||||||
public int CardId { get; set; }
|
public int? CardId { get; set; }
|
||||||
public Card? Card { get; set; }
|
public Card? Card { get; set; }
|
||||||
|
|
||||||
|
// For bank account transactions (checking, savings)
|
||||||
|
[ForeignKey(nameof(Account))]
|
||||||
|
public int? AccountId { get; set; }
|
||||||
|
public Account? Account { get; set; }
|
||||||
|
|
||||||
[MaxLength(4)]
|
[MaxLength(4)]
|
||||||
public string? CardLast4 { get; set; } // parsed from Memo if present
|
public string? Last4 { get; set; } // parsed from Memo if present (replaces CardLast4)
|
||||||
|
|
||||||
public ICollection<Receipt> Receipts { get; set; } = new List<Receipt>();
|
public ICollection<Receipt> Receipts { get; set; } = new List<Receipt>();
|
||||||
|
|
||||||
[NotMapped] public bool IsCredit => Amount > 0;
|
[NotMapped] public bool IsCredit => Amount > 0;
|
||||||
[NotMapped] public bool IsDebit => Amount < 0;
|
[NotMapped] public bool IsDebit => Amount < 0;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public string PaymentMethodLabel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Card != null)
|
||||||
|
return $"{Card.Issuer} {Card.Last4}";
|
||||||
|
if (Account != null)
|
||||||
|
return $"{Account.Institution} {Account.Last4}";
|
||||||
|
if (!string.IsNullOrEmpty(Last4))
|
||||||
|
return $"···· {Last4}";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
MoneyMap/Models/Transfer.cs
Normal file
49
MoneyMap/Models/Transfer.cs
Normal 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";
|
||||||
|
}
|
||||||
72
MoneyMap/Pages/Accounts.cshtml
Normal file
72
MoneyMap/Pages/Accounts.cshtml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@page
|
||||||
|
@model MoneyMap.Pages.AccountsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Accounts";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>Bank Accounts</h2>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
@Model.SuccessMessage
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<a asp-page="/EditAccount" class="btn btn-primary">+ Add New Account</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Accounts.Any())
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Institution</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Last 4</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Nickname</th>
|
||||||
|
<th>Transactions</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var account in Model.Accounts)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@account.Institution</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">@account.AccountType</span>
|
||||||
|
</td>
|
||||||
|
<td><code>@account.Last4</code></td>
|
||||||
|
<td>@account.Owner</td>
|
||||||
|
<td>@(string.IsNullOrEmpty(account.Nickname) ? "-" : account.Nickname)</td>
|
||||||
|
<td>@account.TransactionCount</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a asp-page="/EditAccount" asp-route-id="@account.Id" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||||
|
@if (account.TransactionCount == 0)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="Delete" asp-route-id="@account.Id"
|
||||||
|
onsubmit="return confirm('Delete account @account.Institution @account.Last4?')"
|
||||||
|
class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No accounts added yet. Click "Add New Account" to create one.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
78
MoneyMap/Pages/Accounts.cshtml.cs
Normal file
78
MoneyMap/Pages/Accounts.cshtml.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
|
||||||
|
namespace MoneyMap.Pages
|
||||||
|
{
|
||||||
|
public class AccountsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public AccountsModel(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AccountRow> Accounts { get; set; } = new();
|
||||||
|
|
||||||
|
[TempData]
|
||||||
|
public string? SuccessMessage { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
var accounts = await _db.Accounts
|
||||||
|
.Include(a => a.Transactions)
|
||||||
|
.OrderBy(a => a.Owner)
|
||||||
|
.ThenBy(a => a.Institution)
|
||||||
|
.ThenBy(a => a.Last4)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Accounts = accounts.Select(a => new AccountRow
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
Institution = a.Institution,
|
||||||
|
AccountType = a.AccountType,
|
||||||
|
Last4 = a.Last4,
|
||||||
|
Owner = a.Owner,
|
||||||
|
Nickname = a.Nickname,
|
||||||
|
TransactionCount = a.Transactions.Count
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var account = await _db.Accounts
|
||||||
|
.Include(a => a.Transactions)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == id);
|
||||||
|
|
||||||
|
if (account == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (account.Transactions.Any())
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, "Cannot delete account with existing transactions.");
|
||||||
|
await OnGetAsync();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Accounts.Remove(account);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
SuccessMessage = $"Deleted account {account.Institution} {account.Last4}";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountRow
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Institution { get; set; } = "";
|
||||||
|
public AccountType AccountType { get; set; }
|
||||||
|
public string Last4 { get; set; } = "";
|
||||||
|
public string Owner { get; set; } = "";
|
||||||
|
public string? Nickname { get; set; }
|
||||||
|
public int TransactionCount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
MoneyMap/Pages/EditAccount.cshtml
Normal file
59
MoneyMap/Pages/EditAccount.cshtml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
@page "{id:int?}"
|
||||||
|
@using MoneyMap.Models
|
||||||
|
@model MoneyMap.Pages.EditAccountModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.IsNew ? "Add Account" : "Edit Account";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>@ViewData["Title"]</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" asp-for="Account.Id" />
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="Account.Institution" class="form-label">Institution</label>
|
||||||
|
<input asp-for="Account.Institution" class="form-control" placeholder="e.g., Chase, Wells Fargo" />
|
||||||
|
<span asp-validation-for="Account.Institution" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="Account.AccountType" class="form-label">Account Type</label>
|
||||||
|
<select asp-for="Account.AccountType" class="form-select">
|
||||||
|
<option value="@AccountType.Checking">Checking</option>
|
||||||
|
<option value="@AccountType.Savings">Savings</option>
|
||||||
|
<option value="@AccountType.Other">Other</option>
|
||||||
|
</select>
|
||||||
|
<span asp-validation-for="Account.AccountType" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="Account.Last4" class="form-label">Last 4 Digits</label>
|
||||||
|
<input asp-for="Account.Last4" class="form-control" maxlength="4" placeholder="1234" />
|
||||||
|
<span asp-validation-for="Account.Last4" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="Account.Owner" class="form-label">Owner</label>
|
||||||
|
<input asp-for="Account.Owner" class="form-control" placeholder="Account holder name" />
|
||||||
|
<span asp-validation-for="Account.Owner" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="Account.Nickname" class="form-label">Nickname (Optional)</label>
|
||||||
|
<input asp-for="Account.Nickname" class="form-control" placeholder="e.g., Emergency Fund, Main Checking" />
|
||||||
|
<span asp-validation-for="Account.Nickname" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a asp-page="/Accounts" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<partial name="_ValidationScriptsPartial" />
|
||||||
|
}
|
||||||
124
MoneyMap/Pages/EditAccount.cshtml.cs
Normal file
124
MoneyMap/Pages/EditAccount.cshtml.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
|
||||||
|
namespace MoneyMap.Pages
|
||||||
|
{
|
||||||
|
public class EditAccountModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public EditAccountModel(MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public AccountEditModel Account { get; set; } = new();
|
||||||
|
|
||||||
|
public bool IsNew => Account.Id == 0;
|
||||||
|
|
||||||
|
[TempData]
|
||||||
|
public string? SuccessMessage { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(int? id)
|
||||||
|
{
|
||||||
|
if (id == null)
|
||||||
|
{
|
||||||
|
// New account
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = await _db.Accounts.FindAsync(id.Value);
|
||||||
|
if (account == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
Account = new AccountEditModel
|
||||||
|
{
|
||||||
|
Id = account.Id,
|
||||||
|
Institution = account.Institution,
|
||||||
|
AccountType = account.AccountType,
|
||||||
|
Last4 = account.Last4,
|
||||||
|
Owner = account.Owner,
|
||||||
|
Nickname = account.Nickname
|
||||||
|
};
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Page();
|
||||||
|
|
||||||
|
if (Account.Id == 0)
|
||||||
|
{
|
||||||
|
// Create new
|
||||||
|
var account = new Account
|
||||||
|
{
|
||||||
|
Institution = Account.Institution.Trim(),
|
||||||
|
AccountType = Account.AccountType,
|
||||||
|
Last4 = Account.Last4.Trim(),
|
||||||
|
Owner = Account.Owner.Trim(),
|
||||||
|
Nickname = Account.Nickname?.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Accounts.Add(account);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
SuccessMessage = $"Created account {account.Institution} {account.Last4}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update existing
|
||||||
|
var account = await _db.Accounts.FindAsync(Account.Id);
|
||||||
|
if (account == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
account.Institution = Account.Institution.Trim();
|
||||||
|
account.AccountType = Account.AccountType;
|
||||||
|
account.Last4 = Account.Last4.Trim();
|
||||||
|
account.Owner = Account.Owner.Trim();
|
||||||
|
account.Nickname = Account.Nickname?.Trim();
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
SuccessMessage = $"Updated account {account.Institution} {account.Last4}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage("/Accounts");
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountEditModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
[Display(Name = "Institution")]
|
||||||
|
public string Institution { get; set; } = "";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Account Type")]
|
||||||
|
public AccountType AccountType { get; set; } = AccountType.Checking;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(4, MinimumLength = 4)]
|
||||||
|
[RegularExpression(@"^\d{4}$", ErrorMessage = "Must be exactly 4 digits")]
|
||||||
|
[Display(Name = "Last 4 Digits")]
|
||||||
|
public string Last4 { get; set; } = "";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
[Display(Name = "Owner")]
|
||||||
|
public string Owner { get; set; } = "";
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
[Display(Name = "Nickname")]
|
||||||
|
public string? Nickname { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,9 +62,7 @@ namespace MoneyMap.Pages
|
|||||||
Amount = transaction.Amount,
|
Amount = transaction.Amount,
|
||||||
Category = transaction.Category ?? "",
|
Category = transaction.Category ?? "",
|
||||||
Notes = transaction.Notes ?? "",
|
Notes = transaction.Notes ?? "",
|
||||||
CardLabel = transaction.Card != null
|
CardLabel = transaction.PaymentMethodLabel
|
||||||
? $"{transaction.Card.Owner} - {transaction.Card.Last4}"
|
|
||||||
: $"•••• {transaction.CardLast4}"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems
|
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ namespace MoneyMap.Pages
|
|||||||
Memo = t.Memo,
|
Memo = t.Memo,
|
||||||
Amount = t.Amount,
|
Amount = t.Amount,
|
||||||
Category = t.Category ?? "",
|
Category = t.Category ?? "",
|
||||||
CardLabel = FormatCardLabel(t.Card, t.CardLast4)
|
CardLabel = t.PaymentMethodLabel
|
||||||
})
|
})
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-page="/Cards">Cards</a>
|
<a class="nav-link text-dark" asp-page="/Cards">Cards</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-dark" asp-page="/Accounts">Accounts</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a>
|
<a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -110,9 +110,7 @@ namespace MoneyMap.Pages
|
|||||||
Amount = t.Amount,
|
Amount = t.Amount,
|
||||||
Category = t.Category ?? "",
|
Category = t.Category ?? "",
|
||||||
Notes = t.Notes ?? "",
|
Notes = t.Notes ?? "",
|
||||||
CardLabel = t.Card != null
|
CardLabel = t.PaymentMethodLabel,
|
||||||
? $"{t.Card.Issuer} {t.Card.Last4}"
|
|
||||||
: (string.IsNullOrEmpty(t.CardLast4) ? "" : $"•••• {t.CardLast4}"),
|
|
||||||
ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0
|
ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ namespace MoneyMap.Pages
|
|||||||
if (!cardResolution.IsSuccess)
|
if (!cardResolution.IsSuccess)
|
||||||
return ImportOperationResult.Failure(cardResolution.ErrorMessage!);
|
return ImportOperationResult.Failure(cardResolution.ErrorMessage!);
|
||||||
|
|
||||||
var transaction = MapToTransaction(row, cardResolution.CardId, cardResolution.CardLast4!);
|
var transaction = MapToTransaction(row, cardResolution.CardId, cardResolution.Last4!);
|
||||||
var key = new TransactionKey(transaction);
|
var key = new TransactionKey(transaction);
|
||||||
|
|
||||||
// Check both database AND current batch for duplicates
|
// Check both database AND current batch for duplicates
|
||||||
@@ -161,10 +161,11 @@ namespace MoneyMap.Pages
|
|||||||
t.Amount == txn.Amount &&
|
t.Amount == txn.Amount &&
|
||||||
t.Name == txn.Name &&
|
t.Name == txn.Name &&
|
||||||
t.Memo == txn.Memo &&
|
t.Memo == txn.Memo &&
|
||||||
t.CardId == txn.CardId);
|
t.CardId == txn.CardId &&
|
||||||
|
t.AccountId == txn.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Transaction MapToTransaction(TransactionCsvRow row, int cardId, string cardLast4)
|
private static Transaction MapToTransaction(TransactionCsvRow row, int? cardId, string last4)
|
||||||
{
|
{
|
||||||
return new Transaction
|
return new Transaction
|
||||||
{
|
{
|
||||||
@@ -174,7 +175,7 @@ namespace MoneyMap.Pages
|
|||||||
Memo = row.Memo?.Trim() ?? "",
|
Memo = row.Memo?.Trim() ?? "",
|
||||||
Amount = row.Amount,
|
Amount = row.Amount,
|
||||||
Category = (row.Category ?? "").Trim(),
|
Category = (row.Category ?? "").Trim(),
|
||||||
CardLast4 = cardLast4,
|
Last4 = last4,
|
||||||
CardId = cardId
|
CardId = cardId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -284,10 +285,10 @@ namespace MoneyMap.Pages
|
|||||||
|
|
||||||
// ===== Data Transfer Objects =====
|
// ===== Data Transfer Objects =====
|
||||||
|
|
||||||
public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int CardId)
|
public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int? CardId, int? AccountId)
|
||||||
{
|
{
|
||||||
public TransactionKey(Transaction txn)
|
public TransactionKey(Transaction txn)
|
||||||
: this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.CardId) { }
|
: this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.CardId, txn.AccountId) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImportContext
|
public class ImportContext
|
||||||
@@ -308,12 +309,12 @@ namespace MoneyMap.Pages
|
|||||||
public class CardResolutionResult
|
public class CardResolutionResult
|
||||||
{
|
{
|
||||||
public bool IsSuccess { get; init; }
|
public bool IsSuccess { get; init; }
|
||||||
public int CardId { get; init; }
|
public int? CardId { get; init; }
|
||||||
public string? CardLast4 { get; init; }
|
public string? Last4 { get; init; }
|
||||||
public string? ErrorMessage { get; init; }
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
public static CardResolutionResult Success(int cardId, string cardLast4) =>
|
public static CardResolutionResult Success(int? cardId, string last4) =>
|
||||||
new() { IsSuccess = true, CardId = cardId, CardLast4 = cardLast4 };
|
new() { IsSuccess = true, CardId = cardId, Last4 = last4 };
|
||||||
|
|
||||||
public static CardResolutionResult Failure(string error) =>
|
public static CardResolutionResult Failure(string error) =>
|
||||||
new() { IsSuccess = false, ErrorMessage = error };
|
new() { IsSuccess = false, ErrorMessage = error };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace MoneyMap.Services
|
|||||||
public static readonly string[] TransferCategories = new[]
|
public static readonly string[] TransferCategories = new[]
|
||||||
{
|
{
|
||||||
"Credit Card Payment",
|
"Credit Card Payment",
|
||||||
|
"Bank Transfer",
|
||||||
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user