Refactor: migrate PageModels to use new service layer

Refactor 7 PageModels to delegate business logic to services:

- AccountDetails: Use AccountService for account details retrieval
- Accounts: Use AccountService for listing and deletion
- Cards: Use CardService for listing and deletion
- EditTransaction: Use ReferenceDataService for dropdowns
- Merchants: Use MerchantService for CRUD operations
- Recategorize: Use ReferenceDataService and TransactionStatisticsService
- Transactions: Use ReferenceDataService and TransactionStatisticsService

Significantly simplifies PageModels (229 lines removed, 97 added) by extracting data access and business logic into testable services. Pages now focus solely on HTTP request/response handling.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-25 23:08:28 -04:00
parent c09a8c36a8
commit 04a7e92bc9
7 changed files with 101 additions and 233 deletions

View File

@@ -3,16 +3,19 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages namespace MoneyMap.Pages
{ {
public class AccountDetailsModel : PageModel public class AccountDetailsModel : PageModel
{ {
private readonly MoneyMapContext _db; private readonly IAccountService _accountService;
private readonly ICardService _cardService;
public AccountDetailsModel(MoneyMapContext db) public AccountDetailsModel(IAccountService accountService, ICardService cardService)
{ {
_db = db; _accountService = accountService;
_cardService = cardService;
} }
public Account Account { get; set; } = null!; public Account Account { get; set; } = null!;
@@ -27,67 +30,35 @@ namespace MoneyMap.Pages
public async Task<IActionResult> OnGetAsync(int id) public async Task<IActionResult> OnGetAsync(int id)
{ {
var account = await _db.Accounts.FindAsync(id); var accountDetails = await _accountService.GetAccountDetailsAsync(id);
if (account == null) if (accountDetails == null)
return NotFound(); return NotFound();
Account = account; Account = accountDetails.Account;
Cards = accountDetails.Cards;
// Get cards linked to this account TransactionCount = accountDetails.TransactionCount;
var cards = await _db.Cards
.Where(c => c.AccountId == id)
.OrderBy(c => c.Owner)
.ThenBy(c => c.Last4)
.ToListAsync();
var cardStats = new List<CardWithStats>();
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;
// Get transaction count for this account
TransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id);
return Page(); return Page();
} }
public async Task<IActionResult> OnPostDeleteCardAsync(int cardId) public async Task<IActionResult> OnPostDeleteCardAsync(int cardId)
{ {
var card = await _db.Cards.FindAsync(cardId); // Get card to retrieve account ID before deletion
if (card == null) var card = await _cardService.GetCardByIdAsync(cardId);
var accountId = card?.AccountId;
var result = await _cardService.DeleteCardAsync(cardId);
if (result.Success)
{ {
ErrorMessage = "Card not found."; SuccessMessage = result.Message;
return RedirectToPage(new { id = card?.AccountId }); }
else
{
ErrorMessage = result.Message;
} }
var accountId = card.AccountId;
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(new { id = accountId });
}
_db.Cards.Remove(card);
await _db.SaveChangesAsync();
SuccessMessage = "Card deleted successfully.";
return RedirectToPage(new { id = accountId }); return RedirectToPage(new { id = accountId });
} }
public class CardWithStats
{
public Card Card { get; set; } = null!;
public int TransactionCount { get; set; }
}
} }
} }

View File

