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:
2026-06-19 09:05:16 -04:00
parent 9ce361235f
commit c2cd19e475
6 changed files with 232 additions and 1 deletions
@@ -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/>