Files
MoneyMap/MoneyMap/Pages/CategoryMappings.cshtml.cs
AJ 675ffa6509 Add merchant field to transactions and category mappings
This feature enables easy filtering and identification of transactions by merchant name:
- Added Merchant column to Transaction model (nullable, max 100 chars)
- Added Merchant field to CategoryMapping model
- Modified ITransactionCategorizer to return CategorizationResult (category + merchant)
- Updated auto-categorization logic to assign merchant from category mappings
- Updated category mappings UI to include merchant field in add/edit forms
- Added merchant filter dropdown to transactions page with full pagination support
- Updated receipt parser to set transaction merchant from parsed receipt data
- Created two database migrations for the schema changes
- Updated helper methods to support merchant names in default mappings

Benefits:
- Consistent merchant naming across variant patterns (e.g., "Walmart" for all "WAL-MART*" patterns)
- Easy filtering by merchant on transactions page
- No CSV changes required - merchant is derived from category mapping patterns
- Receipt parsing can also populate merchant field automatically

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:21:31 -04:00

278 lines
9.2 KiB
C#

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class CategoryMappingsModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer)
{
_db = db;
_categorizer = categorizer;
}
public List<CategoryGroup> CategoryGroups { get; set; } = new();
public int TotalMappings { get; set; }
public int TotalCategories { get; set; }
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task OnGetAsync()
{
await LoadDataAsync();
}
public async Task<IActionResult> OnPostSeedDefaultsAsync()
{
await _categorizer.SeedDefaultMappingsAsync();
SuccessMessage = "Default category mappings loaded successfully!";
return RedirectToPage();
}
public async Task<IActionResult> OnPostAddMappingAsync(AddMappingModel model)
{
if (!ModelState.IsValid)
{
ErrorMessage = "Please fill in all required fields.";
await LoadDataAsync();
return Page();
}
var mapping = new CategoryMapping
{
Category = model.Category.Trim(),
Pattern = model.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim(),
Priority = model.Priority
};
_db.CategoryMappings.Add(mapping);
await _db.SaveChangesAsync();
SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostUpdateMappingAsync(UpdateMappingModel model)
{
if (!ModelState.IsValid)
{
ErrorMessage = "Please fill in all required fields.";
await LoadDataAsync();
return Page();
}
var mapping = await _db.CategoryMappings.FindAsync(model.Id);
if (mapping == null)
{
ErrorMessage = "Mapping not found.";
return RedirectToPage();
}
mapping.Category = model.Category.Trim();
mapping.Pattern = model.Pattern.Trim();
mapping.Merchant = string.IsNullOrWhiteSpace(model.Merchant) ? null : model.Merchant.Trim();
mapping.Priority = model.Priority;
await _db.SaveChangesAsync();
SuccessMessage = "Mapping updated successfully.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteMappingAsync(int id)
{
var mapping = await _db.CategoryMappings.FindAsync(id);
if (mapping == null)
{
ErrorMessage = "Mapping not found.";
return RedirectToPage();
}
_db.CategoryMappings.Remove(mapping);
await _db.SaveChangesAsync();
SuccessMessage = "Mapping deleted successfully.";
return RedirectToPage();
}
public async Task<IActionResult> OnGetExportAsync()
{
var mappings = await _db.CategoryMappings
.OrderBy(m => m.Category)
.ThenByDescending(m => m.Priority)
.ToListAsync();
var exportData = mappings.Select(m => new CategoryMappingExport
{
Category = m.Category,
Pattern = m.Pattern,
Merchant = m.Merchant,
Priority = m.Priority
}).ToList();
var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions
{
WriteIndented = true
});
var bytes = Encoding.UTF8.GetBytes(json);
return File(bytes, "application/json", "category-mappings.json");
}
public async Task<IActionResult> OnPostImportAsync(IFormFile jsonFile, bool replaceExisting)
{
if (jsonFile == null || jsonFile.Length == 0)
{
ErrorMessage = "Please select a JSON file to import.";
return RedirectToPage();
}
try
{
using var stream = jsonFile.OpenReadStream();
var importData = await JsonSerializer.DeserializeAsync<List<CategoryMappingExport>>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (importData == null || !importData.Any())
{
ErrorMessage = "No category mappings found in the file.";
return RedirectToPage();
}
// Validate the data
foreach (var item in importData)
{
if (string.IsNullOrWhiteSpace(item.Category) || string.IsNullOrWhiteSpace(item.Pattern))
{
ErrorMessage = "Invalid data in JSON file. Each mapping must have a category and pattern.";
return RedirectToPage();
}
}
// Replace existing or merge
if (replaceExisting)
{
var existingMappings = await _db.CategoryMappings.ToListAsync();
_db.CategoryMappings.RemoveRange(existingMappings);
}
// Add new mappings
var newMappings = importData.Select(m => new CategoryMapping
{
Category = m.Category.Trim(),
Pattern = m.Pattern.Trim(),
Merchant = string.IsNullOrWhiteSpace(m.Merchant) ? null : m.Merchant.Trim(),
Priority = m.Priority
}).ToList();
_db.CategoryMappings.AddRange(newMappings);
await _db.SaveChangesAsync();
SuccessMessage = replaceExisting
? $"Successfully replaced all mappings with {importData.Count} mappings from file."
: $"Successfully imported {importData.Count} mappings from file.";
return RedirectToPage();
}
catch (JsonException)
{
ErrorMessage = "Invalid JSON file format. Please check the file and try again.";
return RedirectToPage();
}
catch (Exception ex)
{
ErrorMessage = $"Error importing file: {ex.Message}";
return RedirectToPage();
}
}
private async Task LoadDataAsync()
{
var mappings = await _categorizer.GetAllMappingsAsync();
CategoryGroups = mappings
.GroupBy(m => m.Category)
.Select(g => new CategoryGroup
{
Category = g.Key,
Mappings = g.OrderByDescending(m => m.Priority).ToList(),
Count = g.Count()
})
.OrderBy(g => g.Category)
.ToList();
TotalMappings = mappings.Count;
TotalCategories = CategoryGroups.Count;
}
public class CategoryGroup
{
public required string Category { get; set; }
public required List<CategoryMapping> Mappings { get; set; }
public int Count { get; set; }
}
public class AddMappingModel
{
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
public string Category { get; set; } = "";
[StringLength(100)]
public string? Merchant { get; set; }
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
public int Priority { get; set; } = 0;
}
public class UpdateMappingModel
{
[Required]
public int Id { get; set; }
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
public string Category { get; set; } = "";
[StringLength(100)]
public string? Merchant { get; set; }
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
public int Priority { get; set; } = 0;
}
public class CategoryMappingExport
{
public string Category { get; set; } = "";
public string Pattern { get; set; } = "";
public string? Merchant { get; set; }
public int Priority { get; set; } = 0;
}
}
}