diff --git a/MoneyMap/Models/CategoryMapping.cs b/MoneyMap/Models/CategoryMapping.cs new file mode 100644 index 0000000..0d362f0 --- /dev/null +++ b/MoneyMap/Models/CategoryMapping.cs @@ -0,0 +1,47 @@ +namespace MoneyMap.Models +{ + /// + /// Represents a mapping rule that associates transaction name patterns with categories. + /// Used for automatic categorization of transactions during import. + /// + public class CategoryMapping + { + public int Id { get; set; } + + /// + /// The category to assign when a transaction matches the pattern. + /// + public required string Category { get; set; } + + /// + /// The pattern to match against transaction names (case-insensitive contains). + /// + public required string Pattern { get; set; } + + /// + /// Higher priority mappings are checked first. Default is 0. + /// + public int Priority { get; set; } = 0; + + /// + /// Optional merchant to auto-assign when this pattern matches. + /// + public int? MerchantId { get; set; } + public Merchant? Merchant { get; set; } + + /// + /// AI confidence score when this rule was created by AI (0.0 - 1.0). + /// + public decimal? Confidence { get; set; } + + /// + /// Who created this rule: "User" or "AI". + /// + public string? CreatedBy { get; set; } + + /// + /// When this rule was created. + /// + public DateTime? CreatedAt { get; set; } + } +} diff --git a/MoneyMap/Pages/CategoryMappings.cshtml.cs b/MoneyMap/Pages/CategoryMappings.cshtml.cs index 058b134..ff93f97 100644 --- a/MoneyMap/Pages/CategoryMappings.cshtml.cs +++ b/MoneyMap/Pages/CategoryMappings.cshtml.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using MoneyMap.Data; +using MoneyMap.Models; using MoneyMap.Services; using System.ComponentModel.DataAnnotations; using System.Text; diff --git a/MoneyMap/Services/TransactionCategorizer.cs b/MoneyMap/Services/TransactionCategorizer.cs index 3632ef2..e961707 100644 --- a/MoneyMap/Services/TransactionCategorizer.cs +++ b/MoneyMap/Services/TransactionCategorizer.cs @@ -1,34 +1,17 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using MoneyMap.Data; +using MoneyMap.Models; 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; } - - // AI categorization tracking - public decimal? Confidence { get; set; } // AI confidence score (0.0 - 1.0) - public string? CreatedBy { get; set; } // "User" or "AI" - public DateTime? CreatedAt { get; set; } // When rule was created - } - - // ===== Service Interface ===== public interface ITransactionCategorizer { Task CategorizeAsync(string merchantName, decimal? amount = null); Task> GetAllMappingsAsync(); Task SeedDefaultMappingsAsync(); + void InvalidateMappingsCache(); } public class CategorizationResult @@ -42,11 +25,20 @@ namespace MoneyMap.Services 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) + 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) @@ -56,11 +48,8 @@ namespace MoneyMap.Services var merchantUpper = merchantName.ToUpperInvariant(); - // Get all mappings ordered by priority - var mappings = await _db.CategoryMappings - .OrderByDescending(m => m.Priority) - .ThenBy(m => m.Category) - .ToListAsync(); + // Get cached mappings or load from database + var mappings = await GetCachedMappingsAsync(); // Special case: Gas stations with small purchases if (amount.HasValue && amount.Value > GasStationThreshold) @@ -91,12 +80,26 @@ namespace MoneyMap.Services 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() { - return await _db.CategoryMappings - .OrderBy(m => m.Category) - .ThenByDescending(m => m.Priority) - .ToListAsync(); + var mappings = await GetCachedMappingsAsync(); + return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList(); } public async Task SeedDefaultMappingsAsync()