From db20226d621659931ca3ccb5456defde60dd7364 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 2 Jun 2026 12:52:05 -0400 Subject: [PATCH] Movement of Equity GL-consistent; wire reconciliation checks R3/R5 (+R6/R9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Movement of Equity derived net income from sub-ledgers (calcNetIncome over invoices/expenses/bills), while the P&L and Balance Sheet use the GL. With any direct bank-categorized income/expense the two disagreed — Ashley Manor's SCE was off from the Balance Sheet equity by 9,257.44. Rebuild Movement of Equity from the GL (current-year earnings + GL equity balances) so all three statements tie by construction. Complete the §9 reconciliation matrix: R3 (P&L net income == raw-GL period net income — guards the P&L builder) and R5 (Movement of Equity ending == Balance Sheet total equity) are now computed by cross-checking the report builders against the raw GL; checks render in numeric order. R6 (GL == TB/BS, satisfied by construction) and R9 (direct vs indirect CFO, only indirect built) shown as N/A for matrix completeness. Adds 5 reconcile unit tests (12 total). Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingReportsPage.tsx | 82 +++++++++++++------ src/pages/accounting/lib/reconcile.test.ts | 36 ++++++++ src/pages/accounting/lib/reconcile.ts | 22 +++++ 3 files changed, 115 insertions(+), 25 deletions(-) diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index ca3975b..4224392 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -21,7 +21,6 @@ import { renderReportPdf, fmtAmount, type StructuredReport, type StructuredRow, } from "./lib/reportPdf"; -import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings"; import { reconcile, type RecAccount, type RecLine } from "./lib/reconcile"; import { computePnL, computeMargins, toMinor, fromMinor, PnlValidationError, @@ -762,7 +761,20 @@ function ReconciliationReport({ d, currency }: { d: any; currency: string }) { const openInv = ((d.allInvoices ?? []) as any[]).filter((i) => i.status !== "void").reduce((s, i) => s + (Number(i.total || 0) - Number(i.paid_amount || 0)), 0); const openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0); - const checks = reconcile({ accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill, arApApplicable: d.glManaged }); + // Cross-path figures from the report builders so R3/R5 verify the builders agree + // with the raw GL. (P&L net income vs GL; Movement-of-Equity ending vs Balance Sheet.) + const pl = buildPnL(d, undefined, false); + const plNI = pl.rows.find((r) => r.kind === "grand" && /net income/i.test(r.label))?.amount; + const bs = buildBalanceSheet(d); + const bsEquity = bs.rows.find((r) => r.kind === "total" && /total equity/i.test(r.label))?.amount; + const sce = buildMovementOfEquity(d, undefined, false); + const sceEnding = sce.rows.find((r) => r.kind === "grand" && /closing equity/i.test(r.label))?.amount; + + const checks = reconcile({ + accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill, + arApApplicable: d.glManaged, + reportPLNetIncome: plNI, sceEndingEquity: sceEnding, bsTotalEquity: bsEquity, + }); const ok = (r: number) => Math.abs(r) < 0.005; const allPass = checks.every((c) => c.pass); @@ -795,12 +807,28 @@ function ReconciliationReport({ d, currency }: { d: any; currency: string }) { {ok(c.residual) ? "✓" : "✗"} ))} + {/* R6 / R9 are not numeric residuals in this model — shown for matrix completeness. */} + + R6 + GL closing balance = Trial Balance / Balance Sheet balance + + n/a + + + R9 + Direct-method CFO = Indirect-method CFO + + n/a +

A non-zero residual is a bug signal (§9), not to be plugged. R1/R2 failing means the ledger is - unbalanced (often an imported single-sided entry). R7/R8 failing means A/R or A/P is summing gross - billings instead of open balances, or a sub-ledger doesn't tie to the GL control account. + unbalanced (often an imported single-sided entry). R3 checks the P&L's net income against the raw GL; + R5 checks the Movement of Equity against the Balance Sheet. R7/R8 failing means A/R or A/P is summing + gross billings instead of open balances, or a sub-ledger doesn't tie to the GL control account. + R6 is satisfied by construction (Trial Balance and Balance Sheet both derive from the GL); R9 is N/A + because only the indirect-method cash flow is produced. {!d.glManaged && " R7/R8 are omitted for this company: its GL is imported, so its A/R/A/P are maintained in the GL rather than from the platform's invoice/bill sub-ledgers."}

@@ -816,38 +844,42 @@ function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: bo } function buildMovementOfEquity(d: any, p: any | undefined, useCompare: boolean): StructuredReport { - const byType = (t: string) => d.accounts.filter((a: any) => a.type === t); - const equityAccs = byType("equity"); - const reAccount = equityAccs.find((a: any) => isRetainedEarnings(a)); + // GL-consistent with the Balance Sheet and P&L: net income is the GL's + // current-year earnings (not a separate sub-ledger figure), and the equity + // rolls forward to exactly the Balance Sheet's total equity (ties R5). + const equityAccs = (d.accounts ?? []).filter((a: any) => a.type === "equity"); const draws = equityAccs.filter((a: any) => /draw|dividend/i.test(a.name)); - const capital = equityAccs.filter((a: any) => !isSystemEquityAccount(a) && !/draw|dividend/i.test(a.name)); + const nonDraw = equityAccs.filter((a: any) => !/draw|dividend/i.test(a.name)); - const reOB = (d.openingBalances ?? []).find((b: any) => b.account_id === reAccount?.id); - const openingRE = reOB ? Number(reOB.credit || 0) - Number(reOB.debit || 0) : (reAccount ? Number(reAccount.balance) : 0); - const capitalTotal = capital.reduce((s: number, a: any) => s + Number(a.balance), 0); - const drawsTotal = draws.reduce((s: number, a: any) => s + Number(a.balance), 0); + // Per-dataset equity figures derived entirely from the GL (via bsBalances). + const eq = (ds: any) => { + const bs = bsBalances(ds); + const bal = (a: any) => bs.glByAcct.get(a.id) ?? 0; + const nonDrawGL = nonDraw.reduce((s: number, a: any) => s + bal(a), 0); + const drawsGL = draws.reduce((s: number, a: any) => s + bal(a), 0); + const opening = nonDrawGL + bs.rePrior; // capital + opening RE + prior-year earnings + const closing = opening + bs.cye + drawsGL; // = Balance Sheet total equity (by construction) + return { bal, drawsGL, opening, closing, netIncome: bs.cye, rePrior: bs.rePrior }; + }; - const netIncome = calcNetIncome({ invoices: d.ytdInvoices ?? d.invoices, expenses: d.ytdExpenses ?? d.expenses, bills: d.ytdBills ?? d.bills }); - const prevNetIncome = useCompare && p ? calcNetIncome({ invoices: p.ytdInvoices ?? p.invoices, expenses: p.ytdExpenses ?? p.expenses, bills: p.ytdBills ?? p.bills }) : undefined; - - const openingEquity = capitalTotal + openingRE; - const closingEquity = openingEquity + netIncome - drawsTotal; - const prevClosing = useCompare && prevNetIncome !== undefined ? openingEquity + prevNetIncome - drawsTotal : undefined; + const cur = eq(d); + const prev = useCompare && p ? eq(p) : undefined; + const cmp = (v: number | undefined) => (prev ? v : undefined); const rows: StructuredRow[] = [ { kind: "section", label: "Opening Equity" }, - ...capital.map((a: any) => ({ kind: "sub" as const, label: a.name, code: a.code ?? undefined, amount: Number(a.balance) })), - { kind: "sub", label: "Retained Earnings (prior periods)", amount: openingRE }, - { kind: "total", label: "Total Opening Equity", amount: openingEquity }, + ...nonDraw.map((a: any) => ({ kind: "sub" as const, label: a.name, code: a.code ?? undefined, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })), + { kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) }, + { kind: "total", label: "Total Opening Equity", amount: cur.opening, compare: cmp(prev?.opening) }, { kind: "spacer", label: "" }, { kind: "section", label: "Period Activity" }, - { kind: "sub", label: "Net Income", amount: netIncome, compare: prevNetIncome }, - ...draws.map((a: any) => ({ kind: "sub" as const, label: `Less: ${a.name}`, amount: -Math.abs(Number(a.balance)) })), - { kind: "total", label: "Net Change in Equity", amount: netIncome - drawsTotal, compare: prevNetIncome !== undefined ? prevNetIncome - drawsTotal : undefined }, + { kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) }, + ...draws.map((a: any) => ({ kind: "sub" as const, label: `Less: ${a.name}`, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })), + { kind: "total", label: "Net Change in Equity", amount: cur.netIncome + cur.drawsGL, compare: cmp(prev ? prev.netIncome + prev.drawsGL : undefined) }, { kind: "spacer", label: "" }, - { kind: "grand", label: "Closing Equity", amount: closingEquity, compare: prevClosing }, + { kind: "grand", label: "Closing Equity", amount: cur.closing, compare: cmp(prev?.closing) }, ]; return { title: "Movement of Equity", rows }; diff --git a/src/pages/accounting/lib/reconcile.test.ts b/src/pages/accounting/lib/reconcile.test.ts index 00e41e2..1e07d46 100644 --- a/src/pages/accounting/lib/reconcile.test.ts +++ b/src/pages/accounting/lib/reconcile.test.ts @@ -103,6 +103,42 @@ describe("reconciliation matrix (§9)", () => { expect(resid(gross, "R7").residual).toBeCloseTo(-400, 2); }); + it("R3: P&L net income matches the raw-GL period net income, fails on a builder drift", () => { + // Period: revenue 1000, expense 300 → GL period net income 700. + const lines = [dr("ar", 1000), cr("rev", 1000), dr("exp", 300), cr("cash", 300)]; + // Builder agrees → R3 passes. + expect(resid(reconcile(base(lines, { reportPLNetIncome: 700 })), "R3").pass).toBe(true); + // Builder disagrees (e.g. dropped/misclassified an account) → R3 fails by the gap. + const bad = reconcile(base(lines, { reportPLNetIncome: 650 })); + expect(resid(bad, "R3").pass).toBe(false); + expect(resid(bad, "R3").residual).toBeCloseTo(-50, 2); + // Omitted → R3 not emitted. + expect(reconcile(base(lines)).find((c) => c.id === "R3")).toBeUndefined(); + }); + + it("R5: Movement of Equity ending equity ties to the Balance Sheet total equity", () => { + const lines = [dr("ar", 1000), cr("rev", 1000)]; + expect(resid(reconcile(base(lines, { sceEndingEquity: 1000, bsTotalEquity: 1000 })), "R5").pass).toBe(true); + const bad = reconcile(base(lines, { sceEndingEquity: 940, bsTotalEquity: 1000 })); + expect(resid(bad, "R5").pass).toBe(false); + expect(resid(bad, "R5").residual).toBeCloseTo(-60, 2); + }); + + it("R7/R8 are omitted for imported-GL companies (arApApplicable=false)", () => { + const lines = [dr("ar", 1000), cr("rev", 1000)]; + const checks = reconcile(base(lines, { arApApplicable: false, openInvoices: 1000 })); + expect(checks.find((c) => c.id === "R7")).toBeUndefined(); + expect(checks.find((c) => c.id === "R8")).toBeUndefined(); + expect(resid(checks, "R1").pass).toBe(true); + }); + + it("checks are returned in numeric order", () => { + const lines = [dr("ar", 1000), cr("rev", 1000)]; + const checks = reconcile(base(lines, { reportPLNetIncome: 1000, sceEndingEquity: 1000, bsTotalEquity: 1000, openInvoices: 1000 })); + const ids = checks.map((c) => Number(c.id.slice(1))); + expect(ids).toEqual([...ids].sort((a, b) => a - b)); + }); + it("cash flow is GL-derived: it ties (R4) whenever the ledger balances", () => { // Indirect CFO+CFI+CFF == ΔCash is an algebraic identity of double entry, so a // balanced ledger always passes R4 — e.g. invoice billed but uncollected: diff --git a/src/pages/accounting/lib/reconcile.ts b/src/pages/accounting/lib/reconcile.ts index 9e1017c..4a1fb3b 100644 --- a/src/pages/accounting/lib/reconcile.ts +++ b/src/pages/accounting/lib/reconcile.ts @@ -36,6 +36,19 @@ export interface ReconcileInput { * Defaults to true. */ arApApplicable?: boolean; + /** + * Net income for the period as produced by the app's P&L builder. R3 cross-checks + * it against the raw-GL period net income computed here, catching a P&L + * classification/grouping bug. Omitted → R3 is skipped. + */ + reportPLNetIncome?: number; + /** + * Ending equity from the Movement-of-Equity builder and total equity from the + * Balance Sheet builder. R5 cross-checks that the two statements agree. + * Both omitted → R5 is skipped. + */ + sceEndingEquity?: number; + bsTotalEquity?: number; } export interface RecCheck { id: string; label: string; residual: number; pass: boolean } @@ -90,6 +103,14 @@ export function reconcile(input: ReconcileInput): RecCheck[] { { id: "R2", label: "Balance Sheet — Assets = Liabilities + Equity (incl. net income)", residual: assets - (liab + equity + netIncomeCum) }, { id: "R4", label: "Cash Flow — CFO+CFI+CFF = change in cash", residual: (periodNI + nonCashImpact) - deltaCash }, ]; + // R3: the P&L builder's net income must equal the raw-GL period net income. + if (input.reportPLNetIncome !== undefined) { + checks.push({ id: "R3", label: "P&L net income = period net income from the GL", residual: input.reportPLNetIncome - periodNI }); + } + // R5: the Movement-of-Equity ending equity must equal the Balance Sheet total equity. + if (input.sceEndingEquity !== undefined && input.bsTotalEquity !== undefined) { + checks.push({ id: "R5", label: "Movement of Equity ending = Balance Sheet total equity", residual: input.sceEndingEquity - input.bsTotalEquity }); + } // R7/R8 (sub-ledger vs GL control) only apply to platform-managed companies. if (input.arApApplicable !== false) { checks.push( @@ -97,5 +118,6 @@ export function reconcile(input: ReconcileInput): RecCheck[] { { id: "R8", label: "A/P = open bill balances (§1.5)", residual: apControl - input.openBills }, ); } + checks.sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1))); return checks.map((c) => ({ ...c, pass: near(c.residual) })); }