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>
278 lines
9.2 KiB
C#
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;
|
|
}
|
|
}
|
|
} |