Fix double-counting issue and refactor category mapping forms.

Transaction Filtering:
- Add TransactionFilters helper class to exclude transfer categories from spending reports
- Exclude "Credit Card Payment" and "Banking" categories from dashboard top spending
- Add ExcludeTransfers() extension method for reusable filtering
- Update dashboard header to indicate transfers are excluded

Category Mappings Refactor:
- Split form models into separate AddMappingModel and UpdateMappingModel
- Remove [BindProperty] attributes and use parameter binding instead
- Eliminate cross-validation issues between add/edit forms
- Simplify validation logic by removing manual ModelState cleanup

This fixes the issue where credit card payments were counted as spending
even though they're just transfers between accounts, causing inflated
spending totals on the dashboard.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-09 18:42:40 -04:00
parent ff14aed65f
commit 227e9dd006
5 changed files with 84 additions and 49 deletions

View File

@@ -146,9 +146,9 @@ else
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label asp-for="NewMapping.Category" class="form-label">Category</label> <label for="addCategory" class="form-label">Category</label>
<div class="position-relative"> <div class="position-relative">
<input asp-for="NewMapping.Category" id="addCategory" class="form-control" placeholder="e.g., Online shopping" autocomplete="off" /> <input name="model.Category" id="addCategory" class="form-control" placeholder="e.g., Online shopping" autocomplete="off" />
<div id="addCategoryDropdown" class="dropdown-menu" style="width: 100%; max-height: 200px; overflow-y: auto;"> <div id="addCategoryDropdown" class="dropdown-menu" style="width: 100%; max-height: 200px; overflow-y: auto;">
@foreach (var group in Model.CategoryGroups) @foreach (var group in Model.CategoryGroups)
{ {
@@ -156,20 +156,17 @@ else
} }
</div> </div>
</div> </div>
<span asp-validation-for="NewMapping.Category" class="text-danger"></span>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label asp-for="NewMapping.Pattern" class="form-label">Pattern</label> <label for="addPattern" class="form-label">Pattern</label>
<input asp-for="NewMapping.Pattern" id="addPattern" class="form-control" placeholder="e.g., TARGET.COM" /> <input name="model.Pattern" id="addPattern" class="form-control" placeholder="e.g., TARGET.COM" />
<span asp-validation-for="NewMapping.Pattern" class="text-danger"></span>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label asp-for="NewMapping.Priority" class="form-label">Priority</label> <label for="addPriority" class="form-label">Priority</label>
<input asp-for="NewMapping.Priority" type="number" id="addPriority" class="form-control" value="0" /> <input name="model.Priority" type="number" id="addPriority" class="form-control" value="0" />
<div class="form-text">Higher priority = checked first (0 = normal, 100 = high, 200 = critical)</div> <div class="form-text">Higher priority = checked first (0 = normal, 100 = high, 200 = critical)</div>
<span asp-validation-for="NewMapping.Priority" class="text-danger"></span>
</div> </div>
<div class="form-text"> <div class="form-text">
@@ -195,12 +192,12 @@ else
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" asp-for="EditMapping.Id" id="editId" /> <input type="hidden" name="model.Id" id="editId" />
<div class="mb-3"> <div class="mb-3">
<label asp-for="EditMapping.Category" class="form-label">Category</label> <label for="editCategory" class="form-label">Category</label>
<div class="position-relative"> <div class="position-relative">
<input asp-for="EditMapping.Category" id="editCategory" class="form-control" autocomplete="off" /> <input name="model.Category" id="editCategory" class="form-control" autocomplete="off" />
<div id="editCategoryDropdown" class="dropdown-menu" style="width: 100%; max-height: 200px; overflow-y: auto;"> <div id="editCategoryDropdown" class="dropdown-menu" style="width: 100%; max-height: 200px; overflow-y: auto;">
@foreach (var group in Model.CategoryGroups) @foreach (var group in Model.CategoryGroups)
{ {
@@ -208,17 +205,16 @@ else
} }
</div> </div>
</div> </div>
<span asp-validation-for="EditMapping.Category" class="text-danger"></span>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label asp-for="EditMapping.Pattern" class="form-label">Pattern</label> <label for="editPattern" class="form-label">Pattern</label>
<input asp-for="EditMapping.Pattern" id="editPattern" class="form-control" /> <input name="model.Pattern" id="editPattern" class="form-control" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label asp-for="EditMapping.Priority" class="form-label">Priority</label> <label for="editPriority" class="form-label">Priority</label>
<input asp-for="EditMapping.Priority" type="number" id="editPriority" class="form-control" /> <input name="model.Priority" type="number" id="editPriority" class="form-control" />
<div class="form-text">Higher priority = checked first (0 = normal, 100 = high, 200 = critical)</div> <div class="form-text">Higher priority = checked first (0 = normal, 100 = high, 200 = critical)</div>
</div> </div>
</div> </div>
@@ -360,4 +356,4 @@ else
} }
}); });
</script> </script>
} }

View File

