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": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"Bash(dotnet ef migrations add:*)",
|
||||
"Bash(dotnet build)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
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);
|
||||
|
||||
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");
|
||||
|
||||
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
|
||||
[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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user