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:
AJ
2025-10-09 20:52:54 -04:00
parent 227e9dd006
commit a44b3d41ac
19 changed files with 1359 additions and 39 deletions

View File

@@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Bash(dotnet ef migrations add:*)",
"Bash(dotnet build)"
],
"deny": [],
"ask": []

View File

@@ -11,7 +11,9 @@ namespace MoneyMap.Data
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>();
@@ -29,6 +31,16 @@ namespace MoneyMap.Data
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 ----------
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.Amount).HasColumnType("decimal(18,2)");
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)
.WithMany(c => c.Transactions)
.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 ----------

View 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
}
}
}

View 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);
}
}
}

View File

@@ -22,6 +22,43 @@ namespace MoneyMap.Migrations
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")
@@ -229,16 +266,15 @@ namespace MoneyMap.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CardId")
b.Property<int?>("CardId")
.HasColumnType("int");
b.Property<string>("CardLast4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
@@ -247,6 +283,10 @@ namespace MoneyMap.Migrations
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Last4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
@@ -270,6 +310,8 @@ namespace MoneyMap.Migrations
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("Amount");
b.HasIndex("CardId");
@@ -278,12 +320,61 @@ namespace MoneyMap.Migrations
b.HasIndex("Date");
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
.IsUnique();
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")
@@ -343,15 +434,54 @@ namespace MoneyMap.Migrations
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)
.IsRequired();
.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");

View 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>();
}

View File

@@ -20,12 +20,13 @@ public class ReceiptLineItem
// ReceiptLineItem
[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)]
public string? Unit { get; set; } // ea, lb, gal, etc.
public string? Unit { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal? UnitPrice { get; set; }
@@ -37,5 +38,5 @@ public class ReceiptLineItem
public string? Sku { get; set; }
[MaxLength(100)]
public string? Category { get; set; } // optional per-line categorization
public string? Category { get; set; }
}

View File

@@ -5,7 +5,7 @@ using System.Transactions;
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
{
[Key]
@@ -31,16 +31,38 @@ public class Transaction
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))]
public int CardId { get; set; }
public int? CardId { 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)]
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>();
[NotMapped] public bool IsCredit => 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";
}
}
}

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";
}

View 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>
}

View 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; }
}
}
}

View 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" />
}

View 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; }
}
}
}

View File

@@ -62,9 +62,7 @@ namespace MoneyMap.Pages
Amount = transaction.Amount,
Category = transaction.Category ?? "",
Notes = transaction.Notes ?? "",
CardLabel = transaction.Card != null
? $"{transaction.Card.Owner} - {transaction.Card.Last4}"
: $"•••• {transaction.CardLast4}"
CardLabel = transaction.PaymentMethodLabel
};
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems

View File

@@ -222,7 +222,7 @@ namespace MoneyMap.Pages
Memo = t.Memo,
Amount = t.Amount,
Category = t.Category ?? "",
CardLabel = FormatCardLabel(t.Card, t.CardLast4)
CardLabel = t.PaymentMethodLabel
})
.Take(count)
.AsNoTracking()

View File

@@ -28,6 +28,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Cards">Cards</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a>
</li>

View File

@@ -110,9 +110,7 @@ namespace MoneyMap.Pages
Amount = t.Amount,
Category = t.Category ?? "",
Notes = t.Notes ?? "",
CardLabel = t.Card != null
? $"{t.Card.Issuer} {t.Card.Last4}"
: (string.IsNullOrEmpty(t.CardLast4) ? "" : $"•••• {t.CardLast4}"),
CardLabel = t.PaymentMethodLabel,
ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0
}).ToList();

View File

@@ -127,7 +127,7 @@ namespace MoneyMap.Pages
if (!cardResolution.IsSuccess)
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);
// Check both database AND current batch for duplicates
@@ -161,10 +161,11 @@ namespace MoneyMap.Pages
t.Amount == txn.Amount &&
t.Name == txn.Name &&
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
{
@@ -174,7 +175,7 @@ namespace MoneyMap.Pages
Memo = row.Memo?.Trim() ?? "",
Amount = row.Amount,
Category = (row.Category ?? "").Trim(),
CardLast4 = cardLast4,
Last4 = last4,
CardId = cardId
};
}
@@ -284,10 +285,10 @@ namespace MoneyMap.Pages
// ===== 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)
: 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
@@ -308,12 +309,12 @@ namespace MoneyMap.Pages
public class CardResolutionResult
{
public bool IsSuccess { get; init; }
public int CardId { get; init; }
public string? CardLast4 { get; init; }
public int? CardId { get; init; }
public string? Last4 { get; init; }
public string? ErrorMessage { get; init; }
public static CardResolutionResult Success(int cardId, string cardLast4) =>
new() { IsSuccess = true, CardId = cardId, CardLast4 = cardLast4 };
public static CardResolutionResult Success(int? cardId, string last4) =>
new() { IsSuccess = true, CardId = cardId, Last4 = last4 };
public static CardResolutionResult Failure(string error) =>
new() { IsSuccess = false, ErrorMessage = error };

View File

@@ -15,6 +15,7 @@ namespace MoneyMap.Services
public static readonly string[] TransferCategories = new[]
{
"Credit Card Payment",
"Bank Transfer",
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
};