2026-01-03 10:29:03 -06:00

196 lines
7.1 KiB
C#

namespace BudgetApp.Application.Services;
using BudgetApp.Domain.Interfaces;
using BudgetApp.Domain.Models;
/// <summary>
/// Application service for ledger operations.
/// Handles recording expenses and income with budget allocations.
/// </summary>
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));
}
/// <summary>
/// Records an expense with budget allocations.
/// Validates that allocations sum to expense amount and updates budget balances.
/// </summary>
public async Task<LedgerEntry> RecordExpenseAsync(
DateTime date,
string description,
Money amount,
Dictionary<Guid, Money> 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;
}
/// <summary>
/// Records income with budget allocations.
/// Validates that allocations sum to income amount and updates budget balances.
/// </summary>
public async Task<LedgerEntry> RecordIncomeAsync(
DateTime date,
string description,
Money amount,
Dictionary<Guid, Money> 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;
}
/// <summary>
/// Gets all ledger entries.
/// </summary>
public async Task<IEnumerable<LedgerEntry>> GetLedgerEntriesAsync()
{
return await _ledgerRepository.GetAllEntriesAsync();
}
/// <summary>
/// Gets the ledger.
/// </summary>
public async Task<Ledger> GetLedgerAsync()
{
return await _ledgerRepository.GetOrCreateAsync();
}
/// <summary>
/// Gets the total balance across all budgets for a specific currency.
/// </summary>
public async Task<Money> 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);
}
/// <summary>
/// Validates that all budget IDs exist.
/// </summary>
private async Task ValidateBudgetsExistAsync(IEnumerable<Guid> 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)}");
}
}
/// <summary>
/// Validates that budget allocations sum to the entry amount (cross-model validation).
/// </summary>
private void ValidateBudgetAllocationsSum(Money entryAmount, Dictionary<Guid, Money> 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}).");
}
}
/// <summary>
/// Updates budget balances for an expense (removes money from budgets).
/// </summary>
private async Task UpdateBudgetBalancesForExpenseAsync(Dictionary<Guid, Money> 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);
}
}
/// <summary>
/// Updates budget balances for income (adds money to budgets).
/// </summary>
private async Task UpdateBudgetBalancesForIncomeAsync(Dictionary<Guid, Money> 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);
}
}
}