diff --git a/MoneyMap/Data/MoneyMapContext.cs b/MoneyMap/Data/MoneyMapContext.cs index 28fbaa6..c7c977d 100644 --- a/MoneyMap/Data/MoneyMapContext.cs +++ b/MoneyMap/Data/MoneyMapContext.cs @@ -27,8 +27,18 @@ namespace MoneyMap.Data { e.Property(x => x.Issuer).HasMaxLength(100).IsRequired(); e.Property(x => x.Last4).HasMaxLength(4).IsRequired(); - e.Property(x => x.Owner).HasMaxLength(100); + e.Property(x => x.Owner).HasMaxLength(100).IsRequired(); + e.Property(x => x.Nickname).HasMaxLength(50); + + // Card can be linked to an account (optional - for credit cards without linked account) + e.HasOne(x => x.Account) + .WithMany(a => a.Cards) + .HasForeignKey(x => x.AccountId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner }); + e.HasIndex(x => x.AccountId); }); // ---------- ACCOUNT ---------- diff --git a/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs new file mode 100644 index 0000000..1f588b4 --- /dev/null +++ b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs @@ -0,0 +1,528 @@ +// +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("20251010025545_LinkCardsToAccounts")] + partial class LinkCardsToAccounts + { + /// + 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("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .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("AccountId"); + + 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.Card", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Cards") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + 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("Cards"); + + 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/20251010025545_LinkCardsToAccounts.cs b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.cs new file mode 100644 index 0000000..363076c --- /dev/null +++ b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class LinkCardsToAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccountId", + table: "Cards", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "Nickname", + table: "Cards", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Cards_AccountId", + table: "Cards", + column: "AccountId"); + + migrationBuilder.AddForeignKey( + name: "FK_Cards_Accounts_AccountId", + table: "Cards", + column: "AccountId", + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Cards_Accounts_AccountId", + table: "Cards"); + + migrationBuilder.DropIndex( + name: "IX_Cards_AccountId", + table: "Cards"); + + migrationBuilder.DropColumn( + name: "AccountId", + table: "Cards"); + + migrationBuilder.DropColumn( + name: "Nickname", + table: "Cards"); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index 2be8c19..710abf5 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -67,6 +67,9 @@ namespace MoneyMap.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("AccountId") + .HasColumnType("int"); + b.Property("Issuer") .IsRequired() .HasMaxLength(100) @@ -77,6 +80,10 @@ namespace MoneyMap.Migrations .HasMaxLength(4) .HasColumnType("nvarchar(4)"); + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("Owner") .IsRequired() .HasMaxLength(100) @@ -84,6 +91,8 @@ namespace MoneyMap.Migrations b.HasKey("Id"); + b.HasIndex("AccountId"); + b.HasIndex("Issuer", "Last4", "Owner"); b.ToTable("Cards"); @@ -399,6 +408,16 @@ namespace MoneyMap.Migrations b.ToTable("CategoryMappings"); }); + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Cards") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + modelBuilder.Entity("MoneyMap.Models.Receipt", b => { b.HasOne("MoneyMap.Models.Transaction", "Transaction") @@ -475,6 +494,8 @@ namespace MoneyMap.Migrations modelBuilder.Entity("MoneyMap.Models.Account", b => { + b.Navigation("Cards"); + b.Navigation("DestinationTransfers"); b.Navigation("SourceTransfers"); diff --git a/MoneyMap/Models/Account.cs b/MoneyMap/Models/Account.cs index d12b7df..afd19db 100644 --- a/MoneyMap/Models/Account.cs +++ b/MoneyMap/Models/Account.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace MoneyMap.Models; @@ -32,7 +33,13 @@ public class Account public string? Nickname { get; set; } // Optional friendly name like "Emergency Fund" // Navigation properties + public ICollection Cards { get; set; } = new List(); // Cards linked to this account public ICollection Transactions { get; set; } = new List(); public ICollection SourceTransfers { get; set; } = new List(); public ICollection DestinationTransfers { get; set; } = new List(); + + [NotMapped] + public string DisplayLabel => string.IsNullOrEmpty(Nickname) + ? $"{Institution} {Last4} ({AccountType})" + : $"{Nickname} ({Institution} {Last4})"; } diff --git a/MoneyMap/Models/Card.cs b/MoneyMap/Models/Card.cs index fcc54b0..d681e3d 100644 --- a/MoneyMap/Models/Card.cs +++ b/MoneyMap/Models/Card.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using System.Transactions; namespace MoneyMap.Models; @@ -8,14 +9,30 @@ public class Card [Key] public int Id { get; set; } + [Required] [MaxLength(100)] - public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC + public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC, Discover + [Required] [MaxLength(4)] public string Last4 { get; set; } = string.Empty; // "1234" + [Required] [MaxLength(100)] - public string Owner { get; set; } = string.Empty; // optional + public string Owner { get; set; } = string.Empty; + + // Link to the account this card draws from/pays to + [ForeignKey(nameof(Account))] + public int? AccountId { get; set; } + public Account? Account { get; set; } + + [MaxLength(50)] + public string? Nickname { get; set; } // Optional friendly name public ICollection Transactions { get; set; } = new List(); + + [NotMapped] + public string DisplayLabel => string.IsNullOrEmpty(Nickname) + ? $"{Issuer} {Last4}" + : $"{Nickname} ({Issuer} {Last4})"; }