diff --git a/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs b/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs index e347cba..8548345 100644 --- a/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs +++ b/src/PowderCoating.Application/DTOs/Accounting/FinancialReportDtos.cs @@ -154,6 +154,49 @@ public class TrialBalanceLine public decimal CreditBalance { get; set; } } +// ── Balance Reconciliation ───────────────────────────────────────────────────── + +/// +/// Diagnostic that surfaces drift in the denormalized balances: each account's stored +/// CurrentBalance vs its recomputed ledger balance, plus the AR/AP subledger totals +/// (sum of Customer/Vendor CurrentBalance) vs their GL control account balances. Read-only. +/// +public class BalanceReconciliationDto +{ + public DateTime AsOf { get; set; } + public string CompanyName { get; set; } = string.Empty; + + public List 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 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; } + /// The denormalized Account.CurrentBalance (what most UI reads). + public decimal StoredBalance { get; set; } + /// The balance recomputed from source documents (what RecalculateBalances would set). + public decimal LedgerBalance { get; set; } + public decimal Difference => StoredBalance - LedgerBalance; + public bool IsReconciled => Math.Abs(Difference) < 0.01m; +} + // ── Profit & Loss ───────────────────────────────────────────────────────────── public class ProfitAndLossDto diff --git a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs index 40614fa..0a39978 100644 --- a/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs +++ b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs @@ -33,6 +33,12 @@ public interface IFinancialReportService /// Returns a Trial Balance using current account balances as of the given date. Task GetTrialBalanceAsync(int companyId, DateTime asOf); + /// + /// 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. + /// + Task GetBalanceReconciliationAsync(int companyId); + /// Looks up the accounting method configured for the given company. Returns Accrual if not found. Task GetCompanyAccountingMethodAsync(int companyId); diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs index 7a682aa..43181b1 100644 --- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs +++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs @@ -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; + } + + /// + public async Task 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(); + 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 + }; } /// diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index a676264..485578a 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -1250,6 +1250,20 @@ public class ReportsController : Controller return View(dto); } + /// + /// 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 . + /// + // GET: /Reports/Reconciliation + public async Task 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); + } + /// /// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions. /// Gated behind . diff --git a/src/PowderCoating.Web/Views/Reports/Landing.cshtml b/src/PowderCoating.Web/Views/Reports/Landing.cshtml index f1f0682..2a944b4 100644 --- a/src/PowderCoating.Web/Views/Reports/Landing.cshtml +++ b/src/PowderCoating.Web/Views/Reports/Landing.cshtml @@ -220,6 +220,14 @@

All active accounts with debit and credit balances — validates that your books are in balance.

Open report
+ +
+ +
+
Balance Reconciliation
+

Stored account balances vs. the recomputed ledger, plus AR/AP subledger vs. control — surfaces any drift.

+
Open report
+
diff --git a/src/PowderCoating.Web/Views/Reports/Reconciliation.cshtml b/src/PowderCoating.Web/Views/Reports/Reconciliation.cshtml new file mode 100644 index 0000000..7e428a2 --- /dev/null +++ b/src/PowderCoating.Web/Views/Reports/Reconciliation.cshtml @@ -0,0 +1,105 @@ +@model PowderCoating.Application.DTOs.Accounting.BalanceReconciliationDto +@using PowderCoating.Core.Enums +@{ + ViewData["Title"] = "Balance Reconciliation"; + ViewData["PageIcon"] = "bi-clipboard-check"; +} + + + +
+ +

+ As of @Model.AsOf.ToLocalTime().ToString("MMMM d, yyyy h:mm tt") · + @if (Model.AllReconciled) + { + Everything reconciles + } + else + { + + @Model.DriftedAccounts.Count() account(s) drifted@(Model.ArReconciled ? "" : ", AR off")@(Model.ApReconciled ? "" : ", AP off") + + } +

+
+ + +
+
+
+
+
Accounts Receivable
+
GL control account@Model.ArControlBalance.ToString("C")
+
Customer subledger (sum)@Model.ArSubledgerTotal.ToString("C")
+
+
+ Difference@Model.ArDifference.ToString("C") +
+
+
+
+
+
+
+
Accounts Payable
+
GL control account@Model.ApControlBalance.ToString("C")
+
Vendor subledger (sum)@Model.ApSubledgerTotal.ToString("C")
+
+
+ Difference@Model.ApDifference.ToString("C") +
+
+
+
+
+ + +
+
+
+
Stored balance vs recomputed ledger
+ @Model.AccountLines.Count accounts +
+

+ A difference means Account.CurrentBalance has drifted from what the source documents + recompute to. Running Accounts → Recalculate Balances resets the stored value + to the ledger value. +

+
+ + + + + + + + + @foreach (var l in Model.AccountLines) + { + + + + + + + + + } + +
#AccountTypeStoredLedgerDifference
@l.AccountNumber@l.AccountName@l.AccountType@l.StoredBalance.ToString("C")@l.LedgerBalance.ToString("C") + @if (l.IsReconciled) + { + + } + else + { + @l.Difference.ToString("C") + } +
+
+
+