using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using MoneyMap.Data; using MoneyMap.Models; namespace MoneyMap.Services { public interface ITransactionCategorizer { Task CategorizeAsync(string merchantName, decimal? amount = null); Task> 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 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> GetCachedMappingsAsync() { if (_cache.TryGetValue(MappingsCacheKey, out List? 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> 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 GetDefaultMappings() { var mappings = new List(); // 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 mappings, params string[] patterns) { AddMappings(category, mappings, 0, patterns); } private static void AddMappings(string category, List 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 CategoryMappings { get; set; } // // Then create a migration: // dotnet ef migrations add AddCategoryMappings // dotnet ef database update }