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 class="modal-body">
<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">
<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;">
@foreach (var group in Model.CategoryGroups)
{
@@ -156,20 +156,17 @@ else
}
</div>
</div>
<span asp-validation-for="NewMapping.Category" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="NewMapping.Pattern" class="form-label">Pattern</label>
<input asp-for="NewMapping.Pattern" id="addPattern" class="form-control" placeholder="e.g., TARGET.COM" />
<span asp-validation-for="NewMapping.Pattern" class="text-danger"></span>
<label for="addPattern" class="form-label">Pattern</label>
<input name="model.Pattern" id="addPattern" class="form-control" placeholder="e.g., TARGET.COM" />
</div>
<div class="mb-3">
<label asp-for="NewMapping.Priority" class="form-label">Priority</label>
<input asp-for="NewMapping.Priority" type="number" id="addPriority" class="form-control" value="0" />
<label for="addPriority" class="form-label">Priority</label>
<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>
<span asp-validation-for="NewMapping.Priority" class="text-danger"></span>
</div>
<div class="form-text">
@@ -195,12 +192,12 @@ else
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<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">
<label asp-for="EditMapping.Category" class="form-label">Category</label>
<label for="editCategory" class="form-label">Category</label>
<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;">
@foreach (var group in Model.CategoryGroups)
{
@@ -208,17 +205,16 @@ else
}
</div>
</div>
<span asp-validation-for="EditMapping.Category" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="EditMapping.Pattern" class="form-label">Pattern</label>
<input asp-for="EditMapping.Pattern" id="editPattern" class="form-control" />
<label for="editPattern" class="form-label">Pattern</label>
<input name="model.Pattern" id="editPattern" class="form-control" />
</div>
<div class="mb-3">
<label asp-for="EditMapping.Priority" class="form-label">Priority</label>
<input asp-for="EditMapping.Priority" type="number" id="editPriority" class="form-control" />
<label for="editPriority" class="form-label">Priority</label>
<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>
</div>
@@ -360,4 +356,4 @@ else
}
});
</script>
}
}

View File

@@ -25,12 +25,6 @@ namespace MoneyMap.Pages
public int TotalMappings { get; set; }
public int TotalCategories { get; set; }
[BindProperty]
public MappingEditModel NewMapping { get; set; } = new();
[BindProperty]
public MappingEditModel EditMapping { get; set; } = new();
[TempData]
public string? SuccessMessage { get; set; }
@@ -49,13 +43,8 @@ namespace MoneyMap.Pages
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)
{
ErrorMessage = "Please fill in all required fields.";
@@ -65,9 +54,9 @@ namespace MoneyMap.Pages
var mapping = new CategoryMapping
{
Category = NewMapping.Category.Trim(),
Pattern = NewMapping.Pattern.Trim(),
Priority = NewMapping.Priority
Category = model.Category.Trim(),
Pattern = model.Pattern.Trim(),
Priority = model.Priority
};
_db.CategoryMappings.Add(mapping);
@@ -77,13 +66,8 @@ namespace MoneyMap.Pages
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)
{
ErrorMessage = "Please fill in all required fields.";
@@ -91,16 +75,16 @@ namespace MoneyMap.Pages
return Page();
}
var mapping = await _db.CategoryMappings.FindAsync(EditMapping.Id);
var mapping = await _db.CategoryMappings.FindAsync(model.Id);
if (mapping == null)
{
ErrorMessage = "Mapping not found.";
return RedirectToPage();
}
mapping.Category = EditMapping.Category.Trim();
mapping.Pattern = EditMapping.Pattern.Trim();
mapping.Priority = EditMapping.Priority;
mapping.Category = model.Category.Trim();
mapping.Pattern = model.Pattern.Trim();
mapping.Priority = model.Priority;
await _db.SaveChangesAsync();
@@ -150,15 +134,29 @@ namespace MoneyMap.Pages
public int Count { get; set; }
}
public class MappingEditModel
public class AddMappingModel
{
public int Id { get; set; }
[Required]
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
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]
public int Id { get; set; }
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
public string Category { get; set; } = "";
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";

View File

@@ -61,7 +61,10 @@
@if (Model.TopCategories.Any())
{
<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">
<table class="table table-sm mb-0 table-hover">
<thead>

View File

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