diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2998dc6..8d5d189 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/MoneyMap/Data/MoneyMapContext.cs b/MoneyMap/Data/MoneyMapContext.cs index a3fde47..28fbaa6 100644 --- a/MoneyMap/Data/MoneyMapContext.cs +++ b/MoneyMap/Data/MoneyMapContext.cs @@ -11,7 +11,9 @@ namespace MoneyMap.Data public MoneyMapContext(DbContextOptions options) : base(options) { } public DbSet Cards => Set(); + public DbSet Accounts => Set(); public DbSet Transactions => Set(); + public DbSet Transfers => Set(); public DbSet Receipts => Set(); public DbSet ReceiptParseLogs => Set(); public DbSet ReceiptLineItems => Set(); @@ -29,6 +31,16 @@ namespace MoneyMap.Data e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner }); }); + // ---------- ACCOUNT ---------- + modelBuilder.Entity(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(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(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 ---------- diff --git a/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs new file mode 100644 index 0000000..2e9bd43 --- /dev/null +++ b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs @@ -0,0 +1,507 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs new file mode 100644 index 0000000..9f54eac --- /dev/null +++ b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs @@ -0,0 +1,185 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class SplitCardsAndAccounts : Migration + { + /// + 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( + name: "CardId", + table: "Transactions", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AddColumn( + name: "AccountId", + table: "Transactions", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Institution = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Last4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: false), + Owner = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + AccountType = table.Column(type: "int", nullable: false), + Nickname = table.Column(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(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Date = table.Column(type: "datetime2", nullable: false), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Notes = table.Column(type: "nvarchar(max)", nullable: false), + SourceAccountId = table.Column(type: "int", nullable: true), + DestinationAccountId = table.Column(type: "int", nullable: true), + OriginalTransactionId = table.Column(type: "bigint", nullable: true), + CreatedAt = table.Column(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); + } + + /// + 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( + 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); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index ac51c06..2be8c19 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -22,6 +22,43 @@ namespace MoneyMap.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("MoneyMap.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") @@ -229,16 +266,15 @@ namespace MoneyMap.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("AccountId") + .HasColumnType("int"); + b.Property("Amount") .HasColumnType("decimal(18,2)"); - b.Property("CardId") + b.Property("CardId") .HasColumnType("int"); - b.Property("CardLast4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - b.Property("Category") .IsRequired() .HasMaxLength(100) @@ -247,6 +283,10 @@ namespace MoneyMap.Migrations b.Property("Date") .HasColumnType("datetime2"); + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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("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"); diff --git a/MoneyMap/Models/Account.cs b/MoneyMap/Models/Account.cs new file mode 100644 index 0000000..d12b7df --- /dev/null +++ b/MoneyMap/Models/Account.cs @@ -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 Transactions { get; set; } = new List(); + public ICollection SourceTransfers { get; set; } = new List(); + public ICollection DestinationTransfers { get; set; } = new List(); +} diff --git a/MoneyMap/Models/ReceiptLineItem.cs b/MoneyMap/Models/ReceiptLineItem.cs index 456b59d..ae1eb3f 100644 --- a/MoneyMap/Models/ReceiptLineItem.cs +++ b/MoneyMap/Models/ReceiptLineItem.cs @@ -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; } + /// + /// Unit of Measure (ea, lb, gal, etc.) + /// [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; } } \ No newline at end of file diff --git a/MoneyMap/Models/Transaction.cs b/MoneyMap/Models/Transaction.cs index b9469db..fad6567 100644 --- a/MoneyMap/Models/Transaction.cs +++ b/MoneyMap/Models/Transaction.cs @@ -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 Receipts { get; set; } = new List(); [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"; + } + } } + diff --git a/MoneyMap/Models/Transfer.cs b/MoneyMap/Models/Transfer.cs new file mode 100644 index 0000000..53b6d30 --- /dev/null +++ b/MoneyMap/Models/Transfer.cs @@ -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"; +} diff --git a/MoneyMap/Pages/Accounts.cshtml b/MoneyMap/Pages/Accounts.cshtml new file mode 100644 index 0000000..be562a6 --- /dev/null +++ b/MoneyMap/Pages/Accounts.cshtml @@ -0,0 +1,72 @@ +@page +@model MoneyMap.Pages.AccountsModel +@{ + ViewData["Title"] = "Accounts"; +} + +

