Files
MoneyMap/MoneyMap.Core/Services/TransactionCategorizer.cs
T
2026-04-20 18:18:20 -04:00

263 lines
10 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services
{
public interface ITransactionCategorizer
{
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
Task<List<CategoryMapping>> GetAllMappingsAsync();
Task SeedDefaultMappingsAsync();
void InvalidateMappingsCache();
}
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 readonly IMemoryCache _cache;
private const decimal GasStationThreshold = -20m;
private const string MappingsCacheKey = "CategoryMappings";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
public TransactionCategorizer(MoneyMapContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public void InvalidateMappingsCache()
{
_cache.Remove(MappingsCacheKey);
}
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
{
if (string.IsNullOrWhiteSpace(merchantName))
return new CategorizationResult();
var merchantUpper = merchantName.ToUpperInvariant();
// Get cached mappings or load from database
var mappings = await GetCachedMappingsAsync();
// 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
}
private async Task<List<CategoryMapping>> GetCachedMappingsAsync()
{
if (_cache.TryGetValue(MappingsCacheKey, out List<CategoryMapping>? cachedMappings) && cachedMappings != null)
{
return cachedMappings;
}
var mappings = await _db.CategoryMappings
.OrderByDescending(m => m.Priority)
.ThenBy(m => m.Category)
.ToListAsync();
_cache.Set(MappingsCacheKey, mappings, CacheDuration);
return mappings;
}
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
{
var mappings = await GetCachedMappingsAsync();
return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
}
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
}