Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/AccountBalanceService.cs
T
2026-04-23 21:38:24 -04:00

136 lines
5.8 KiB
C#

using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Atomic double-entry bookkeeping primitives used across InvoicesController, BillsController,
/// and ExpensesController to keep the chart-of-accounts <see cref="PowderCoating.Core.Entities.Account.CurrentBalance"/>
/// current after every financial transaction.
/// </summary>
public class AccountBalanceService : IAccountBalanceService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILedgerService _ledgerService;
private readonly ILogger<AccountBalanceService> _logger;
/// <summary>
/// Constructs the service with the required unit-of-work, ledger service for balance
/// recalculation, and logger for error reporting during batch recalculations.
/// </summary>
public AccountBalanceService(
IUnitOfWork unitOfWork,
ILedgerService ledgerService,
ILogger<AccountBalanceService> logger)
{
_unitOfWork = unitOfWork;
_ledgerService = ledgerService;
_logger = logger;
}
/// <summary>
/// Records a debit against the specified account and persists the updated balance.
/// For debit-normal accounts (Assets, Expenses, COGS) a debit increases the balance;
/// for credit-normal accounts (Liabilities, Equity, Revenue) it decreases it.
/// Silently no-ops when <paramref name="accountId"/> is null or <paramref name="amount"/> is zero
/// so callers do not need null-guards for optional account mappings.
/// </summary>
public async Task DebitAsync(int? accountId, decimal amount)
{
if (accountId == null || amount == 0) return;
var account = await _unitOfWork.Accounts.GetByIdAsync(accountId.Value);
if (account == null) return;
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
/// <summary>
/// Records a credit against the specified account and persists the updated balance.
/// For credit-normal accounts (Liabilities, Equity, Revenue) a credit increases the balance;
/// for debit-normal accounts (Assets, Expenses, COGS) it decreases it.
/// Silently no-ops when <paramref name="accountId"/> is null or <paramref name="amount"/> is zero.
/// </summary>
public async Task CreditAsync(int? accountId, decimal amount)
{
if (accountId == null || amount == 0) return;
var account = await _unitOfWork.Accounts.GetByIdAsync(accountId.Value);
if (account == null) return;
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
/// <summary>
/// Recomputes and persists <c>CurrentBalance</c> for every active account in the company
/// by replaying all ledger activity via <see cref="LedgerService.GetAccountLedgerAsync"/>.
/// Uses the epoch date 2000-01-01 as the range start so that LedgerService treats
/// <c>OpeningBalance</c> as a prior balance and all real transactions fall within the window.
/// Errors on individual accounts are logged and swallowed so a single bad account does not
/// abort the full recalculation.
/// </summary>
public async Task RecalculateAllAsync(int companyId)
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
// Use a date range that covers all possible transactions.
// LedgerService computes opening balance separately, so starting from 2000-01-01
// means priorBalance = OpeningBalance + 0 (no transactions before that date),
// and all real transactions fall inside the period window.
var from = new DateTime(2000, 1, 1);
var to = DateTime.UtcNow;
foreach (var account in accounts)
{
try
{
var ledger = await _ledgerService.GetAccountLedgerAsync(account.Id, from, to);
if (ledger != null)
{
account.CurrentBalance = ledger.ClosingBalance;
await _unitOfWork.Accounts.UpdateAsync(account);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recalculating balance for account {AccountId} ({AccountNumber})",
account.Id, account.AccountNumber);
}
}
await _unitOfWork.CompleteAsync();
}
/// <summary>
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
/// decide the direction of the balance adjustment.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
_ => false
};
}