Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/AccountBalanceService.cs
T
spouliot 379b0de885 Refactor: centralize accounting helpers, status constants, and query deduplication
- AccountingDropdownHelper: wired into BillsController and ExpensesController,
  replacing 35-40 lines of duplicated DB queries per controller
- AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all
  magic status strings across Jobs, Quotes, Appointments, OvenScheduler,
  AiQuickQuote, QuoteApproval, and AccountingDropdownHelper
- AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure
  helper; removed duplicate private method from AccountBalanceService and
  LedgerService (~50 lines deleted)
- AccountDataExportController: extracted 9 Fetch*Async methods (superset of
  includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each
  entity is queried once regardless of whether XLSX or CSV format is requested
- BillsController.Create and ExpensesController.Create wrapped in
  ExecuteInTransactionAsync; blob uploads moved after commit to keep
  financial data atomic and prevent orphaned blobs from rolling back
- Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table
  GetAllAsync to prefix-filtered FindAsync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:42:39 -04:00

113 lines
4.9 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 += AccountingRules.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 += AccountingRules.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();
}
}