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; }
|
||||
}
|
||||
|
||||
// ── 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 — 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