Refactor: Extract CategoryMapping model and add caching

- Move CategoryMapping class to Models/CategoryMapping.cs
- Add IMemoryCache with 10-minute TTL for category mappings
- Add InvalidateMappingsCache() method for cache invalidation
- Reduces repeated DB queries during bulk categorization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 21:11:29 -05:00
parent 625314d167
commit a30e6ff089
3 changed files with 81 additions and 30 deletions

View File

@@ -0,0 +1,47 @@
namespace MoneyMap.Models
{
/// <summary>
/// Represents a mapping rule that associates transaction name patterns with categories.
/// Used for automatic categorization of transactions during import.
/// </summary>
public class CategoryMapping
{
public int Id { get; set; }
/// <summary>
/// The category to assign when a transaction matches the pattern.
/// </summary>
public required string Category { get; set; }
/// <summary>
/// The pattern to match against transaction names (case-insensitive contains).
/// </summary>
public required string Pattern { get; set; }
/// <summary>
/// Higher priority mappings are checked first. Default is 0.
/// </summary>
public int Priority { get; set; } = 0;
/// <summary>
/// Optional merchant to auto-assign when this pattern matches.
/// </summary>
public int? MerchantId { get; set; }
public Merchant? Merchant { get; set; }
/// <summary>
/// AI confidence score when this rule was created by AI (0.0 - 1.0).
/// </summary>
public decimal? Confidence { get; set; }
/// <summary>
/// Who created this rule: "User" or "AI".
/// </summary>
public string? CreatedBy { get; set; }
/// <summary>
/// When this rule was created.
/// </summary>
public DateTime? CreatedAt { get; set; }
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services; using MoneyMap.Services;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text; using System.Text;

View File

@@ -1,34 +1,17 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services 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 public interface ITransactionCategorizer
{ {
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null); Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
Task<List<CategoryMapping>> GetAllMappingsAsync(); Task<List<CategoryMapping>> GetAllMappingsAsync();
Task SeedDefaultMappingsAsync(); Task SeedDefaultMappingsAsync();
void InvalidateMappingsCache();
} }
public class CategorizationResult public class CategorizationResult
@@ -42,11 +25,20 @@ namespace MoneyMap.Services
public class TransactionCategorizer : ITransactionCategorizer public class TransactionCategorizer : ITransactionCategorizer
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly IMemoryCache _cache;
private const decimal GasStationThreshold = -20m; 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; _db = db;
_cache = cache;
}
public void InvalidateMappingsCache()
{
_cache.Remove(MappingsCacheKey);
} }
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null) public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
@@ -56,11 +48,8 @@ namespace MoneyMap.Services
var merchantUpper = merchantName.ToUpperInvariant(); var merchantUpper = merchantName.ToUpperInvariant();
// Get all mappings ordered by priority // Get cached mappings or load from database
var mappings = await _db.CategoryMappings var mappings = await GetCachedMappingsAsync();
.OrderByDescending(m => m.Priority)
.ThenBy(m => m.Category)
.ToListAsync();
// Special case: Gas stations with small purchases // Special case: Gas stations with small purchases
if (amount.HasValue && amount.Value > GasStationThreshold) if (amount.HasValue && amount.Value > GasStationThreshold)
@@ -91,12 +80,26 @@ namespace MoneyMap.Services
return new CategorizationResult(); // No match - needs manual categorization 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() public async Task<List<CategoryMapping>> GetAllMappingsAsync()
{ {
return await _db.CategoryMappings var mappings = await GetCachedMappingsAsync();
.OrderBy(m => m.Category) return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
.ThenByDescending(m => m.Priority)
.ToListAsync();
} }
public async Task SeedDefaultMappingsAsync() public async Task SeedDefaultMappingsAsync()