namespace BudgetApp.Application.Services; using BudgetApp.Domain.Interfaces; using BudgetApp.Domain.Models; /// /// Application service for ledger operations. /// Handles recording expenses and income with budget allocations. /// public class LedgerService { private readonly ILedgerRepository _ledgerRepository; private readonly IBudgetRepository _budgetRepository; public LedgerService(ILedgerRepository ledgerRepository, IBudgetRepository budgetRepository) { _ledgerRepository = ledgerRepository ?? throw new ArgumentNullException(nameof(ledgerRepository)); _budgetRepository = budgetRepository ?? throw new ArgumentNullException(nameof(budgetRepository)); } /// /// Records an expense with budget allocations. /// Validates that allocations sum to expense amount and updates budget balances. /// public async Task RecordExpenseAsync( DateTime date, string description, Money amount, Dictionary budgetAllocations) { if (string.IsNullOrWhiteSpace(description)) throw new ArgumentException("Description cannot be null or empty.", nameof(description)); if (amount == null) throw new ArgumentNullException(nameof(amount)); if (budgetAllocations == null || budgetAllocations.Count == 0) throw new ArgumentException("Budget allocations cannot be null or empty.", nameof(budgetAllocations)); // Validate that all budgets exist await ValidateBudgetsExistAsync(budgetAllocations.Keys); // Validate that allocations sum to expense amount (cross-model validation) ValidateBudgetAllocationsSum(amount, budgetAllocations); // Create and validate ledger entry var entry = new LedgerEntry(date, description, amount, EntryType.Expense, budgetAllocations); entry.Validate(); // Domain validation // Update budget balances await UpdateBudgetBalancesForExpenseAsync(budgetAllocations); // Save entry to ledger var ledger = await _ledgerRepository.GetOrCreateAsync(); ledger.AddEntry(entry); await _ledgerRepository.SaveAsync(ledger); await _ledgerRepository.SaveEntryAsync(entry); return entry; } /// /// Records income with budget allocations. /// Validates that allocations sum to income amount and updates budget balances. /// public async Task RecordIncomeAsync( DateTime date, string description, Money amount, Dictionary budgetAllocations) { if (string.IsNullOrWhiteSpace(description)) throw new ArgumentException("Description cannot be null or empty.", nameof(description)); if (amount == null) throw new ArgumentNullException(nameof(amount)); if (budgetAllocations == null || budgetAllocations.Count == 0) throw new ArgumentException("Budget allocations cannot be null or empty.", nameof(budgetAllocations)); // Validate that all budgets exist await ValidateBudgetsExistAsync(budgetAllocations.Keys); // Validate that allocations sum to income amount (cross-model validation) ValidateBudgetAllocationsSum(amount, budgetAllocations); // Create and validate ledger entry var entry = new LedgerEntry(date, description, amount, EntryType.Income, budgetAllocations); entry.Validate(); // Domain validation // Update budget balances await UpdateBudgetBalancesForIncomeAsync(budgetAllocations); // Save entry to ledger var ledger = await _ledgerRepository.GetOrCreateAsync(); ledger.AddEntry(entry); await _ledgerRepository.SaveAsync(ledger); await _ledgerRepository.SaveEntryAsync(entry); return entry; } /// /// Gets all ledger entries. /// public async Task> GetLedgerEntriesAsync() { return await _ledgerRepository.GetAllEntriesAsync(); } /// /// Gets the ledger. /// public async Task GetLedgerAsync() { return await _ledgerRepository.GetOrCreateAsync(); } /// /// Gets the total balance across all budgets for a specific currency. /// public async Task GetTotalBudgetBalanceAsync(string currency) { var budgets = await _budgetRepository.GetAllAsync(); var total = budgets .Where(b => b.Balance.Currency == currency) .Sum(b => b.Balance.Amount); return new Money(total, currency); } /// /// Validates that all budget IDs exist. /// private async Task ValidateBudgetsExistAsync(IEnumerable budgetIds) { var allBudgets = await _budgetRepository.GetAllAsync(); var existingIds = allBudgets.Select(b => b.Id).ToHashSet(); var missingIds = budgetIds.Where(id => !existingIds.Contains(id)).ToList(); if (missingIds.Any()) { throw new InvalidOperationException( $"The following budget IDs do not exist: {string.Join(", ", missingIds)}"); } } /// /// Validates that budget allocations sum to the entry amount (cross-model validation). /// private void ValidateBudgetAllocationsSum(Money entryAmount, Dictionary budgetAllocations) { var totalAllocated = budgetAllocations.Values .Aggregate(new Money(0, entryAmount.Currency), (sum, money) => sum.Add(money)); if (totalAllocated.Amount != entryAmount.Amount) { throw new InvalidOperationException( $"Entry amount ({entryAmount.Amount}) does not match sum of budget allocations ({totalAllocated.Amount})."); } } /// /// Updates budget balances for an expense (removes money from budgets). /// private async Task UpdateBudgetBalancesForExpenseAsync(Dictionary budgetAllocations) { foreach (var allocation in budgetAllocations) { var budget = await _budgetRepository.GetByIdAsync(allocation.Key); if (budget == null) throw new InvalidOperationException($"Budget with ID {allocation.Key} not found."); budget.RemoveMoney(allocation.Value); await _budgetRepository.SaveAsync(budget); } } /// /// Updates budget balances for income (adds money to budgets). /// private async Task UpdateBudgetBalancesForIncomeAsync(Dictionary budgetAllocations) { foreach (var allocation in budgetAllocations) { var budget = await _budgetRepository.GetByIdAsync(allocation.Key); if (budget == null) throw new InvalidOperationException($"Budget with ID {allocation.Key} not found."); budget.AddMoney(allocation.Value); await _budgetRepository.SaveAsync(budget); } } }