@@ -3,16 +3,17 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages namespace MoneyMap.Pages
{ {
public class AccountsModel : PageModel public class AccountsModel : PageModel
{ {
private readonly MoneyMapContext _db; private readonly IAccountService _accountService;
public AccountsModel(MoneyMapContext db) public AccountsModel(IAccountService accountService)
{ {
_db = db; _accountService = accountService;
} }
public List<AccountRow> Accounts { get; set; } = new(); public List<AccountRow> Accounts { get; set; } = new();
@@ -22,14 +23,9 @@ namespace MoneyMap.Pages
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
var accounts = await _db.Accounts var accountsWithStats = await _accountService.GetAllAccountsWithStatsAsync();
.Include(a => a.Transactions)
.OrderBy(a => a.Owner)
.ThenBy(a => a.Institution)
.ThenBy(a => a.Last4)
.ToListAsync();
Accounts = accounts.Select(a => new AccountRow Accounts = accountsWithStats.Select(a => new AccountRow
{ {
Id = a.Id, Id = a.Id,
Institution = a.Institution, Institution = a.Institution,
@@ -37,30 +33,25 @@ namespace MoneyMap.Pages
Last4 = a.Last4, Last4 = a.Last4,
Owner = a.Owner, Owner = a.Owner,
Nickname = a.Nickname, Nickname = a.Nickname,
TransactionCount = a.Transactions.Count TransactionCount = a.TransactionCount
}).ToList(); }).ToList();
} }
public async Task<IActionResult> OnPostDeleteAsync(int id) public async Task<IActionResult> OnPostDeleteAsync(int id)
{ {
var account = await _db.Accounts var result = await _accountService.DeleteAccountAsync(id);
.Include(a => a.Transactions)
.FirstOrDefaultAsync(a => a.Id == id);
if (account == null) if (result.Success)
return NotFound();
if (account.Transactions.Any())
{ {
ModelState.AddModelError(string.Empty, "Cannot delete account with existing transactions."); SuccessMessage = result.Message;
}
else
{
ModelState.AddModelError(string.Empty, result.Message);
await OnGetAsync(); await OnGetAsync();
return Page(); return Page();
} }
_db.Accounts.Remove(account);
await _db.SaveChangesAsync();
SuccessMessage = $"Deleted account {account.Institution} {account.Last4}";
return RedirectToPage(); return RedirectToPage();
} }

View File

@@ -3,16 +3,17 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages namespace MoneyMap.Pages
{ {
public class CardsModel : PageModel public class CardsModel : PageModel
{ {
private readonly MoneyMapContext _db; private readonly ICardService _cardService;
public CardsModel(MoneyMapContext db) public CardsModel(ICardService cardService)
{ {
_db = db; _cardService = cardService;
} }
public List<CardWithStats> Cards { get; set; } = new(); public List<CardWithStats> Cards { get; set; } = new();
@@ -25,55 +26,23 @@ namespace MoneyMap.Pages
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
var cards = await _db.Cards Cards = await _cardService.GetAllCardsWithStatsAsync();
.Include(c => c.Account)
.OrderBy(c => c.Owner)
.ThenBy(c => c.Last4)
.ToListAsync();
var cardStats = new List<CardWithStats>();
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<IActionResult> OnPostDeleteAsync(int id) public async Task<IActionResult> OnPostDeleteAsync(int id)
{ {
var card = await _db.Cards.FindAsync(id); var result = await _cardService.DeleteCardAsync(id);
if (card == null)
if (result.Success)
{ {
ErrorMessage = "Card not found."; SuccessMessage = result.Message;
return RedirectToPage(); }
else
{
ErrorMessage = result.Message;
} }
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(); return RedirectToPage();
} }
public class CardWithStats
{
public Card Card { get; set; } = null!;
public int TransactionCount { get; set; }
}
} }
} }

View File

@@ -12,12 +12,16 @@ namespace MoneyMap.Pages
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager; private readonly IReceiptManager _receiptManager;
private readonly IReceiptParser _receiptParser; private readonly IReceiptParser _receiptParser;
private readonly IReferenceDataService _referenceDataService;
private readonly IMerchantService _merchantService;
public EditTransactionModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptParser receiptParser) public EditTransactionModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptParser receiptParser, IReferenceDataService referenceDataService, IMerchantService merchantService)
{ {
_db = db; _db = db;
_receiptManager = receiptManager; _receiptManager = receiptManager;
_receiptParser = receiptParser; _receiptParser = receiptParser;
_referenceDataService = referenceDataService;
_merchantService = merchantService;
} }
[BindProperty] [BindProperty]
@@ -124,22 +128,9 @@ namespace MoneyMap.Pages
// Update merchant // Update merchant
if (!string.IsNullOrWhiteSpace(Transaction.MerchantName)) if (!string.IsNullOrWhiteSpace(Transaction.MerchantName))
{ {
// Create new merchant if custom name was entered // Create or get merchant if custom name was entered
var merchantName = Transaction.MerchantName.Trim(); var merchantId = await _merchantService.GetOrCreateIdAsync(Transaction.MerchantName);
var existingMerchant = await _db.Merchants transaction.MerchantId = merchantId;
.FirstOrDefaultAsync(m => m.Name == merchantName);
if (existingMerchant != null)
{
transaction.MerchantId = existingMerchant.Id;
}
else
{
var newMerchant = new Merchant { Name = merchantName };
_db.Merchants.Add(newMerchant);
await _db.SaveChangesAsync();
transaction.MerchantId = newMerchant.Id;
}
} }
else if (Transaction.MerchantId.HasValue && Transaction.MerchantId.Value > 0) else if (Transaction.MerchantId.HasValue && Transaction.MerchantId.Value > 0)
{ {
@@ -252,19 +243,12 @@ namespace MoneyMap.Pages
private async Task LoadAvailableCategoriesAsync() private async Task LoadAvailableCategoriesAsync()
{ {
AvailableCategories = await _db.Transactions AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync();
.Select(t => t.Category ?? "")
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct()
.OrderBy(c => c)
.ToListAsync();
} }
private async Task LoadAvailableMerchantsAsync() private async Task LoadAvailableMerchantsAsync()
{ {
AvailableMerchants = await _db.Merchants AvailableMerchants = await _referenceDataService.GetAvailableMerchantsAsync();
.OrderBy(m => m.Name)
.ToListAsync();
} }
public class TransactionEditModel public class TransactionEditModel

View File

@@ -3,17 +3,18 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace MoneyMap.Pages namespace MoneyMap.Pages
{ {
public class MerchantsModel : PageModel public class MerchantsModel : PageModel
{ {
private readonly MoneyMapContext _db; private readonly IMerchantService _merchantService;
public MerchantsModel(MoneyMapContext db) public MerchantsModel(IMerchantService merchantService)
{ {
_db = db; _merchantService = merchantService;
} }
public List<MerchantRow> Merchants { get; set; } = new(); public List<MerchantRow> Merchants { get; set; } = new();
@@ -40,9 +41,7 @@ namespace MoneyMap.Pages
} }
// Check if merchant already exists // Check if merchant already exists
var existing = await _db.Merchants var existing = await _merchantService.FindByNameAsync(model.Name);
.FirstOrDefaultAsync(m => m.Name == model.Name.Trim());
if (existing != null) if (existing != null)
{ {
ErrorMessage = $"Merchant '{model.Name}' already exists."; ErrorMessage = $"Merchant '{model.Name}' already exists.";
@@ -50,13 +49,7 @@ namespace MoneyMap.Pages
return Page(); return Page();
} }
var merchant = new Merchant var merchant = await _merchantService.GetOrCreateAsync(model.Name);
{
Name = model.Name.Trim()
};
_db.Merchants.Add(merchant);
await _db.SaveChangesAsync();
SuccessMessage = $"Added merchant '{merchant.Name}'."; SuccessMessage = $"Added merchant '{merchant.Name}'.";
return RedirectToPage(); return RedirectToPage();
@@ -71,68 +64,48 @@ namespace MoneyMap.Pages
return Page(); return Page();
} }
var merchant = await _db.Merchants.FindAsync(model.Id); var result = await _merchantService.UpdateMerchantAsync(model.Id, model.Name);
if (merchant == null)
if (result.Success)
{ {
ErrorMessage = "Merchant not found."; SuccessMessage = result.Message;
return RedirectToPage();
} }
else
// Check if another merchant with the same name exists
var existing = await _db.Merchants
.FirstOrDefaultAsync(m => m.Name == model.Name.Trim() && m.Id != model.Id);
if (existing != null)
{ {
ErrorMessage = $"Merchant '{model.Name}' already exists."; ErrorMessage = result.Message;
await LoadDataAsync(); await LoadDataAsync();
return Page(); return Page();
} }
merchant.Name = model.Name.Trim();
await _db.SaveChangesAsync();
SuccessMessage = "Merchant updated successfully.";
return RedirectToPage(); return RedirectToPage();
} }
public async Task<IActionResult> OnPostDeleteMerchantAsync(int id) public async Task<IActionResult> OnPostDeleteMerchantAsync(int id)
{ {
var merchant = await _db.Merchants var result = await _merchantService.DeleteMerchantAsync(id);
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.FirstOrDefaultAsync(m => m.Id == id);
if (merchant == null) if (result.Success)
{ {
ErrorMessage = "Merchant not found."; SuccessMessage = result.Message;
return RedirectToPage(); }
else
{
ErrorMessage = result.Message;
} }
var transactionCount = merchant.Transactions.Count;
var mappingCount = merchant.CategoryMappings.Count;
_db.Merchants.Remove(merchant);
await _db.SaveChangesAsync();
SuccessMessage = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.";
return RedirectToPage(); return RedirectToPage();
} }
private async Task LoadDataAsync() private async Task LoadDataAsync()
{ {
var merchants = await _db.Merchants var merchantsWithStats = await _merchantService.GetAllMerchantsWithStatsAsync();
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.OrderBy(m => m.Name)
.ToListAsync();
Merchants = merchants.Select(m => new MerchantRow Merchants = merchantsWithStats.Select(m => new MerchantRow
{ {
Id = m.Id, Id = m.Id,
Name = m.Name, Name = m.Name,
TransactionCount = m.Transactions.Count, TransactionCount = m.TransactionCount,
MappingCount = m.CategoryMappings.Count MappingCount = m.MappingCount
}).ToList(); }).ToList();
TotalMerchants = Merchants.Count; TotalMerchants = Merchants.Count;

View File

@@ -10,11 +10,13 @@ namespace MoneyMap.Pages
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer; private readonly ITransactionCategorizer _categorizer;
private readonly ITransactionStatisticsService _statsService;
public RecategorizeModel(MoneyMapContext db, ITransactionCategorizer categorizer) public RecategorizeModel(MoneyMapContext db, ITransactionCategorizer categorizer, ITransactionStatisticsService statsService)
{ {
_db = db; _db = db;
_categorizer = categorizer; _categorizer = categorizer;
_statsService = statsService;
} }
public RecategorizeStats Stats { get; set; } = new(); public RecategorizeStats Stats { get; set; } = new();
@@ -47,16 +49,13 @@ namespace MoneyMap.Pages
private async Task LoadStatsAsync() private async Task LoadStatsAsync()
{ {
var totalTransactions = await _db.Transactions.CountAsync(); var categorizationStats = await _statsService.GetCategorizationStatsAsync();
var uncategorized = await _db.Transactions
.CountAsync(t => string.IsNullOrWhiteSpace(t.Category));
var categorized = totalTransactions - uncategorized;
Stats = new RecategorizeStats Stats = new RecategorizeStats
{ {
TotalTransactions = totalTransactions, TotalTransactions = categorizationStats.TotalTransactions,
Categorized = categorized, Categorized = categorizationStats.Categorized,
Uncategorized = uncategorized Uncategorized = categorizationStats.Uncategorized
}; };
} }

View File

@@ -3,16 +3,21 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages namespace MoneyMap.Pages
{ {
public class TransactionsModel : PageModel public class TransactionsModel : PageModel
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly ITransactionStatisticsService _statsService;
private readonly IReferenceDataService _referenceDataService;
public TransactionsModel(MoneyMapContext db) public TransactionsModel(MoneyMapContext db, ITransactionStatisticsService statsService, IReferenceDataService referenceDataService)
{ {
_db = db; _db = db;
_statsService = statsService;
_referenceDataService = referenceDataService;
} }
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)]
@@ -147,33 +152,17 @@ namespace MoneyMap.Pages
}).ToList(); }).ToList();
// Calculate stats for filtered results (all pages, not just current) // Calculate stats for filtered results (all pages, not just current)
var allFilteredTransactions = await query.ToListAsync(); Stats = await _statsService.CalculateStatsAsync(query);
Stats = new TransactionStats
{
Count = allFilteredTransactions.Count,
TotalDebits = allFilteredTransactions.Where(t => t.Amount < 0).Sum(t => t.Amount),
TotalCredits = allFilteredTransactions.Where(t => t.Amount > 0).Sum(t => t.Amount),
NetAmount = allFilteredTransactions.Sum(t => t.Amount)
};
// Get available categories for filter dropdown // Get available categories for filter dropdown
AvailableCategories = await _db.Transactions AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync();
.Select(t => t.Category ?? "")
.Distinct()
.OrderBy(c => c)
.ToListAsync();
// Get available merchants for filter dropdown // Get available merchants for filter dropdown
AvailableMerchants = await _db.Merchants var merchants = await _referenceDataService.GetAvailableMerchantsAsync();
.OrderBy(m => m.Name) AvailableMerchants = merchants.Select(m => m.Name).ToList();
.Select(m => m.Name)
.ToListAsync();
// Get available cards for filter dropdown // Get available cards for filter dropdown
AvailableCards = await _db.Cards AvailableCards = await _referenceDataService.GetAvailableCardsAsync(includeAccount: false);
.OrderBy(c => c.Owner)
.ThenBy(c => c.Last4)
.ToListAsync();
} }
public class TransactionRow public class TransactionRow
@@ -189,13 +178,5 @@ namespace MoneyMap.Pages
public string AccountLabel { get; set; } = ""; public string AccountLabel { get; set; } = "";
public int ReceiptCount { get; set; } public int ReceiptCount { get; set; }
} }
public class TransactionStats
{
public int Count { get; set; }
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public decimal NetAmount { get; set; }
}
} }
} }