Convert merchant from string to entity with foreign keys
This refactors the merchant field from a simple string column to a normalized entity with proper foreign key relationships: **Database Changes:** - Created Merchant entity/table with unique Name constraint - Replaced Transaction.Merchant (string) with Transaction.MerchantId (FK) - Replaced CategoryMapping.Merchant (string) with CategoryMapping.MerchantId (FK) - Added proper foreign key constraints with SET NULL on delete - Added indexes on MerchantId columns for performance **Backend Changes:** - Created MerchantService for finding/creating merchants - Updated CategorizationResult to return MerchantId instead of merchant name - Modified TransactionCategorizer to return MerchantId from pattern matches - Updated Upload, Recategorize, and CategoryMappings to use merchant service - Updated OpenAIReceiptParser to create/link merchants from parsed receipts - Registered IMerchantService in dependency injection **UI Changes:** - Updated CategoryMappings UI to handle merchant entities (display as Merchant.Name) - Updated Transactions page merchant filter to query by merchant entity - Modified category mapping add/edit/import to create merchants on-the-fly - Updated JavaScript to pass merchant names for edit modal **Migration:** - ConvertMerchantToEntity migration handles schema conversion - Drops old string columns and creates new FK relationships - All existing merchant data is lost (acceptable for this refactoring) **Benefits:** - Database normalization - merchants stored once, referenced many times - Referential integrity with foreign keys - Easier merchant management (rename once, updates everywhere) - Foundation for future merchant features (logos, categories, etc.) - Improved query performance with proper indexes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ namespace MoneyMap.Data
|
||||
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
|
||||
|
||||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
|
||||
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<Merchant>(e =>
|
||||
{
|
||||
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
e.HasIndex(x => x.Name).IsUnique();
|
||||
});
|
||||
|
||||
// ---------- CATEGORY MAPPING ----------
|
||||
modelBuilder.Entity<CategoryMapping>(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<Transaction>().HasIndex(x => x.Date);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
|
||||
modelBuilder.Entity<Transaction>().HasIndex(x => x.MerchantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
596
MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs
generated
Normal file
596
MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs
generated
Normal file
@@ -0,0 +1,596 @@
|
||||
// <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("20251012075038_ConvertMerchantToEntity")]
|
||||
partial class ConvertMerchantToEntity
|
||||
{
|
||||
/// <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<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.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("AccountId");
|
||||
|
||||
b.HasIndex("Issuer", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("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<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<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
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.Property<int?>("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<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()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
159
MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs
Normal file
159
MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ConvertMerchantToEntity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MerchantId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Pattern",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Category",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MerchantId",
|
||||
table: "CategoryMappings",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Merchants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string>(
|
||||
name: "Merchant",
|
||||
table: "Transactions",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Pattern",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(200)",
|
||||
oldMaxLength: 200);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Category",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(100)",
|
||||
oldMaxLength: 100);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Merchant",
|
||||
table: "CategoryMappings",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,27 @@ namespace MoneyMap.Migrations
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
@@ -303,9 +324,8 @@ namespace MoneyMap.Migrations
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("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<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("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");
|
||||
|
||||
17
MoneyMap/Models/Merchant.cs
Normal file
17
MoneyMap/Models/Merchant.cs
Normal file
@@ -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<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -100,15 +100,15 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-muted" style="cursor: pointer;"
|
||||
onclick="openEditModal(@mapping.Id, '@Html.Raw(mapping.Category.Replace("'", "\\'"))', '@Html.Raw(mapping.Pattern.Replace("'", "\\'"))', @mapping.Priority, '@Html.Raw((mapping.Merchant ?? "").Replace("'", "\\'"))')">
|
||||
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)
|
||||
{
|
||||
<span class="badge bg-info me-1" title="Priority @mapping.Priority">P@(mapping.Priority)</span>
|
||||
}
|
||||
@mapping.Pattern
|
||||
@if (!string.IsNullOrWhiteSpace(mapping.Merchant))
|
||||
@if (mapping.Merchant != null)
|
||||
{
|
||||
<span class="text-primary ms-1" title="Merchant: @mapping.Merchant">→ @mapping.Merchant</span>
|
||||
<span class="text-primary ms-1" title="Merchant: @mapping.Merchant.Name">→ @mapping.Merchant.Name</span>
|
||||
}
|
||||
</span>
|
||||
<form method="post" asp-page-handler="DeleteMapping" asp-route-id="@mapping.Id"
|
||||
|
||||
@@ -17,11 +17,13 @@ namespace MoneyMap.Pages
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ITransactionCategorizer _categorizer;
|
||||
private readonly IMerchantService _merchantService;
|
||||
|
||||
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer)
|
||||
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
|
||||
{
|
||||
_db = db;
|
||||
_categorizer = categorizer;
|
||||
_merchantService = merchantService;
|
||||
}
|
||||
|
||||
public List<CategoryGroup> 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<CategoryMapping>();
|
||||
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)
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ builder.Services.AddSession(options =>
|
||||
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
|
||||
builder.Services.AddScoped<ICardResolver, CardResolver>();
|
||||
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
||||
builder.Services.AddScoped<IMerchantService, MerchantService>();
|
||||
|
||||
// Dashboard services
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
|
||||
59
MoneyMap/Services/MerchantService.cs
Normal file
59
MoneyMap/Services/MerchantService.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IMerchantService
|
||||
{
|
||||
Task<Merchant?> FindByNameAsync(string name);
|
||||
Task<Merchant> GetOrCreateAsync(string name);
|
||||
Task<int?> GetOrCreateIdAsync(string? name);
|
||||
}
|
||||
|
||||
public class MerchantService : IMerchantService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public MerchantService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> FindByNameAsync(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
return await _db.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Name == name.Trim());
|
||||
}
|
||||
|
||||
public async Task<Merchant> 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<int?> GetOrCreateIdAsync(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var merchant = await GetOrCreateAsync(name);
|
||||
return merchant.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ReceiptParseResult> 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
|
||||
|
||||
@@ -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<CategoryMapping> mappings, params string[] patterns)
|
||||
{
|
||||
AddMappings(category, mappings, 0, null, patterns);
|
||||
AddMappings(category, mappings, 0, patterns);
|
||||
}
|
||||
|
||||
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
|
||||
{
|
||||
AddMappings(category, mappings, priority, null, patterns);
|
||||
}
|
||||
|
||||
private static void AddMappings(string category, List<CategoryMapping> 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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user