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);
}
}
}