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:
@@ -154,6 +154,49 @@ public class TrialBalanceLine
|
|||||||
public decimal CreditBalance { get; set; }
|
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 ─────────────────────────────────────────────────────────────
|
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class ProfitAndLossDto
|
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>
|
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
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>
|
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,65 @@ namespace PowderCoating.Infrastructure.Services;
|
|||||||
public class FinancialReportService : IFinancialReportService
|
public class FinancialReportService : IFinancialReportService
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly ILedgerService _ledger;
|
||||||
|
|
||||||
public FinancialReportService(ApplicationDbContext context)
|
public FinancialReportService(ApplicationDbContext context, ILedgerService ledger)
|
||||||
{
|
{
|
||||||
_context = context;
|
_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/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -1250,6 +1250,20 @@ public class ReportsController : Controller
|
|||||||
return View(dto);
|
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>
|
/// <summary>
|
||||||
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
|
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
|
||||||
/// Gated behind <see cref="AllowAccounting"/>.
|
/// 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>
|
<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>
|
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||||
</a>
|
</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">
|
<a asp-controller="Reports" asp-action="CashFlowStatement" class="report-card">
|
||||||
<div class="report-card-icon" style="background:#ecfeff;color:#0891b2;">
|
<div class="report-card-icon" style="background:#ecfeff;color:#0891b2;">
|
||||||
<i class="bi bi-water"></i>
|
<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