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:
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
MoneyMap/Services/TransactionFilters.cs
Normal file
37
MoneyMap/Services/TransactionFilters.cs
Normal 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 ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user