Bank Accounts

+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + + + +@if (Model.Accounts.Any()) +{ +
+
+ + + + + + + + + + + + + + @foreach (var account in Model.Accounts) + { + + + + + + + + + + } + +
InstitutionTypeLast 4OwnerNicknameTransactions
@account.Institution + @account.AccountType + @account.Last4@account.Owner@(string.IsNullOrEmpty(account.Nickname) ? "-" : account.Nickname)@account.TransactionCount + Edit + @if (account.TransactionCount == 0) + { +
+ +
+ } +
+
+
+} +else +{ +
+ No accounts added yet. Click "Add New Account" to create one. +
+} diff --git a/MoneyMap/Pages/Accounts.cshtml.cs b/MoneyMap/Pages/Accounts.cshtml.cs new file mode 100644 index 0000000..6bd6e78 --- /dev/null +++ b/MoneyMap/Pages/Accounts.cshtml.cs @@ -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 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 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; } + } + } +} diff --git a/MoneyMap/Pages/EditAccount.cshtml b/MoneyMap/Pages/EditAccount.cshtml new file mode 100644 index 0000000..836ea6b --- /dev/null +++ b/MoneyMap/Pages/EditAccount.cshtml @@ -0,0 +1,59 @@ +@page "{id:int?}" +@using MoneyMap.Models +@model MoneyMap.Pages.EditAccountModel +@{ + ViewData["Title"] = Model.IsNew ? "Add Account" : "Edit Account"; +} + +

@ViewData["Title"]

+ +
+
+
+ + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + Cancel +
+
+
+
+ +@section Scripts { + +} diff --git a/MoneyMap/Pages/EditAccount.cshtml.cs b/MoneyMap/Pages/EditAccount.cshtml.cs new file mode 100644 index 0000000..01338b3 --- /dev/null +++ b/MoneyMap/Pages/EditAccount.cshtml.cs @@ -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 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 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; } + } + } +} diff --git a/MoneyMap/Pages/EditTransaction.cshtml.cs b/MoneyMap/Pages/EditTransaction.cshtml.cs index 0327600..a725486 100644 --- a/MoneyMap/Pages/EditTransaction.cshtml.cs +++ b/MoneyMap/Pages/EditTransaction.cshtml.cs @@ -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 diff --git a/MoneyMap/Pages/Index.cshtml.cs b/MoneyMap/Pages/Index.cshtml.cs index b55e0bd..ceab630 100644 --- a/MoneyMap/Pages/Index.cshtml.cs +++ b/MoneyMap/Pages/Index.cshtml.cs @@ -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() diff --git a/MoneyMap/Pages/Shared/_Layout.cshtml b/MoneyMap/Pages/Shared/_Layout.cshtml index ab36d27..6d0fe3f 100644 --- a/MoneyMap/Pages/Shared/_Layout.cshtml +++ b/MoneyMap/Pages/Shared/_Layout.cshtml @@ -28,6 +28,9 @@ + diff --git a/MoneyMap/Pages/Transactions.cshtml.cs b/MoneyMap/Pages/Transactions.cshtml.cs index ff8420e..65dcce7 100644 --- a/MoneyMap/Pages/Transactions.cshtml.cs +++ b/MoneyMap/Pages/Transactions.cshtml.cs @@ -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(); diff --git a/MoneyMap/Pages/Upload.cshtml.cs b/MoneyMap/Pages/Upload.cshtml.cs index 78c9af9..e87bc4b 100644 --- a/MoneyMap/Pages/Upload.cshtml.cs +++ b/MoneyMap/Pages/Upload.cshtml.cs @@ -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 }; diff --git a/MoneyMap/Services/TransactionFilters.cs b/MoneyMap/Services/TransactionFilters.cs index 5075121..0fdf54e 100644 --- a/MoneyMap/Services/TransactionFilters.cs +++ b/MoneyMap/Services/TransactionFilters.cs @@ -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 };