Add Balance Reconciliation report (detective control)
Phase 1 of the accounting audit remediation. A read-only diagnostic that surfaces drift in the denormalized balances without changing any posting: - Per account: stored Account.CurrentBalance vs the balance recomputed from source documents (the same LedgerService path RecalculateBalances uses). Drifted rows are highlighted; a difference means the cache is stale and a recalc would fix it. - AR subledger (sum of Customer.CurrentBalance) vs the AR control account, and AP subledger (sum of Vendor.CurrentBalance) vs the AP control account. FinancialReportService now takes ILedgerService to recompute. New GetBalanceReconciliationAsync + BalanceReconciliationDto, a /Reports/Reconciliation action, view, and a card on the reports landing. Build clean; 284 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,10 +19,65 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public class FinancialReportService : IFinancialReportService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILedgerService _ledger;
|
||||
|
||||
public FinancialReportService(ApplicationDbContext context)
|
||||
public FinancialReportService(ApplicationDbContext context, ILedgerService ledger)
|
||||
{
|
||||
_context = context;
|
||||
_ledger = ledger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId)
|
||||
{
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
|
||||
// Epoch start so LedgerService treats OpeningBalance as prior and all activity falls in-window —
|
||||
// identical to how AccountBalanceService.RecalculateAllAsync derives the authoritative balance.
|
||||
var epoch = new DateTime(2000, 1, 1);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var lines = new List<BalanceReconciliationLine>();
|
||||
decimal arControl = 0m, apControl = 0m;
|
||||
foreach (var a in accounts)
|
||||
{
|
||||
var ledger = await _ledger.GetAccountLedgerAsync(a.Id, epoch, now);
|
||||
var ledgerBalance = ledger?.ClosingBalance ?? 0m;
|
||||
lines.Add(new BalanceReconciliationLine
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
AccountType = a.AccountType,
|
||||
StoredBalance = a.CurrentBalance,
|
||||
LedgerBalance = ledgerBalance
|
||||
});
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable) arControl += ledgerBalance;
|
||||
if (a.AccountSubType == AccountSubType.AccountsPayable) apControl += ledgerBalance;
|
||||
}
|
||||
|
||||
var arSubledger = await _context.Customers
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
|
||||
.SumAsync(c => (decimal?)c.CurrentBalance) ?? 0m;
|
||||
var apSubledger = await _context.Vendors
|
||||
.Where(v => v.CompanyId == companyId && !v.IsDeleted)
|
||||
.SumAsync(v => (decimal?)v.CurrentBalance) ?? 0m;
|
||||
|
||||
return new BalanceReconciliationDto
|
||||
{
|
||||
AsOf = now,
|
||||
CompanyName = companyName,
|
||||
AccountLines = lines,
|
||||
ArControlBalance = arControl,
|
||||
ArSubledgerTotal = arSubledger,
|
||||
ApControlBalance = apControl,
|
||||
ApSubledgerTotal = apSubledger
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
Reference in New Issue
Block a user