Files
MoneyMap/MoneyMap/Services/TransactionCategorizer.cs
AJ b1143ad484 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>
2025-10-12 03:52:05 -04:00

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
}