Feature: add entity management and statistics services

Add four new services to extract business logic from PageModels:

- AccountService: Account retrieval, stats, and deletion with validation
- CardService: Card retrieval, stats, and deletion with validation
- ReferenceDataService: Centralized reference data for dropdowns (categories, merchants, cards, accounts)
- TransactionStatisticsService: Transaction statistics and aggregate calculations

All services follow the established service layer pattern with interfaces for DI and improved testability. Includes comprehensive DTOs for stats and validation results.

🤖 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:07:57 -04:00
parent 51a51f9b27
commit 77cab2595f
5 changed files with 541 additions and 2 deletions

View File

@@ -19,14 +19,24 @@ builder.Services.AddSession(options =>
options.IOTimeout = TimeSpan.FromMinutes(5); // Increase timeout for large data
});
// Add the new services here
// Core transaction and import services
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
builder.Services.AddScoped<ICardResolver, CardResolver>();
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
builder.Services.AddScoped<IMerchantService, MerchantService>();
builder.Services.AddScoped<ITransactionService, TransactionService>();
builder.Services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
// Entity management services
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<ICardService, CardService>();
builder.Services.AddScoped<IMerchantService, MerchantService>();
// Receipt services
builder.Services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
// Reference data services
builder.Services.AddScoped<IReferenceDataService, ReferenceDataService>();
// Dashboard services
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();

View File

@@ -0,0 +1,208 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services;
/// <summary>
/// Service for account management including retrieval, validation, and deletion.
/// </summary>
public interface IAccountService
{
/// <summary>
/// Gets an account by ID with optional related data.
/// </summary>
Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false);
/// <summary>
/// Gets all accounts with optional statistics.
/// </summary>
Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync();
/// <summary>
/// Gets account details with cards and transaction count.
/// </summary>
Task<AccountDetails?> GetAccountDetailsAsync(int id);
/// <summary>
/// Checks if an account can be deleted (no transactions exist).
/// </summary>
Task<DeleteValidationResult> CanDeleteAccountAsync(int id);
/// <summary>
/// Deletes an account if it has no associated transactions.
/// </summary>
Task<DeleteResult> DeleteAccountAsync(int id);
}
public class AccountService : IAccountService
{
private readonly MoneyMapContext _db;
public AccountService(MoneyMapContext db)
{
_db = db;
}
public async Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false)
{
var query = _db.Accounts.AsQueryable();
if (includeRelated)
{
query = query
.Include(a => a.Cards)
.Include(a => a.Transactions);
}
return await query.FirstOrDefaultAsync(a => a.Id == id);
}
public async Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync()
{
var accounts = await _db.Accounts
.Include(a => a.Transactions)
.OrderBy(a => a.Owner)
.ThenBy(a => a.Institution)
.ThenBy(a => a.Last4)
.ToListAsync();
return accounts.Select(a => new AccountWithStats
{
Id = a.Id,
Institution = a.Institution,
AccountType = a.AccountType,
Last4 = a.Last4,
Owner = a.Owner,
Nickname = a.Nickname,
TransactionCount = a.Transactions.Count
}).ToList();
}
public async Task<AccountDetails?> GetAccountDetailsAsync(int id)
{
var account = await _db.Accounts.FindAsync(id);
if (account == null)
return null;
// 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
});
}
// Get transaction count for this account
var accountTransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id);
return new AccountDetails
{
Account = account,
Cards = cardStats,
TransactionCount = accountTransactionCount
};
}
public async Task<DeleteValidationResult> CanDeleteAccountAsync(int id)
{
var account = await _db.Accounts
.Include(a => a.Transactions)
.FirstOrDefaultAsync(a => a.Id == id);
if (account == null)
return new DeleteValidationResult
{
CanDelete = false,
Reason = "Account not found."
};
if (account.Transactions.Any())
return new DeleteValidationResult
{
CanDelete = false,
Reason = $"Cannot delete account. It has {account.Transactions.Count} transaction(s) associated with it."
};
return new DeleteValidationResult { CanDelete = true };
}
public async Task<DeleteResult> DeleteAccountAsync(int id)
{
var validation = await CanDeleteAccountAsync(id);
if (!validation.CanDelete)
{
return new DeleteResult
{
Success = false,
Message = validation.Reason ?? "Cannot delete account."
};
}
var account = await _db.Accounts.FindAsync(id);
if (account == null)
{
return new DeleteResult
{
Success = false,
Message = "Account not found."
};
}
_db.Accounts.Remove(account);
await _db.SaveChangesAsync();
return new DeleteResult
{
Success = true,
Message = $"Deleted account {account.Institution} {account.Last4}"
};
}
}
// DTOs
public class AccountWithStats
{
public int Id { get; set; }
public string Institution { get; set; } = "";
public AccountType AccountType { get; set; }
public string Last4 { get; set; } = "";
public string Owner { get; set; } = "";
public string? Nickname { get; set; }
public int TransactionCount { get; set; }
}
public class AccountDetails
{
public Account Account { get; set; } = null!;
public List<CardWithStats> Cards { get; set; } = new();
public int TransactionCount { get; set; }
}
public class CardWithStats
{
public Card Card { get; set; } = null!;
public int TransactionCount { get; set; }
}
public class DeleteValidationResult
{
public bool CanDelete { get; set; }
public string? Reason { get; set; }
}
public class DeleteResult
{
public bool Success { get; set; }
public string Message { get; set; } = "";
}

View File

