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:
@@ -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 — 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 — 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") ·
|
||||
@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 → 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>
|
||||
Reference in New Issue
Block a user