diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2998dc6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/MoneyMap/Pages/Cards.cshtml b/MoneyMap/Pages/Cards.cshtml new file mode 100644 index 0000000..1f8b42d --- /dev/null +++ b/MoneyMap/Pages/Cards.cshtml @@ -0,0 +1,93 @@ +@page +@model MoneyMap.Pages.CardsModel +@{ + ViewData["Title"] = "Manage Cards"; +} + +
+

Manage Cards

+
+ Add New Card + Back to Dashboard +
+
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +@if (Model.Cards.Any()) +{ +
+
+ Your Cards (@Model.Cards.Count) +
+
+
+ + + + + + + + + + + + @foreach (var item in Model.Cards) + { + + + + + + + + } + +
OwnerIssuerLast 4TransactionsActions
@item.Card.Owner@item.Card.Issuer•••• @item.Card.Last4@item.TransactionCount +
+ + Edit + + @if (item.TransactionCount == 0) + { +
+ +
+ } + else + { + + } +
+
+
+
+
+} +else +{ +
+
No cards found
+

Add your first card to start tracking transactions.

+ Add New Card +
+} \ No newline at end of file diff --git a/MoneyMap/Pages/Cards.cshtml.cs b/MoneyMap/Pages/Cards.cshtml.cs new file mode 100644 index 0000000..a75ddb0 --- /dev/null +++ b/MoneyMap/Pages/Cards.cshtml.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Pages +{ + public class CardsModel : PageModel + { + private readonly MoneyMapContext _db; + + public CardsModel(MoneyMapContext db) + { + _db = db; + } + + public List Cards { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + var cards = await _db.Cards + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + + var cardStats = new List(); + + foreach (var card in cards) + { + var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == card.Id); + + cardStats.Add(new CardWithStats + { + Card = card, + TransactionCount = transactionCount + }); + } + + Cards = cardStats; + } + + public async Task OnPostDeleteAsync(int id) + { + var card = await _db.Cards.FindAsync(id); + if (card == null) + { + ErrorMessage = "Card not found."; + return RedirectToPage(); + } + + var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == card.Id); + if (transactionCount > 0) + { + ErrorMessage = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it."; + return RedirectToPage(); + } + + _db.Cards.Remove(card); + await _db.SaveChangesAsync(); + + SuccessMessage = "Card deleted successfully."; + return RedirectToPage(); + } + + public class CardWithStats + { + public Card Card { get; set; } = null!; + public int TransactionCount { get; set; } + } + } +} \ No newline at end of file diff --git a/MoneyMap/Pages/CategoryMappings.cshtml b/MoneyMap/Pages/CategoryMappings.cshtml index c5e47da..7df633c 100644 --- a/MoneyMap/Pages/CategoryMappings.cshtml +++ b/MoneyMap/Pages/CategoryMappings.cshtml @@ -19,6 +19,14 @@ } +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} +
@@ -57,6 +65,13 @@
+ +
+ +
+ @if (Model.CategoryGroups.Any()) {
@@ -75,13 +90,22 @@ @group.Count
- @foreach (var pattern in group.Patterns.Take(5)) + @foreach (var mapping in group.Mappings) { -
• @pattern
- } - @if (group.Patterns.Count > 5) - { -
+ @(group.Patterns.Count - 5) more...
+
+ + @if (mapping.Priority > 0) + { + P@(mapping.Priority) + } + @mapping.Pattern + +
+ +
+
}
@@ -107,5 +131,233 @@ else
  • Categories in the CSV file (if present) take precedence over auto-categorization
  • Transactions with no match will have an empty category and can be categorized manually
  • Special rule: Gas stations with purchases under $20 are categorized as "Convenience Store"
  • +
  • Priority: Higher priority patterns are checked first (useful for Banking, Income, etc.)
  • -
    \ No newline at end of file + + + + + + + + +@section Scripts { + +} \ No newline at end of file diff --git a/MoneyMap/Pages/CategoryMappings.cshtml.cs b/MoneyMap/Pages/CategoryMappings.cshtml.cs index 5f45f26..47747b4 100644 --- a/MoneyMap/Pages/CategoryMappings.cshtml.cs +++ b/MoneyMap/Pages/CategoryMappings.cshtml.cs @@ -1,18 +1,23 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; 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(ITransactionCategorizer categorizer) + public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer) { + _db = db; _categorizer = categorizer; } @@ -20,26 +25,21 @@ 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; } + [TempData] + public string? ErrorMessage { get; set; } + public async Task OnGetAsync() { - var mappings = await _categorizer.GetAllMappingsAsync(); - - CategoryGroups = mappings - .GroupBy(m => m.Category) - .Select(g => new CategoryGroup - { - Category = g.Key, - Patterns = g.Select(m => m.Pattern).ToList(), - Count = g.Count() - }) - .OrderBy(g => g.Category) - .ToList(); - - TotalMappings = mappings.Count; - TotalCategories = CategoryGroups.Count; + await LoadDataAsync(); } public async Task OnPostSeedDefaultsAsync() @@ -49,11 +49,120 @@ namespace MoneyMap.Pages return RedirectToPage(); } + public async Task OnPostAddMappingAsync() + { + // 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."; + await LoadDataAsync(); + return Page(); + } + + var mapping = new CategoryMapping + { + Category = NewMapping.Category.Trim(), + Pattern = NewMapping.Pattern.Trim(), + Priority = NewMapping.Priority + }; + + _db.CategoryMappings.Add(mapping); + await _db.SaveChangesAsync(); + + SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'."; + return RedirectToPage(); + } + + public async Task OnPostUpdateMappingAsync() + { + // 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."; + await LoadDataAsync(); + return Page(); + } + + var mapping = await _db.CategoryMappings.FindAsync(EditMapping.Id); + if (mapping == null) + { + ErrorMessage = "Mapping not found."; + return RedirectToPage(); + } + + mapping.Category = EditMapping.Category.Trim(); + mapping.Pattern = EditMapping.Pattern.Trim(); + mapping.Priority = EditMapping.Priority; + + await _db.SaveChangesAsync(); + + SuccessMessage = "Mapping updated successfully."; + return RedirectToPage(); + } + + public async Task 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(); + } + + 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 Patterns { get; set; } + public required List Mappings { get; set; } public int Count { get; set; } } + + public class MappingEditModel + { + public int Id { get; set; } + + [Required] + [StringLength(100)] + public string Category { get; set; } = ""; + + [Required] + [StringLength(200)] + public string Pattern { get; set; } = ""; + + public int Priority { get; set; } = 0; + } } } \ No newline at end of file diff --git a/MoneyMap/Pages/EditCard.cshtml b/MoneyMap/Pages/EditCard.cshtml new file mode 100644 index 0000000..cc12bfe --- /dev/null +++ b/MoneyMap/Pages/EditCard.cshtml @@ -0,0 +1,81 @@ +@page +@model MoneyMap.Pages.EditCardModel +@{ + ViewData["Title"] = Model.IsNewCard ? "Add Card" : "Edit Card"; +} + +
    +

    @(Model.IsNewCard ? "Add Card" : "Edit Card")

    + Back to Cards +
    + +
    +
    +
    +
    + Card Details +
    +
    +
    + + +
    + + + +
    Who owns this card?
    +
    + +
    + + + +
    Which bank or institution issued the card?
    +
    + +
    + + + +
    The last 4 digits of the card number
    +
    + +
    + + Cancel +
    +
    +
    +
    +
    + +
    +
    +
    + Tips +
    +
    +
      +
    • Owner: Use the cardholder's name for easy identification
    • +
    • Issuer: The bank or credit card company (e.g., Chase, Discover, Capital One)
    • +
    • Last 4: These digits help match transactions to the correct card
    • +
    • Cards with transactions cannot be deleted, only edited
    • +
    • Auto-imported cards will have "Unknown" as the owner - update them here
    • +
    +
    +
    + + @if (!Model.IsNewCard) + { +
    + Note: Updating card details will not affect existing transactions, but will change how the card is displayed going forward. +
    + } +
    +
    + +@section Scripts { + +} \ No newline at end of file diff --git a/MoneyMap/Pages/EditCard.cshtml.cs b/MoneyMap/Pages/EditCard.cshtml.cs new file mode 100644 index 0000000..c17d3e0 --- /dev/null +++ b/MoneyMap/Pages/EditCard.cshtml.cs @@ -0,0 +1,111 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Pages +{ + public class EditCardModel : PageModel + { + private readonly MoneyMapContext _db; + + public EditCardModel(MoneyMapContext db) + { + _db = db; + } + + [BindProperty] + public CardEditModel Card { get; set; } = new(); + + public bool IsNewCard { get; set; } + + [TempData] + public string? SuccessMessage { get; set; } + + public async Task OnGetAsync(int? id) + { + if (id.HasValue) + { + var card = await _db.Cards.FindAsync(id.Value); + if (card == null) + return NotFound(); + + Card = new CardEditModel + { + Id = card.Id, + Owner = card.Owner, + Issuer = card.Issuer, + Last4 = card.Last4 + }; + + IsNewCard = false; + } + else + { + IsNewCard = true; + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + IsNewCard = Card.Id == 0; + return Page(); + } + + if (Card.Id == 0) + { + // Create new card + var card = new Card + { + Owner = Card.Owner.Trim(), + Issuer = Card.Issuer.Trim(), + Last4 = Card.Last4.Trim() + }; + + _db.Cards.Add(card); + SuccessMessage = "Card added successfully!"; + } + else + { + // Update existing card + var card = await _db.Cards.FindAsync(Card.Id); + if (card == null) + return NotFound(); + + card.Owner = Card.Owner.Trim(); + card.Issuer = Card.Issuer.Trim(); + card.Last4 = Card.Last4.Trim(); + + SuccessMessage = "Card updated successfully!"; + } + + await _db.SaveChangesAsync(); + return RedirectToPage("/Cards"); + } + + public class CardEditModel + { + public int Id { get; set; } + + [Required(ErrorMessage = "Owner is required")] + [StringLength(100)] + public string Owner { get; set; } = ""; + + [Required(ErrorMessage = "Issuer is required")] + [StringLength(100)] + public string Issuer { get; set; } = ""; + + [Required(ErrorMessage = "Last 4 digits are required")] + [StringLength(4, MinimumLength = 4, ErrorMessage = "Last 4 must be exactly 4 digits")] + [RegularExpression(@"^\d{4}$", ErrorMessage = "Last 4 must be 4 digits")] + public string Last4 { get; set; } = ""; + } + } +} \ No newline at end of file diff --git a/MoneyMap/Pages/Privacy.cshtml b/MoneyMap/Pages/Privacy.cshtml new file mode 100644 index 0000000..46ba966 --- /dev/null +++ b/MoneyMap/Pages/Privacy.cshtml @@ -0,0 +1,8 @@ +@page +@model PrivacyModel +@{ + ViewData["Title"] = "Privacy Policy"; +} +

    @ViewData["Title"]

    + +

    Use this page to detail your site's privacy policy.

    diff --git a/MoneyMap/Pages/Privacy.cshtml.cs b/MoneyMap/Pages/Privacy.cshtml.cs new file mode 100644 index 0000000..e3619a0 --- /dev/null +++ b/MoneyMap/Pages/Privacy.cshtml.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace MoneyMap.Pages +{ + public class PrivacyModel : PageModel + { + private readonly ILogger _logger; + + public PrivacyModel(ILogger logger) + { + _logger = logger; + } + + public void OnGet() + { + } + } + +} diff --git a/MoneyMap/Pages/Shared/_Layout.cshtml b/MoneyMap/Pages/Shared/_Layout.cshtml index 5adcfdd..ab36d27 100644 --- a/MoneyMap/Pages/Shared/_Layout.cshtml +++ b/MoneyMap/Pages/Shared/_Layout.cshtml @@ -25,6 +25,9 @@ + @@ -52,7 +55,7 @@ - + @await RenderSectionAsync("Scripts", required: false) diff --git a/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml b/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml index 5a16d80..861d681 100644 --- a/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml +++ b/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml @@ -1,2 +1,2 @@ - - + + diff --git a/MoneyMap/Services/TransactionCategorizer.cs b/MoneyMap/Services/TransactionCategorizer.cs index 9a6ee4e..bf37f06 100644 --- a/MoneyMap/Services/TransactionCategorizer.cs +++ b/MoneyMap/Services/TransactionCategorizer.cs @@ -101,7 +101,7 @@ namespace MoneyMap.Services "AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY", "SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM", "NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM", - "JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS"); + "JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM"); // Walmart AddMappings("Walmart Online", mappings, "WALMART.COM"); @@ -181,8 +181,8 @@ namespace MoneyMap.Services AddMappings("Insurance", mappings, "BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS"); - // Credit Card Payment - AddMappings("Credit Card Payment", mappings, + // Credit Card Payment (high priority to catch before Banking) + AddMappings("Credit Card Payment", mappings, 200, "PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU"); // Ice Cream @@ -229,4 +229,12 @@ namespace MoneyMap.Services } } } + + // ===== Database Migration ===== + // Add this to your DbContext: + // public DbSet CategoryMappings { get; set; } + // + // Then create a migration: + // dotnet ef migrations add AddCategoryMappings + // dotnet ef database update } \ No newline at end of file