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>
259 lines
9.8 KiB
C#
259 lines
9.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MoneyMap.Data;
|
|
|
|
namespace MoneyMap.Services
|
|
{
|
|
// ===== Models =====
|
|
|
|
public class CategoryMapping
|
|
{
|
|
public int Id { get; set; }
|
|
public required string Category { get; set; }
|
|
public required string Pattern { get; set; }
|
|
public int Priority { get; set; } = 0; // Higher priority = checked first
|
|
|
|
// Merchant relationship
|
|
public int? MerchantId { get; set; }
|
|
public Models.Merchant? Merchant { get; set; }
|
|
}
|
|
|
|
// ===== Service Interface =====
|
|
|
|
public interface ITransactionCategorizer
|
|
{
|
|
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
|
|
Task<List<CategoryMapping>> GetAllMappingsAsync();
|
|
Task SeedDefaultMappingsAsync();
|
|
}
|
|
|
|
public class CategorizationResult
|
|
{
|
|
public string Category { get; set; } = string.Empty;
|
|
public int? MerchantId { get; set; }
|
|
}
|
|
|
|
// ===== Service Implementation =====
|
|
|
|
public class TransactionCategorizer : ITransactionCategorizer
|
|
{
|
|
private readonly MoneyMapContext _db;
|
|
private const decimal GasStationThreshold = -20m;
|
|
|
|
public TransactionCategorizer(MoneyMapContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(merchantName))
|
|
return new CategorizationResult();
|
|
|
|
var merchantUpper = merchantName.ToUpperInvariant();
|
|
|
|
// Get all mappings ordered by priority
|
|
var mappings = await _db.CategoryMappings
|
|
.OrderByDescending(m => m.Priority)
|
|
.ThenBy(m => m.Category)
|
|
.ToListAsync();
|
|
|
|
// Special case: Gas stations with small purchases
|
|
if (amount.HasValue && amount.Value > GasStationThreshold)
|
|
{
|
|
var gasMapping = mappings.FirstOrDefault(m =>
|
|
m.Category == "Gas & Auto" &&
|
|
merchantUpper.Contains(m.Pattern.ToUpperInvariant()));
|
|
|
|
if (gasMapping != null)
|
|
return new CategorizationResult
|
|
{
|
|
Category = "Convenience Store",
|
|
MerchantId = gasMapping.MerchantId
|
|
};
|
|
}
|
|
|
|
// Check each category's patterns
|
|
foreach (var mapping in mappings)
|
|
{
|
|
if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant()))
|
|
return new CategorizationResult
|
|
{
|
|
Category = mapping.Category,
|
|
MerchantId = mapping.MerchantId
|
|
};
|
|
}
|
|
|
|
return new CategorizationResult(); // No match - needs manual categorization
|
|
}
|
|
|
|
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
|
|
{
|
|
return await _db.CategoryMappings
|
|
.OrderBy(m => m.Category)
|
|
.ThenByDescending(m => m.Priority)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task SeedDefaultMappingsAsync()
|
|
{
|
|
// Check if mappings already exist
|
|
if (await _db.CategoryMappings.AnyAsync())
|
|
return;
|
|
|
|
var defaultMappings = GetDefaultMappings();
|
|
|
|
_db.CategoryMappings.AddRange(defaultMappings);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
private static List<CategoryMapping> GetDefaultMappings()
|
|
{
|
|
var mappings = new List<CategoryMapping>();
|
|
|
|
// Online Shopping
|
|
AddMappings("Online shopping", mappings,
|
|
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
|
|
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
|
|
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
|
|
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM");
|
|
|
|
// Walmart
|
|
AddMappings("Walmart Online", mappings, "WALMART.COM");
|
|
AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C");
|
|
|
|
// Pizza
|
|
AddMappings("Pizza", mappings,
|
|
"CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ",
|
|
"PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES");
|
|
|
|
// Retail Stores
|
|
AddMappings("Brick/mortar store", mappings,
|
|
"DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE",
|
|
"WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES",
|
|
"GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT");
|
|
|
|
// Restaurants
|
|
AddMappings("Eat out / Restaurants", mappings,
|
|
"KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT",
|
|
"STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL",
|
|
"RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE",
|
|
"EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR",
|
|
"ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL");
|
|
|
|
// School
|
|
AddMappings("School", mappings,
|
|
"INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE");
|
|
|
|
// Health
|
|
AddMappings("Health", mappings,
|
|
"MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS",
|
|
"WHITEWATER EYE", "GIESTING FAMILY DENTIS");
|
|
|
|
// Gas & Auto (higher priority for special handling)
|
|
AddMappings("Gas & Auto", mappings, 100,
|
|
"SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO",
|
|
"WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR",
|
|
"COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC");
|
|
|
|
// Utilities
|
|
AddMappings("Utilities/Services", mappings,
|
|
"SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT",
|
|
"US MOBILE", "WHITEWATER VALLE", "RUMPKE");
|
|
|
|
// Entertainment
|
|
AddMappings("Entertainment", mappings,
|
|
"SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE",
|
|
"GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX",
|
|
"SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB");
|
|
|
|
// Banking (high priority to catch these first)
|
|
AddMappings("Banking", mappings, 200,
|
|
"ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE",
|
|
"MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT",
|
|
"MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE",
|
|
"FRGN TRANS FEE", "START SCHEDULED TRANSFER");
|
|
|
|
// Mortgage
|
|
AddMappings("Mortgage", mappings, "WAYNE BANK");
|
|
|
|
// Car Payment
|
|
AddMappings("Car Payment", mappings, "UNION SAVINGS AN");
|
|
|
|
// Convenience Store
|
|
AddMappings("Convenience Store", mappings,
|
|
"PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK");
|
|
|
|
// Income (high priority)
|
|
AddMappings("Income", mappings, 200,
|
|
"MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS",
|
|
"RPA PA", "REWARDS REDEEMED");
|
|
|
|
// Taxes
|
|
AddMappings("Taxes", mappings, "MYERS INCOME TAX");
|
|
|
|
// Insurance
|
|
AddMappings("Insurance", mappings,
|
|
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
|
|
|
|
// Credit Card Payment (high priority to catch before Banking)
|
|
AddMappings("Credit Card Payment", mappings, 200,
|
|
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
|
|
|
|
// Ice Cream
|
|
AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE");
|
|
|
|
// Government
|
|
AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK");
|
|
|
|
// Home Services
|
|
AddMappings("Home Services", mappings, "DUNGAN PLUMBING");
|
|
|
|
// Special Occasions
|
|
AddMappings("Special Occasions", mappings,
|
|
"CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR");
|
|
|
|
// Home Improvement
|
|
AddMappings("Home Improvement", mappings,
|
|
"SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907",
|
|
"123FILTER", "O-RING STORE");
|
|
|
|
// Software/Subscriptions
|
|
AddMappings("Software/Subscriptions", mappings,
|
|
"GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP",
|
|
"AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List");
|
|
|
|
return mappings;
|
|
}
|
|
|
|
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
|
|
{
|
|
AddMappings(category, mappings, 0, patterns);
|
|
}
|
|
|
|
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
|
|
{
|
|
foreach (var pattern in patterns)
|
|
{
|
|
mappings.Add(new CategoryMapping
|
|
{
|
|
Category = category,
|
|
Pattern = pattern,
|
|
MerchantId = null, // Will be set by users via UI
|
|
Priority = priority
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Database Migration =====
|
|
// Add this to your DbContext:
|
|
// public DbSet<CategoryMapping> CategoryMappings { get; set; }
|
|
//
|
|
// Then create a migration:
|
|
// dotnet ef migrations add AddCategoryMappings
|
|
// dotnet ef database update
|
|
} |