196 lines
7.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
|