Movement of Equity GL-consistent; wire reconciliation checks R3/R5 (+R6/R9)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:52:05 -04:00
parent 96de47496a
commit db20226d62
3 changed files with 115 additions and 25 deletions
+57 -25
View File
@@ -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 }) {
<TableCell className="text-right">{ok(c.residual) ? "✓" : "✗"}</TableCell>
</TableRow>
))}
{/* R6 / R9 are not numeric residuals in this model — shown for matrix completeness. */}
<TableRow className="text-muted-foreground">
<TableCell className="font-mono">R6</TableCell>
<TableCell>GL closing balance = Trial Balance / Balance Sheet balance</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right" title="Trial Balance and Balance Sheet are both derived directly from the GL, so they agree by construction.">n/a</TableCell>
</TableRow>
<TableRow className="text-muted-foreground">
<TableCell className="font-mono">R9</TableCell>
<TableCell>Direct-method CFO = Indirect-method CFO</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right" title="Only the indirect-method cash flow is produced, so there is no second method to reconcile against.">n/a</TableCell>
</TableRow>
</TableBody>
</Table>
<p className="text-xs text-muted-foreground p-4">
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."}
</p>
</CardContent>
@@ -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 };
@@ -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:
+22
View File
@@ -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) }));
}