diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index e0ddf28..bf18dfb 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Entity management services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Receipt services builder.Services.AddScoped(); +// Reference data services +builder.Services.AddScoped(); + // Dashboard services builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/MoneyMap/Services/AccountService.cs b/MoneyMap/Services/AccountService.cs new file mode 100644 index 0000000..98147b8 --- /dev/null +++ b/MoneyMap/Services/AccountService.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for account management including retrieval, validation, and deletion. +/// +public interface IAccountService +{ + /// + /// Gets an account by ID with optional related data. + /// + Task GetAccountByIdAsync(int id, bool includeRelated = false); + + /// + /// Gets all accounts with optional statistics. + /// + Task> GetAllAccountsWithStatsAsync(); + + /// + /// Gets account details with cards and transaction count. + /// + Task GetAccountDetailsAsync(int id); + + /// + /// Checks if an account can be deleted (no transactions exist). + /// + Task CanDeleteAccountAsync(int id); + + /// + /// Deletes an account if it has no associated transactions. + /// + Task DeleteAccountAsync(int id); +} + +public class AccountService : IAccountService +{ + private readonly MoneyMapContext _db; + + public AccountService(MoneyMapContext db) + { + _db = db; + } + + public async Task 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> 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 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(); + 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 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 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 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; } = ""; +} diff --git a/MoneyMap/Services/CardService.cs b/MoneyMap/Services/CardService.cs new file mode 100644 index 0000000..9ef6195 --- /dev/null +++ b/MoneyMap/Services/CardService.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for card management including retrieval, validation, and deletion. +/// +public interface ICardService +{ + /// + /// Gets a card by ID with optional related data. + /// + Task GetCardByIdAsync(int id, bool includeRelated = false); + + /// + /// Gets all cards with transaction statistics. + /// + Task> GetAllCardsWithStatsAsync(); + + /// + /// Checks if a card can be deleted (no transactions exist). + /// + Task CanDeleteCardAsync(int id); + + /// + /// Deletes a card if it has no associated transactions. + /// + Task DeleteCardAsync(int id); +} + +public class CardService : ICardService +{ + private readonly MoneyMapContext _db; + + public CardService(MoneyMapContext db) + { + _db = db; + } + + public async Task 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> GetAllCardsWithStatsAsync() + { + var cards = await _db.Cards + .Include(c => c.Account) + .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 + }); + } + + return cardStats; + } + + public async Task 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 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." + }; + } +} diff --git a/MoneyMap/Services/ReferenceDataService.cs b/MoneyMap/Services/ReferenceDataService.cs new file mode 100644 index 0000000..d85f7ae --- /dev/null +++ b/MoneyMap/Services/ReferenceDataService.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for retrieving reference/lookup data used in dropdowns and filters. +/// +public interface IReferenceDataService +{ + /// + /// Gets all distinct categories from transactions, sorted alphabetically. + /// + Task> GetAvailableCategoriesAsync(); + + /// + /// Gets all merchants, sorted by name. + /// + Task> GetAvailableMerchantsAsync(); + + /// + /// Gets all cards with optional account information included. + /// + Task> GetAvailableCardsAsync(bool includeAccount = true); + + /// + /// Gets all accounts, sorted by institution and last4. + /// + Task> GetAvailableAccountsAsync(); +} + +public class ReferenceDataService : IReferenceDataService +{ + private readonly MoneyMapContext _db; + + public ReferenceDataService(MoneyMapContext db) + { + _db = db; + } + + public async Task> GetAvailableCategoriesAsync() + { + return await _db.Transactions + .Select(t => t.Category ?? "") + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + } + + public async Task> GetAvailableMerchantsAsync() + { + return await _db.Merchants + .OrderBy(m => m.Name) + .ToListAsync(); + } + + public async Task> 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> GetAvailableAccountsAsync() + { + return await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + } +} diff --git a/MoneyMap/Services/TransactionStatisticsService.cs b/MoneyMap/Services/TransactionStatisticsService.cs new file mode 100644 index 0000000..aebe0bf --- /dev/null +++ b/MoneyMap/Services/TransactionStatisticsService.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for calculating transaction statistics and aggregates. +/// +public interface ITransactionStatisticsService +{ + /// + /// Calculates statistics for a filtered set of transactions. + /// + Task CalculateStatsAsync(IQueryable query); + + /// + /// Gets categorization statistics for the entire database. + /// + Task GetCategorizationStatsAsync(); + + /// + /// Gets card statistics for a specific account. + /// + Task> GetCardStatsForAccountAsync(int accountId); +} + +public class TransactionStatisticsService : ITransactionStatisticsService +{ + private readonly MoneyMapContext _db; + + public TransactionStatisticsService(MoneyMapContext db) + { + _db = db; + } + + public async Task CalculateStatsAsync(IQueryable 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 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> 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(); + 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; } +}