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
@@ -154,6 +154,49 @@ public class TrialBalanceLine
public decimal CreditBalance { get; set; }
}
// ── Balance Reconciliation ─────────────────────────────────────────────────────
/// <summary>
/// Diagnostic that surfaces drift in the denormalized balances: each account's stored
/// <c>CurrentBalance</c> vs its recomputed ledger balance, plus the AR/AP subledger totals
/// (sum of Customer/Vendor CurrentBalance) vs their GL control account balances. Read-only.
/// </summary>
public class BalanceReconciliationDto
{
public DateTime AsOf { get; set; }
public string CompanyName { get; set; } = string.Empty;
public List<BalanceReconciliationLine> AccountLines { get; set; } = new();
public decimal ArControlBalance { get; set; }
public decimal ArSubledgerTotal { get; set; }
public decimal ArDifference => ArControlBalance - ArSubledgerTotal;
public decimal ApControlBalance { get; set; }
public decimal ApSubledgerTotal { get; set; }
public decimal ApDifference => ApControlBalance - ApSubledgerTotal;
public IEnumerable<BalanceReconciliationLine> DriftedAccounts => AccountLines.Where(l => !l.IsReconciled);
public bool AccountsReconciled => AccountLines.All(l => l.IsReconciled);
public bool ArReconciled => Math.Abs(ArDifference) < 0.01m;
public bool ApReconciled => Math.Abs(ApDifference) < 0.01m;
public bool AllReconciled => AccountsReconciled && ArReconciled && ApReconciled;
}
public class BalanceReconciliationLine
{
public int AccountId { get; set; }
public string AccountNumber { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
/// <summary>The denormalized Account.CurrentBalance (what most UI reads).</summary>
public decimal StoredBalance { get; set; }
/// <summary>The balance recomputed from source documents (what RecalculateBalances would set).</summary>
public decimal LedgerBalance { get; set; }
public decimal Difference => StoredBalance - LedgerBalance;
public bool IsReconciled => Math.Abs(Difference) < 0.01m;
}
// ── Profit & Loss ─────────────────────────────────────────────────────────────
public class ProfitAndLossDto
@@ -33,6 +33,12 @@ public interface IFinancialReportService
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
/// <summary>
/// Returns a balance reconciliation: each account's stored CurrentBalance vs its recomputed ledger
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only drift diagnostic.
/// </summary>
Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId);
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
@@ -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/>
@@ -1250,6 +1250,20 @@ public class ReportsController : Controller
return View(dto);
}
/// <summary>
/// Balance reconciliation diagnostic: each account's stored CurrentBalance vs its recomputed ledger
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only; surfaces drift in the
/// denormalized balances without changing any posting. Gated behind <see cref="AllowAccounting"/>.
/// </summary>
// GET: /Reports/Reconciliation
public async Task<IActionResult> Reconciliation()
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetBalanceReconciliationAsync(companyId);
return View(dto);
}
/// <summary>
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
/// Gated behind <see cref="AllowAccounting"/>.
@@ -220,6 +220,14 @@
<p>All active accounts with debit and credit balances &mdash; validates that your books are in balance.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="Reconciliation" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
<i class="bi bi-clipboard-check"></i>
</div>
<h5>Balance Reconciliation</h5>
<p>Stored account balances vs. the recomputed ledger, plus AR/AP subledger vs. control &mdash; surfaces any drift.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CashFlowStatement" class="report-card">
<div class="report-card-icon" style="background:#ecfeff;color:#0891b2;">
<i class="bi bi-water"></i>
@@ -0,0 +1,105 @@
@model PowderCoating.Application.DTOs.Accounting.BalanceReconciliationDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Balance Reconciliation";
ViewData["PageIcon"] = "bi-clipboard-check";
}
<style>
@@media print { .no-print { display: none !important; } }
.drift { background: #fef2f2; }
</style>
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">
As of @Model.AsOf.ToLocalTime().ToString("MMMM d, yyyy h:mm tt") &middot;
@if (Model.AllReconciled)
{
<span class="text-success fw-semibold"><i class="bi bi-check-circle me-1"></i>Everything reconciles</span>
}
else
{
<span class="text-danger fw-semibold">
<i class="bi bi-exclamation-triangle me-1"></i>@Model.DriftedAccounts.Count() account(s) drifted@(Model.ArReconciled ? "" : ", AR off")@(Model.ApReconciled ? "" : ", AP off")
</span>
}
</p>
</div>
<!-- Subledger vs control -->
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card shadow-sm h-100 @(Model.ArReconciled ? "" : "border-danger")">
<div class="card-body">
<h6 class="text-muted mb-2">Accounts Receivable</h6>
<div class="d-flex justify-content-between"><span>GL control account</span><span>@Model.ArControlBalance.ToString("C")</span></div>
<div class="d-flex justify-content-between"><span>Customer subledger (sum)</span><span>@Model.ArSubledgerTotal.ToString("C")</span></div>
<hr class="my-2" />
<div class="d-flex justify-content-between fw-semibold @(Model.ArReconciled ? "text-success" : "text-danger")">
<span>Difference</span><span>@Model.ArDifference.ToString("C")</span>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100 @(Model.ApReconciled ? "" : "border-danger")">
<div class="card-body">
<h6 class="text-muted mb-2">Accounts Payable</h6>
<div class="d-flex justify-content-between"><span>GL control account</span><span>@Model.ApControlBalance.ToString("C")</span></div>
<div class="d-flex justify-content-between"><span>Vendor subledger (sum)</span><span>@Model.ApSubledgerTotal.ToString("C")</span></div>
<hr class="my-2" />
<div class="d-flex justify-content-between fw-semibold @(Model.ApReconciled ? "text-success" : "text-danger")">
<span>Difference</span><span>@Model.ApDifference.ToString("C")</span>
</div>
</div>
</div>
</div>
</div>
<!-- Stored vs recomputed ledger, per account -->
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<h6 class="mb-0">Stored balance vs recomputed ledger</h6>
<span class="ms-2 badge bg-secondary">@Model.AccountLines.Count accounts</span>
</div>
<p class="text-muted small mb-3">
A difference means <code>Account.CurrentBalance</code> has drifted from what the source documents
recompute to. Running <strong>Accounts &rarr; Recalculate Balances</strong> resets the stored value
to the ledger value.
</p>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>#</th><th>Account</th><th>Type</th>
<th class="text-end">Stored</th><th class="text-end">Ledger</th><th class="text-end">Difference</th>
</tr>
</thead>
<tbody>
@foreach (var l in Model.AccountLines)
{
<tr class="@(l.IsReconciled ? "" : "drift")">
<td>@l.AccountNumber</td>
<td>@l.AccountName</td>
<td><span class="text-muted small">@l.AccountType</span></td>
<td class="text-end">@l.StoredBalance.ToString("C")</td>
<td class="text-end">@l.LedgerBalance.ToString("C")</td>
<td class="text-end @(l.IsReconciled ? "" : "text-danger fw-semibold")">
@if (l.IsReconciled)
{
<i class="bi bi-check2 text-success"></i>
}
else
{
@l.Difference.ToString("C")
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>