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 MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
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!;
@@ -27,67 +30,35 @@ namespace MoneyMap.Pages
public async Task<IActionResult> OnGetAsync(int id)
{
var account = await _db.Accounts.FindAsync(id);
if (account == null)
var accountDetails = await _accountService.GetAccountDetailsAsync(id);
if (accountDetails == null)
return NotFound();
Account = account;
// Get cards linked to this account
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);
Account = accountDetails.Account;
Cards = accountDetails.Cards;
TransactionCount = accountDetails.TransactionCount;
return Page();
}
public async Task<IActionResult> OnPostDeleteCardAsync(int cardId)
{
var card = await _db.Cards.FindAsync(cardId);
if (card == null)
// Get card to retrieve account ID before deletion
var card = await _cardService.GetCardByIdAsync(cardId);
var accountId = card?.AccountId;
var result = await _cardService.DeleteCardAsync(cardId);
if (result.Success)
{
ErrorMessage = "Card not found.";
return RedirectToPage(new { id = card?.AccountId });
SuccessMessage = result.Message;
}
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 });
}
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 MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
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();
@@ -22,14 +23,9 @@ namespace MoneyMap.Pages
public async Task OnGetAsync()
{
var accounts = await _db.Accounts
.Include(a => a.Transactions)
.OrderBy(a => a.Owner)
.ThenBy(a => a.Institution)
.ThenBy(a => a.Last4)
.ToListAsync();
var accountsWithStats = await _accountService.GetAllAccountsWithStatsAsync();
Accounts = accounts.Select(a => new AccountRow
Accounts = accountsWithStats.Select(a => new AccountRow
{
Id = a.Id,
Institution = a.Institution,
@@ -37,30 +33,25 @@ namespace MoneyMap.Pages
Last4 = a.Last4,
Owner = a.Owner,
Nickname = a.Nickname,
TransactionCount = a.Transactions.Count
TransactionCount = a.TransactionCount
}).ToList();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var account = await _db.Accounts
.Include(a => a.Transactions)
.FirstOrDefaultAsync(a => a.Id == id);
var result = await _accountService.DeleteAccountAsync(id);
if (account == null)
return NotFound();
if (account.Transactions.Any())
if (result.Success)
{
ModelState.AddModelError(string.Empty, "Cannot delete account with existing transactions.");
SuccessMessage = result.Message;
}
else
{
ModelState.AddModelError(string.Empty, result.Message);
await OnGetAsync();
return Page();
}
_db.Accounts.Remove(account);
await _db.SaveChangesAsync();
SuccessMessage = $"Deleted account {account.Institution} {account.Last4}";
return RedirectToPage();
}

View File

@@ -3,16 +3,17 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
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();
@@ -25,55 +26,23 @@ namespace MoneyMap.Pages
public async Task OnGetAsync()
{
var cards = await _db.Cards
.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;
Cards = await _cardService.GetAllCardsWithStatsAsync();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var card = await _db.Cards.FindAsync(id);
if (card == null)
var result = await _cardService.DeleteCardAsync(id);
if (result.Success)
{
ErrorMessage = "Card not found.";
return RedirectToPage();
SuccessMessage = result.Message;
}
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();
}
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 IReceiptManager _receiptManager;
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;
_receiptManager = receiptManager;
_receiptParser = receiptParser;
_referenceDataService = referenceDataService;
_merchantService = merchantService;
}
[BindProperty]
@@ -124,22 +128,9 @@ namespace MoneyMap.Pages
// Update merchant
if (!string.IsNullOrWhiteSpace(Transaction.MerchantName))
{
// Create new merchant if custom name was entered
var merchantName = Transaction.MerchantName.Trim();
var existingMerchant = await _db.Merchants
.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;
}
// Create or get merchant if custom name was entered
var merchantId = await _merchantService.GetOrCreateIdAsync(Transaction.MerchantName);
transaction.MerchantId = merchantId;
}
else if (Transaction.MerchantId.HasValue && Transaction.MerchantId.Value > 0)
{
@@ -252,19 +243,12 @@ namespace MoneyMap.Pages
private async Task LoadAvailableCategoriesAsync()
{
AvailableCategories = await _db.Transactions
.Select(t => t.Category ?? "")
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct()
.OrderBy(c => c)
.ToListAsync();
AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync();
}
private async Task LoadAvailableMerchantsAsync()
{
AvailableMerchants = await _db.Merchants
.OrderBy(m => m.Name)
.ToListAsync();
AvailableMerchants = await _referenceDataService.GetAvailableMerchantsAsync();
}
public class TransactionEditModel

View File

@@ -3,17 +3,18 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
using System.ComponentModel.DataAnnotations;
namespace MoneyMap.Pages
{
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();
@@ -40,9 +41,7 @@ namespace MoneyMap.Pages
}
// Check if merchant already exists
var existing = await _db.Merchants
.FirstOrDefaultAsync(m => m.Name == model.Name.Trim());
var existing = await _merchantService.FindByNameAsync(model.Name);
if (existing != null)
{
ErrorMessage = $"Merchant '{model.Name}' already exists.";
@@ -50,13 +49,7 @@ namespace MoneyMap.Pages
return Page();
}
var merchant = new Merchant
{
Name = model.Name.Trim()
};
_db.Merchants.Add(merchant);
await _db.SaveChangesAsync();
var merchant = await _merchantService.GetOrCreateAsync(model.Name);
SuccessMessage = $"Added merchant '{merchant.Name}'.";
return RedirectToPage();
@@ -71,68 +64,48 @@ namespace MoneyMap.Pages
return Page();
}
var merchant = await _db.Merchants.FindAsync(model.Id);
if (merchant == null)
var result = await _merchantService.UpdateMerchantAsync(model.Id, model.Name);
if (result.Success)
{
ErrorMessage = "Merchant not found.";
return RedirectToPage();
SuccessMessage = result.Message;
}
// 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)
else
{
ErrorMessage = $"Merchant '{model.Name}' already exists.";
ErrorMessage = result.Message;
await LoadDataAsync();
return Page();
}
merchant.Name = model.Name.Trim();
await _db.SaveChangesAsync();
SuccessMessage = "Merchant updated successfully.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteMerchantAsync(int id)
{
var merchant = await _db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.FirstOrDefaultAsync(m => m.Id == id);
var result = await _merchantService.DeleteMerchantAsync(id);
if (merchant == null)
if (result.Success)
{
ErrorMessage = "Merchant not found.";
return RedirectToPage();
SuccessMessage = result.Message;
}
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();
}
private async Task LoadDataAsync()
{
var merchants = await _db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.OrderBy(m => m.Name)
.ToListAsync();
var merchantsWithStats = await _merchantService.GetAllMerchantsWithStatsAsync();
Merchants = merchants.Select(m => new MerchantRow
Merchants = merchantsWithStats.Select(m => new MerchantRow
{
Id = m.Id,
Name = m.Name,
TransactionCount = m.Transactions.Count,
MappingCount = m.CategoryMappings.Count
TransactionCount = m.TransactionCount,
MappingCount = m.MappingCount
}).ToList();
TotalMerchants = Merchants.Count;

View File

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

View File

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