diff --git a/MoneyMap/Data/MoneyMapContext.cs b/MoneyMap/Data/MoneyMapContext.cs index c7c977d..4abc6ac 100644 --- a/MoneyMap/Data/MoneyMapContext.cs +++ b/MoneyMap/Data/MoneyMapContext.cs @@ -19,6 +19,7 @@ namespace MoneyMap.Data public DbSet ReceiptLineItems => Set(); public DbSet CategoryMappings => Set(); + public DbSet Merchants => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -74,6 +75,13 @@ namespace MoneyMap.Data .HasForeignKey(x => x.AccountId) .OnDelete(DeleteBehavior.Restrict) .IsRequired(false); + + // Merchant (optional). If a merchant is deleted, set to null. + e.HasOne(x => x.Merchant) + .WithMany(m => m.Transactions) + .HasForeignKey(x => x.MerchantId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); }); // ---------- TRANSFER ---------- @@ -159,11 +167,33 @@ namespace MoneyMap.Data .OnDelete(DeleteBehavior.Cascade); }); + // ---------- MERCHANT ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Name).HasMaxLength(100).IsRequired(); + e.HasIndex(x => x.Name).IsUnique(); + }); + + // ---------- CATEGORY MAPPING ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Category).HasMaxLength(100).IsRequired(); + e.Property(x => x.Pattern).HasMaxLength(200).IsRequired(); + + // Merchant (optional). If a merchant is deleted, set to null. + e.HasOne(x => x.Merchant) + .WithMany(m => m.CategoryMappings) + .HasForeignKey(x => x.MerchantId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + }); + // ---------- Extra SQL Server–friendly indexes ---------- // Fast filtering by date/amount/category modelBuilder.Entity().HasIndex(x => x.Date); modelBuilder.Entity().HasIndex(x => x.Amount); modelBuilder.Entity().HasIndex(x => x.Category); + modelBuilder.Entity().HasIndex(x => x.MerchantId); } } } diff --git a/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs new file mode 100644 index 0000000..3dbf7c1 --- /dev/null +++ b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs @@ -0,0 +1,596 @@ +// +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("20251012075038_ConvertMerchantToEntity")] + partial class ConvertMerchantToEntity + { + /// + 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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Merchants"); + }); + + 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("MerchantId") + .HasColumnType("int"); + + 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.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId") + .IsUnique() + .HasFilter("[CardId] 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() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + 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.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("Transactions") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MoneyMap.Models.Account", "TransferToAccount") + .WithMany() + .HasForeignKey("TransferToAccountId"); + + b.Navigation("Account"); + + b.Navigation("Card"); + + b.Navigation("Merchant"); + + b.Navigation("TransferToAccount"); + }); + + 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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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.Merchant", b => + { + b.Navigation("CategoryMappings"); + + 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/20251012075038_ConvertMerchantToEntity.cs b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs new file mode 100644 index 0000000..365a173 --- /dev/null +++ b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class ConvertMerchantToEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Merchant", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "Merchant", + table: "CategoryMappings"); + + migrationBuilder.AddColumn( + name: "MerchantId", + table: "Transactions", + type: "int", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Pattern", + table: "CategoryMappings", + type: "nvarchar(200)", + maxLength: 200, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AlterColumn( + name: "Category", + table: "CategoryMappings", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AddColumn( + name: "MerchantId", + table: "CategoryMappings", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "Merchants", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Merchants", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_MerchantId", + table: "Transactions", + column: "MerchantId"); + + migrationBuilder.CreateIndex( + name: "IX_CategoryMappings_MerchantId", + table: "CategoryMappings", + column: "MerchantId"); + + migrationBuilder.CreateIndex( + name: "IX_Merchants_Name", + table: "Merchants", + column: "Name", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_CategoryMappings_Merchants_MerchantId", + table: "CategoryMappings", + column: "MerchantId", + principalTable: "Merchants", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Merchants_MerchantId", + table: "Transactions", + column: "MerchantId", + principalTable: "Merchants", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CategoryMappings_Merchants_MerchantId", + table: "CategoryMappings"); + + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Merchants_MerchantId", + table: "Transactions"); + + migrationBuilder.DropTable( + name: "Merchants"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_MerchantId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_CategoryMappings_MerchantId", + table: "CategoryMappings"); + + migrationBuilder.DropColumn( + name: "MerchantId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "MerchantId", + table: "CategoryMappings"); + + migrationBuilder.AddColumn( + name: "Merchant", + table: "Transactions", + type: "nvarchar(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AlterColumn( + name: "Pattern", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(200)", + oldMaxLength: 200); + + migrationBuilder.AlterColumn( + name: "Category", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100); + + migrationBuilder.AddColumn( + name: "Merchant", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: true); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index 042bb02..5e51962 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -98,6 +98,27 @@ namespace MoneyMap.Migrations b.ToTable("Cards"); }); + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Merchants"); + }); + modelBuilder.Entity("MoneyMap.Models.Receipt", b => { b.Property("Id") @@ -303,9 +324,8 @@ namespace MoneyMap.Migrations .HasColumnType("nvarchar(500)") .HasDefaultValue(""); - b.Property("Merchant") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); + b.Property("MerchantId") + .HasColumnType("int"); b.Property("Name") .IsRequired() @@ -336,6 +356,8 @@ namespace MoneyMap.Migrations b.HasIndex("Date"); + b.HasIndex("MerchantId"); + b.HasIndex("TransferToAccountId"); b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId") @@ -403,20 +425,24 @@ namespace MoneyMap.Migrations b.Property("Category") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); - b.Property("Merchant") - .HasColumnType("nvarchar(max)"); + b.Property("MerchantId") + .HasColumnType("int"); b.Property("Pattern") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); b.Property("Priority") .HasColumnType("int"); b.HasKey("Id"); + b.HasIndex("MerchantId"); + b.ToTable("CategoryMappings"); }); @@ -475,6 +501,11 @@ namespace MoneyMap.Migrations .HasForeignKey("CardId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("Transactions") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("MoneyMap.Models.Account", "TransferToAccount") .WithMany() .HasForeignKey("TransferToAccountId"); @@ -483,6 +514,8 @@ namespace MoneyMap.Migrations b.Navigation("Card"); + b.Navigation("Merchant"); + b.Navigation("TransferToAccount"); }); @@ -510,6 +543,16 @@ namespace MoneyMap.Migrations b.Navigation("SourceAccount"); }); + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + modelBuilder.Entity("MoneyMap.Models.Account", b => { b.Navigation("Cards"); @@ -526,6 +569,13 @@ namespace MoneyMap.Migrations b.Navigation("Transactions"); }); + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Navigation("CategoryMappings"); + + b.Navigation("Transactions"); + }); + modelBuilder.Entity("MoneyMap.Models.Receipt", b => { b.Navigation("LineItems"); diff --git a/MoneyMap/Models/Merchant.cs b/MoneyMap/Models/Merchant.cs new file mode 100644 index 0000000..a13bbab --- /dev/null +++ b/MoneyMap/Models/Merchant.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using MoneyMap.Services; + +namespace MoneyMap.Models; + +public class Merchant +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + public ICollection Transactions { get; set; } = new List(); + public ICollection CategoryMappings { get; set; } = new List(); +} diff --git a/MoneyMap/Models/Transaction.cs b/MoneyMap/Models/Transaction.cs index 99cc729..2ff1452 100644 --- a/MoneyMap/Models/Transaction.cs +++ b/MoneyMap/Models/Transaction.cs @@ -29,8 +29,10 @@ public class Transaction [MaxLength(100)] public string Category { get; set; } = string.Empty; - [MaxLength(100)] - public string? Merchant { get; set; } + // Merchant relationship + [ForeignKey(nameof(Merchant))] + public int? MerchantId { get; set; } + public Merchant? Merchant { get; set; } public string Notes { get; set; } = string.Empty; diff --git a/MoneyMap/Pages/CategoryMappings.cshtml b/MoneyMap/Pages/CategoryMappings.cshtml index 91280b1..ec891f4 100644 --- a/MoneyMap/Pages/CategoryMappings.cshtml +++ b/MoneyMap/Pages/CategoryMappings.cshtml @@ -100,15 +100,15 @@ {
+ onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority, '@Html.Raw((mapping.Merchant?.Name ?? "").Replace("'", "\\'"))')"> @if (mapping.Priority > 0) { P@(mapping.Priority) } @mapping.Pattern - @if (!string.IsNullOrWhiteSpace(mapping.Merchant)) + @if (mapping.Merchant != null) { - → @mapping.Merchant + → @mapping.Merchant.Name }
CategoryGroups { get; set; } = new(); @@ -55,11 +57,17 @@ namespace MoneyMap.Pages return Page(); } + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(model.Merchant)) + { + merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant); + } + var mapping = new CategoryMapping { Category = model.Category.Trim(), Pattern = model.Pattern.Trim(), - Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(), + MerchantId = merchantId, Priority = model.Priority }; @@ -86,9 +94,15 @@ namespace MoneyMap.Pages return RedirectToPage(); } + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(model.Merchant)) + { + merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant); + } + mapping.Category = model.Category.Trim(); mapping.Pattern = model.Pattern.Trim(); - mapping.Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(); + mapping.MerchantId = merchantId; mapping.Priority = model.Priority; await _db.SaveChangesAsync(); @@ -124,7 +138,7 @@ namespace MoneyMap.Pages { Category = m.Category, Pattern = m.Pattern, - Merchant = m.Merchant, + Merchant = m.Merchant?.Name, Priority = m.Priority }).ToList(); @@ -176,14 +190,24 @@ namespace MoneyMap.Pages _db.CategoryMappings.RemoveRange(existingMappings); } - // Add new mappings - var newMappings = importData.Select(m => new CategoryMapping + // Add new mappings (create merchants first if needed) + var newMappings = new List(); + foreach (var item in importData) { - Category = m.Category.Trim(), - Pattern = m.Pattern.Trim(), - Merchant = string.IsNullOrWhiteSpace(m.Merchant) ? null : m.Merchant.Trim(), - Priority = m.Priority - }).ToList(); + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(item.Merchant)) + { + merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant); + } + + newMappings.Add(new CategoryMapping + { + Category = item.Category.Trim(), + Pattern = item.Pattern.Trim(), + MerchantId = merchantId, + Priority = item.Priority + }); + } _db.CategoryMappings.AddRange(newMappings); await _db.SaveChangesAsync(); @@ -208,7 +232,11 @@ namespace MoneyMap.Pages private async Task LoadDataAsync() { - var mappings = await _categorizer.GetAllMappingsAsync(); + var mappings = await _db.CategoryMappings + .Include(m => m.Merchant) + .OrderBy(m => m.Category) + .ThenByDescending(m => m.Priority) + .ToListAsync(); CategoryGroups = mappings .GroupBy(m => m.Category) diff --git a/MoneyMap/Pages/Recategorize.cshtml.cs b/MoneyMap/Pages/Recategorize.cshtml.cs index 7e46670..5eff134 100644 --- a/MoneyMap/Pages/Recategorize.cshtml.cs +++ b/MoneyMap/Pages/Recategorize.cshtml.cs @@ -88,14 +88,14 @@ namespace MoneyMap.Pages continue; } - if (txn.Category == result.Category && txn.Merchant == result.Merchant) + if (txn.Category == result.Category && txn.MerchantId == result.MerchantId) { alreadyCorrect++; continue; } txn.Category = result.Category; - txn.Merchant = result.Merchant; + txn.MerchantId = result.MerchantId; updated++; } diff --git a/MoneyMap/Pages/Transactions.cshtml.cs b/MoneyMap/Pages/Transactions.cshtml.cs index c5bf586..cc7f235 100644 --- a/MoneyMap/Pages/Transactions.cshtml.cs +++ b/MoneyMap/Pages/Transactions.cshtml.cs @@ -73,11 +73,11 @@ namespace MoneyMap.Pages { if (Merchant == "(blank)") { - query = query.Where(t => string.IsNullOrWhiteSpace(t.Merchant)); + query = query.Where(t => t.MerchantId == null); } else { - query = query.Where(t => t.Merchant == Merchant); + query = query.Where(t => t.Merchant != null && t.Merchant.Name == Merchant); } } @@ -154,11 +154,9 @@ namespace MoneyMap.Pages .ToListAsync(); // Get available merchants for filter dropdown - AvailableMerchants = await _db.Transactions - .Where(t => !string.IsNullOrWhiteSpace(t.Merchant)) - .Select(t => t.Merchant!) - .Distinct() - .OrderBy(m => m) + AvailableMerchants = await _db.Merchants + .OrderBy(m => m.Name) + .Select(m => m.Name) .ToListAsync(); // Get available cards for filter dropdown diff --git a/MoneyMap/Pages/Upload.cshtml.cs b/MoneyMap/Pages/Upload.cshtml.cs index b3f37a2..8597e76 100644 --- a/MoneyMap/Pages/Upload.cshtml.cs +++ b/MoneyMap/Pages/Upload.cshtml.cs @@ -100,7 +100,7 @@ namespace MoneyMap.Pages { var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount); preview.Transaction.Category = categorizationResult.Category; - preview.Transaction.Merchant = categorizationResult.Merchant; + preview.Transaction.MerchantId = categorizationResult.MerchantId; preview.SuggestedCategory = categorizationResult.Category; } } diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index 620c453..5363704 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -24,6 +24,7 @@ builder.Services.AddSession(options => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Dashboard services builder.Services.AddScoped(); diff --git a/MoneyMap/Services/MerchantService.cs b/MoneyMap/Services/MerchantService.cs new file mode 100644 index 0000000..4a5e547 --- /dev/null +++ b/MoneyMap/Services/MerchantService.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services +{ + public interface IMerchantService + { + Task FindByNameAsync(string name); + Task GetOrCreateAsync(string name); + Task GetOrCreateIdAsync(string? name); + } + + public class MerchantService : IMerchantService + { + private readonly MoneyMapContext _db; + + public MerchantService(MoneyMapContext db) + { + _db = db; + } + + public async Task FindByNameAsync(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + return await _db.Merchants + .FirstOrDefaultAsync(m => m.Name == name.Trim()); + } + + public async Task GetOrCreateAsync(string name) + { + var trimmedName = name.Trim(); + + var existing = await _db.Merchants + .FirstOrDefaultAsync(m => m.Name == trimmedName); + + if (existing != null) + return existing; + + var merchant = new Merchant { Name = trimmedName }; + _db.Merchants.Add(merchant); + await _db.SaveChangesAsync(); + + return merchant; + } + + public async Task GetOrCreateIdAsync(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var merchant = await GetOrCreateAsync(name); + return merchant.Id; + } + } +} diff --git a/MoneyMap/Services/OpenAIReceiptParser.cs b/MoneyMap/Services/OpenAIReceiptParser.cs index f88da26..9bd345f 100644 --- a/MoneyMap/Services/OpenAIReceiptParser.cs +++ b/MoneyMap/Services/OpenAIReceiptParser.cs @@ -26,17 +26,20 @@ namespace MoneyMap.Services private readonly IWebHostEnvironment _environment; private readonly IConfiguration _configuration; private readonly HttpClient _httpClient; + private readonly IMerchantService _merchantService; public OpenAIReceiptParser( MoneyMapContext db, IWebHostEnvironment environment, IConfiguration configuration, - HttpClient httpClient) + HttpClient httpClient, + IMerchantService merchantService) { _db = db; _environment = environment; _configuration = configuration; _httpClient = httpClient; + _merchantService = merchantService; } public async Task ParseReceiptAsync(long receiptId) @@ -102,9 +105,10 @@ namespace MoneyMap.Services // Update transaction merchant if we extracted one and transaction doesn't have one yet if (receipt.Transaction != null && !string.IsNullOrWhiteSpace(parseData.Merchant) && - string.IsNullOrWhiteSpace(receipt.Transaction.Merchant)) + receipt.Transaction.MerchantId == null) { - receipt.Transaction.Merchant = parseData.Merchant; + var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant); + receipt.Transaction.MerchantId = merchantId; } // Remove existing line items diff --git a/MoneyMap/Services/TransactionCategorizer.cs b/MoneyMap/Services/TransactionCategorizer.cs index 91dc157..ce5a474 100644 --- a/MoneyMap/Services/TransactionCategorizer.cs +++ b/MoneyMap/Services/TransactionCategorizer.cs @@ -15,7 +15,10 @@ namespace MoneyMap.Services public required string Category { get; set; } public required string Pattern { get; set; } public int Priority { get; set; } = 0; // Higher priority = checked first - public string? Merchant { get; set; } // Friendly merchant name to assign to matching transactions + + // Merchant relationship + public int? MerchantId { get; set; } + public Models.Merchant? Merchant { get; set; } } // ===== Service Interface ===== @@ -30,7 +33,7 @@ namespace MoneyMap.Services public class CategorizationResult { public string Category { get; set; } = string.Empty; - public string? Merchant { get; set; } + public int? MerchantId { get; set; } } // ===== Service Implementation ===== @@ -69,7 +72,7 @@ namespace MoneyMap.Services return new CategorizationResult { Category = "Convenience Store", - Merchant = gasMapping.Merchant + MerchantId = gasMapping.MerchantId }; } @@ -80,7 +83,7 @@ namespace MoneyMap.Services return new CategorizationResult { Category = mapping.Category, - Merchant = mapping.Merchant + MerchantId = mapping.MerchantId }; } @@ -228,15 +231,10 @@ namespace MoneyMap.Services private static void AddMappings(string category, List mappings, params string[] patterns) { - AddMappings(category, mappings, 0, null, patterns); + AddMappings(category, mappings, 0, patterns); } private static void AddMappings(string category, List mappings, int priority, params string[] patterns) - { - AddMappings(category, mappings, priority, null, patterns); - } - - private static void AddMappings(string category, List mappings, int priority, string? merchant, params string[] patterns) { foreach (var pattern in patterns) { @@ -244,7 +242,7 @@ namespace MoneyMap.Services { Category = category, Pattern = pattern, - Merchant = merchant, + MerchantId = null, // Will be set by users via UI Priority = priority }); }