@@ -0,0 +1,132 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services;
/// <summary>
/// Service for card management including retrieval, validation, and deletion.
/// </summary>
public interface ICardService
{
/// <summary>
/// Gets a card by ID with optional related data.
/// </summary>
Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false);
/// <summary>
/// Gets all cards with transaction statistics.
/// </summary>
Task<List<CardWithStats>> GetAllCardsWithStatsAsync();
/// <summary>
/// Checks if a card can be deleted (no transactions exist).
/// </summary>
Task<DeleteValidationResult> CanDeleteCardAsync(int id);
/// <summary>
/// Deletes a card if it has no associated transactions.
/// </summary>
Task<DeleteResult> DeleteCardAsync(int id);
}
public class CardService : ICardService
{
private readonly MoneyMapContext _db;
public CardService(MoneyMapContext db)
{
_db = db;
}
public async Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false)
{
var query = _db.Cards.AsQueryable();
if (includeRelated)
{
query = query
.Include(c => c.Account)
.Include(c => c.Transactions);
}
return await query.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<List<CardWithStats>> GetAllCardsWithStatsAsync()
{
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
});
}
return cardStats;
}
public async Task<DeleteValidationResult> CanDeleteCardAsync(int id)
{
var card = await _db.Cards.FindAsync(id);
if (card == null)
return new DeleteValidationResult
{
CanDelete = false,
Reason = "Card not found."
};
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == id);
if (transactionCount > 0)
return new DeleteValidationResult
{
CanDelete = false,
Reason = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it."
};
return new DeleteValidationResult { CanDelete = true };
}
public async Task<DeleteResult> DeleteCardAsync(int id)
{
var validation = await CanDeleteCardAsync(id);
if (!validation.CanDelete)
{
return new DeleteResult
{
Success = false,
Message = validation.Reason ?? "Cannot delete card."
};
}
var card = await _db.Cards.FindAsync(id);
if (card == null)
{
return new DeleteResult
{
Success = false,
Message = "Card not found."
};
}
_db.Cards.Remove(card);
await _db.SaveChangesAsync();
return new DeleteResult
{
Success = true,
Message = "Card deleted successfully."
};
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services;
/// <summary>
/// Service for retrieving reference/lookup data used in dropdowns and filters.
/// </summary>
public interface IReferenceDataService
{
/// <summary>
/// Gets all distinct categories from transactions, sorted alphabetically.
/// </summary>
Task<List<string>> GetAvailableCategoriesAsync();
/// <summary>
/// Gets all merchants, sorted by name.
/// </summary>
Task<List<Merchant>> GetAvailableMerchantsAsync();
/// <summary>
/// Gets all cards with optional account information included.
/// </summary>
Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true);
/// <summary>
/// Gets all accounts, sorted by institution and last4.
/// </summary>
Task<List<Account>> GetAvailableAccountsAsync();
}
public class ReferenceDataService : IReferenceDataService
{
private readonly MoneyMapContext _db;
public ReferenceDataService(MoneyMapContext db)
{
_db = db;
}
public async Task<List<string>> GetAvailableCategoriesAsync()
{
return await _db.Transactions
.Select(t => t.Category ?? "")
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct()
.OrderBy(c => c)
.ToListAsync();
}
public async Task<List<Merchant>> GetAvailableMerchantsAsync()
{
return await _db.Merchants
.OrderBy(m => m.Name)
.ToListAsync();
}
public async Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true)
{
var query = _db.Cards.AsQueryable();
if (includeAccount)
{
query = query.Include(c => c.Account);
}
return await query
.OrderBy(c => c.Owner)
.ThenBy(c => c.Last4)
.ToListAsync();
}
public async Task<List<Account>> GetAvailableAccountsAsync()
{
return await _db.Accounts
.OrderBy(a => a.Institution)
.ThenBy(a => a.Last4)
.ToListAsync();
}
}

View File

@@ -0,0 +1,108 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services;
/// <summary>
/// Service for calculating transaction statistics and aggregates.
/// </summary>
public interface ITransactionStatisticsService
{
/// <summary>
/// Calculates statistics for a filtered set of transactions.
/// </summary>
Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query);
/// <summary>
/// Gets categorization statistics for the entire database.
/// </summary>
Task<CategorizationStats> GetCategorizationStatsAsync();
/// <summary>
/// Gets card statistics for a specific account.
/// </summary>
Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId);
}
public class TransactionStatisticsService : ITransactionStatisticsService
{
private readonly MoneyMapContext _db;
public TransactionStatisticsService(MoneyMapContext db)
{
_db = db;
}
public async Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query)
{
var allFilteredTransactions = await query.ToListAsync();
return 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)
};
}
public async Task<CategorizationStats> GetCategorizationStatsAsync()
{
var totalTransactions = await _db.Transactions.CountAsync();
var uncategorized = await _db.Transactions
.CountAsync(t => string.IsNullOrWhiteSpace(t.Category));
var categorized = totalTransactions - uncategorized;
return new CategorizationStats
{
TotalTransactions = totalTransactions,
Categorized = categorized,
Uncategorized = uncategorized
};
}
public async Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId)
{
var cards = await _db.Cards
.Where(c => c.AccountId == accountId)
.OrderBy(c => c.Owner)
.ThenBy(c => c.Last4)
.ToListAsync();
var cardStats = new List<CardStats>();
foreach (var card in cards)
{
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == card.Id);
cardStats.Add(new CardStats
{
Card = card,
TransactionCount = transactionCount
});
}
return cardStats;
}
}
// DTOs
public class TransactionStats
{
public int Count { get; set; }
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public decimal NetAmount { get; set; }
}
public class CategorizationStats
{
public int TotalTransactions { get; set; }
public int Categorized { get; set; }
public int Uncategorized { get; set; }
}
public class CardStats
{
public Card Card { get; set; } = null!;
public int TransactionCount { get; set; }
}