bc9de38da3
AccountSubType.Cash was not included in IsNormalDebitBalance in both AccountBalanceService and LedgerService, causing Cash accounts to be treated as credit-normal. Payments deposited to a Cash account were debited in the wrong direction, producing a negative balance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
5.8 KiB
C#
137 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.Cash
|
|
or 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
|
|
};
|
|
}
|