@@ -25,12 +25,6 @@ namespace MoneyMap.Pages
public int TotalMappings { get; set; } public int TotalMappings { get; set; }
public int TotalCategories { get; set; } public int TotalCategories { get; set; }
[BindProperty]
public MappingEditModel NewMapping { get; set; } = new();
[BindProperty]
public MappingEditModel EditMapping { get; set; } = new();
[TempData] [TempData]
public string? SuccessMessage { get; set; } public string? SuccessMessage { get; set; }
@@ -49,13 +43,8 @@ namespace MoneyMap.Pages
return RedirectToPage(); return RedirectToPage();
} }
public async Task<IActionResult> OnPostAddMappingAsync() public async Task<IActionResult> OnPostAddMappingAsync(AddMappingModel model)
{ {
// Remove validation errors for EditMapping since we're not using it in this handler
ModelState.Remove("EditMapping.Category");
ModelState.Remove("EditMapping.Pattern");
ModelState.Remove("EditMapping.Priority");
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
ErrorMessage = "Please fill in all required fields."; ErrorMessage = "Please fill in all required fields.";
@@ -65,9 +54,9 @@ namespace MoneyMap.Pages
var mapping = new CategoryMapping var mapping = new CategoryMapping
{ {
Category = NewMapping.Category.Trim(), Category = model.Category.Trim(),
Pattern = NewMapping.Pattern.Trim(), Pattern = model.Pattern.Trim(),
Priority = NewMapping.Priority Priority = model.Priority
}; };
_db.CategoryMappings.Add(mapping); _db.CategoryMappings.Add(mapping);
@@ -77,13 +66,8 @@ namespace MoneyMap.Pages
return RedirectToPage(); return RedirectToPage();
} }
public async Task<IActionResult> OnPostUpdateMappingAsync() public async Task<IActionResult> OnPostUpdateMappingAsync(UpdateMappingModel model)
{ {
// Remove validation errors for NewMapping since we're not using it in this handler
ModelState.Remove("NewMapping.Category");
ModelState.Remove("NewMapping.Pattern");
ModelState.Remove("NewMapping.Priority");
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
ErrorMessage = "Please fill in all required fields."; ErrorMessage = "Please fill in all required fields.";
@@ -91,16 +75,16 @@ namespace MoneyMap.Pages
return Page(); return Page();
} }
var mapping = await _db.CategoryMappings.FindAsync(EditMapping.Id); var mapping = await _db.CategoryMappings.FindAsync(model.Id);
if (mapping == null) if (mapping == null)
{ {
ErrorMessage = "Mapping not found."; ErrorMessage = "Mapping not found.";
return RedirectToPage(); return RedirectToPage();
} }
mapping.Category = EditMapping.Category.Trim(); mapping.Category = model.Category.Trim();
mapping.Pattern = EditMapping.Pattern.Trim(); mapping.Pattern = model.Pattern.Trim();
mapping.Priority = EditMapping.Priority; mapping.Priority = model.Priority;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -150,15 +134,29 @@ namespace MoneyMap.Pages
public int Count { get; set; } public int Count { get; set; }
} }
public class MappingEditModel public class AddMappingModel
{ {
public int Id { get; set; } [Required(ErrorMessage = "Category is required")]
[Required]
[StringLength(100)] [StringLength(100)]
public string Category { get; set; } = ""; public string Category { get; set; } = "";
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
public int Priority { get; set; } = 0;
}
public class UpdateMappingModel
{
[Required] [Required]
public int Id { get; set; }
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
public string Category { get; set; } = "";
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)] [StringLength(200)]
public string Pattern { get; set; } = ""; public string Pattern { get; set; } = "";

View File

@@ -61,7 +61,10 @@
@if (Model.TopCategories.Any()) @if (Model.TopCategories.Any())
{ {
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header">Top expense categories (last 90 days)</div> <div class="card-header">
Top expense categories (last 90 days)
<small class="text-muted">· excludes transfers</small>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm mb-0 table-hover"> <table class="table table-sm mb-0 table-hover">
<thead> <thead>

View File

@@ -178,6 +178,7 @@ namespace MoneyMap.Pages
return await _db.Transactions return await _db.Transactions
.Where(t => t.Date >= since && t.Amount < 0) .Where(t => t.Date >= since && t.Amount < 0)
.ExcludeTransfers() // Exclude credit card payments and transfers
.GroupBy(t => t.Category ?? "") .GroupBy(t => t.Category ?? "")
.Select(g => new IndexModel.TopCategoryRow .Select(g => new IndexModel.TopCategoryRow
{ {
@@ -236,7 +237,7 @@ namespace MoneyMap.Pages
if (string.IsNullOrEmpty(cardLast4)) if (string.IsNullOrEmpty(cardLast4))
return ""; return "";
return $"•••• {cardLast4}"; return $"<EFBFBD><EFBFBD><EFBFBD><EFBFBD> {cardLast4}";
} }
} }

View File

@@ -0,0 +1,37 @@
using System.Linq;
using MoneyMap.Models;
namespace MoneyMap.Services
{
/// <summary>
/// Helper class for filtering transactions in queries
/// </summary>
public static class TransactionFilters
{
/// <summary>
/// Categories that represent transfers between accounts, not actual spending.
/// These should be excluded from spending reports and analytics.
/// </summary>
public static readonly string[] TransferCategories = new[]
{
"Credit Card Payment",
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
};
/// <summary>
/// Filter to exclude transfer transactions from spending queries
/// </summary>
public static IQueryable<Transaction> ExcludeTransfers(this IQueryable<Transaction> query)
{
return query.Where(t => !TransferCategories.Contains(t.Category ?? ""));
}
/// <summary>
/// Check if a category represents a transfer (not actual spending)
/// </summary>
public static bool IsTransferCategory(string? category)
{
return TransferCategories.Contains(category ?? "");
}
}
}