diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx
index 3f4cb7a..c948243 100644
--- a/src/pages/accounting/AccountingReportsPage.tsx
+++ b/src/pages/accounting/AccountingReportsPage.tsx
@@ -22,6 +22,7 @@ import {
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,
type PnlAccount, type PnlClassification, type Posting as PnlPosting, type PnlResult,
@@ -735,32 +736,20 @@ function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare,
// Reconciliation matrix (§9) surfaced as visible residuals — never plug a residual.
function ReconciliationReport({ d, currency }: { d: any; currency: string }) {
if (!d) return
Loading…
;
- const acctById = new Map((d.accounts ?? []).map((a: any) => [a.id, a]));
- let dr = 0, cr = 0, assets = 0, liab = 0, equity = 0, income = 0, expense = 0, arControl = 0, apControl = 0;
- for (const l of (d.glCumulative ?? []) as any[]) {
- const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
- dr += debit; cr += credit;
- const a: any = acctById.get(l.account_id) || {};
- const t = l.accounts?.type || a.type;
- const name = String(a.name || "").toLowerCase();
- if (t === "asset") { assets += debit - credit; if (name.includes("receivable")) arControl += debit - credit; }
- else if (t === "liability") { liab += credit - debit; if (name.includes("payable")) apControl += credit - debit; }
- else if (t === "equity") equity += credit - debit;
- else if (t === "income") income += credit - debit;
- else if (t === "expense") expense += debit - credit;
- }
+ const accounts: RecAccount[] = ((d.accounts ?? []) as any[]).map((a) => ({
+ id: a.id, type: a.type, name: a.name,
+ is_cash: !!a.is_bank || /cash|undeposited/i.test(String(a.name || "")),
+ }));
+ const lines: RecLine[] = ((d.glCumulative ?? []) as any[]).map((l) => ({
+ account_id: l.account_id, date: String(l.journal_entries?.date ?? ""),
+ debit: Number(l.debit || 0), credit: Number(l.credit || 0),
+ }));
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 netIncome = income - expense;
- const checks = [
- { id: "R1", label: "Trial Balance — total debits = total credits", residual: dr - cr },
- { id: "R2", label: "Balance Sheet — Assets = Liabilities + Equity (incl. net income)", residual: assets - (liab + equity + netIncome) },
- { id: "R7", label: "A/R = open invoice balances (§1.5, gross vs open)", residual: arControl - openInv },
- { id: "R8", label: "A/P = open bill balances (§1.5)", residual: apControl - openBill },
- ];
+ const checks = reconcile({ accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill });
const ok = (r: number) => Math.abs(r) < 0.005;
- const allPass = checks.every((c) => ok(c.residual));
+ const allPass = checks.every((c) => c.pass);
return (
@@ -1147,39 +1136,83 @@ function buildBalanceSheet(d: any): StructuredReport {
}
-function buildCashFlow(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
- const inflow = d.invoices.filter((i: any) => i.status === "paid").reduce((s: number, i: any) => s + Number(i.total), 0);
- const expOut = d.expenses.reduce((s: number, e: any) => s + Number(e.amount), 0);
- const billOut = d.bills.reduce((s: number, b: any) => s + Number(b.paid_amount ?? 0), 0);
+// Indirect-method cash flow built from the GL (§5). It ties to the change in the
+// Balance Sheet cash accounts by construction (R4): because every entry balances,
+// the cash impact of net income + all non-cash balance movements equals ΔCash.
+function buildCashFlow(d: any, _p: any | undefined, _useCompare: boolean): StructuredReport {
+ const from: string = d.from;
+ const acctById = new Map((d.accounts ?? []).map((a: any) => [a.id, a]));
+ const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || "")));
- const prevInflow = useCompare && p ? p.invoices.filter((i: any) => i.status === "paid").reduce((s: number, i: any) => s + Number(i.total), 0) : undefined;
- const prevExp = useCompare && p ? p.expenses.reduce((s: number, e: any) => s + Number(e.amount), 0) : undefined;
- const prevBill = useCompare && p ? p.bills.reduce((s: number, b: any) => s + Number(b.paid_amount ?? 0), 0) : undefined;
+ // Beginning (date < from) and ending (<= asOf) raw balances (debit − credit).
+ const beginRaw = new Map();
+ const endRaw = new Map();
+ for (const l of (d.glCumulative ?? []) as any[]) {
+ const raw = Number(l.debit || 0) - Number(l.credit || 0);
+ endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw);
+ if (String(l.journal_entries?.date ?? "") < from) {
+ beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
+ }
+ }
+ const ids = new Set([...endRaw.keys(), ...beginRaw.keys()]);
+ const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0);
- const opNet = inflow - expOut - billOut;
- const prevOpNet = (prevInflow !== undefined && prevExp !== undefined && prevBill !== undefined)
- ? prevInflow - prevExp - prevBill : undefined;
+ let beginCash = 0, endCash = 0, revenue = 0, expense = 0;
+ const operating: { label: string; amount: number }[] = [];
+ let cfi = 0, cff = 0;
- const rows: StructuredRow[] = [
- { kind: "section", label: "Operating Activities" },
- { kind: "sub", label: "Cash from customers", amount: inflow, compare: prevInflow },
- { kind: "sub", label: "Cash paid for expenses", amount: -expOut, compare: prevExp !== undefined ? -prevExp : undefined },
- { kind: "sub", label: "Cash paid for bills", amount: -billOut, compare: prevBill !== undefined ? -prevBill : undefined },
- { kind: "total", label: "Net Cash from Operating Activities", amount: opNet, compare: prevOpNet },
- { kind: "spacer", label: "" },
+ for (const id of ids) {
+ const a = acctById.get(id);
+ if (!a) continue;
+ if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; }
+ if (a.type === "income") { revenue += -deltaRaw(id); continue; } // natural = −raw
+ if (a.type === "expense") { expense += deltaRaw(id); continue; } // natural = raw
- { kind: "section", label: "Investing Activities" },
- { kind: "total", label: "Net Cash from Investing Activities", amount: 0, compare: useCompare ? 0 : undefined },
- { kind: "spacer", label: "" },
+ // Non-cash balance-sheet account: cash impact of its movement = −Δraw.
+ const impact = -deltaRaw(id);
+ if (Math.abs(impact) < 0.005) continue;
+ const name = String(a.name || "").toLowerCase();
+ if (a.type === "asset") {
+ const naturalUp = deltaRaw(id) > 0; // asset natural = raw
+ if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact;
+ else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
+ } else if (a.type === "liability") {
+ const naturalUp = -deltaRaw(id) > 0; // liability natural = −raw
+ if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact;
+ else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
+ } else if (a.type === "equity") {
+ cff += impact; // contributions / distributions / opening equity
+ }
+ }
- { kind: "section", label: "Financing Activities" },
- { kind: "total", label: "Net Cash from Financing Activities", amount: 0, compare: useCompare ? 0 : undefined },
- ];
+ const netIncome = revenue - expense;
+ const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0);
+ const netChange = cfo + cfi + cff;
+ const deltaCash = endCash - beginCash;
+ const residual = netChange - deltaCash;
+
+ const rows: StructuredRow[] = [];
+ rows.push({ kind: "section", label: "Operating Activities" });
+ rows.push({ kind: "sub", label: "Net Income", amount: netIncome });
+ for (const r of operating) rows.push({ kind: "sub", label: r.label, amount: r.amount });
+ rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cfo });
+ rows.push({ kind: "spacer", label: "" });
+ rows.push({ kind: "section", label: "Investing Activities" });
+ rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cfi });
+ rows.push({ kind: "spacer", label: "" });
+ rows.push({ kind: "section", label: "Financing Activities" });
+ rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cff });
+ rows.push({ kind: "spacer", label: "" });
+ rows.push({ kind: "sub", label: "Beginning Cash", amount: beginCash });
+ rows.push({ kind: "sub", label: "Ending Cash", amount: endCash });
+ if (Math.abs(residual) >= 0.005) {
+ rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF − ΔCash)", amount: residual });
+ }
return {
title: "Cash Flow Statement",
rows,
- cashHighlight: { label: "Net Change in Cash", amount: opNet },
+ cashHighlight: { label: "Net Change in Cash", amount: netChange },
};
}
diff --git a/src/pages/accounting/lib/reconcile.test.ts b/src/pages/accounting/lib/reconcile.test.ts
new file mode 100644
index 0000000..00e41e2
--- /dev/null
+++ b/src/pages/accounting/lib/reconcile.test.ts
@@ -0,0 +1,118 @@
+// @vitest-environment node
+import { describe, it, expect } from "vitest";
+import { reconcile, type RecAccount, type RecLine, type ReconcileInput } from "./reconcile";
+
+// Synthetic chart of accounts for the §10 test ledgers.
+const ACCT: Record = {
+ cash: { id: "cash", type: "asset", name: "Operating Cash", is_cash: true },
+ ar: { id: "ar", type: "asset", name: "Accounts Receivable" },
+ ap: { id: "ap", type: "liability", name: "Accounts Payable" },
+ re: { id: "re", type: "equity", name: "Retained Earnings" },
+ rev: { id: "rev", type: "income", name: "Assessment Fees" },
+ exp: { id: "exp", type: "expense", name: "Administrative" },
+};
+const accounts = Object.values(ACCT);
+
+function dr(account_id: string, amount: number, date = "2026-03-15"): RecLine {
+ return { account_id, date, debit: amount, credit: 0 };
+}
+function cr(account_id: string, amount: number, date = "2026-03-15"): RecLine {
+ return { account_id, date, debit: 0, credit: amount };
+}
+
+const base = (lines: RecLine[], extra: Partial = {}): ReconcileInput => ({
+ accounts,
+ lines,
+ periodStart: "2026-01-01",
+ openInvoices: 0,
+ openBills: 0,
+ ...extra,
+});
+
+const resid = (checks: ReturnType, id: string) => checks.find((c) => c.id === id)!;
+
+describe("reconciliation matrix (§9)", () => {
+ it("ledger 1: empty ledger — every check passes trivially", () => {
+ const checks = reconcile(base([]));
+ expect(checks.every((c) => c.pass)).toBe(true);
+ });
+
+ it("ledger 2: single balanced entry — R1 and R2 hold", () => {
+ // Customer invoiced 1000 (Dr AR / Cr Revenue), then fully paid (Dr Cash / Cr AR)
+ const lines = [
+ dr("ar", 1000), cr("rev", 1000),
+ dr("cash", 1000), cr("ar", 1000),
+ ];
+ const checks = reconcile(base(lines, { openInvoices: 0 })); // fully paid → no open AR
+ expect(resid(checks, "R1").pass).toBe(true);
+ expect(resid(checks, "R2").pass).toBe(true);
+ expect(resid(checks, "R4").pass).toBe(true);
+ expect(resid(checks, "R7").pass).toBe(true);
+ });
+
+ it("ledger 3: full period (revenue, expense, AP, partial AR) — R1,R2,R4,R7,R8 pass", () => {
+ const lines = [
+ // Assessments billed 1000, 600 collected
+ dr("ar", 1000), cr("rev", 1000),
+ dr("cash", 600), cr("ar", 600),
+ // Expense 300 on credit (bill), 100 paid
+ dr("exp", 300), cr("ap", 300),
+ dr("ap", 100), cr("cash", 100),
+ ];
+ const checks = reconcile(base(lines, { openInvoices: 400, openBills: 200 }));
+ for (const id of ["R1", "R2", "R4", "R7", "R8"]) {
+ expect(resid(checks, id).pass, `${id} should pass`).toBe(true);
+ }
+ });
+
+ it("fault: single-sided entry → R1 fails", () => {
+ const checks = reconcile(base([dr("cash", 500)])); // no offsetting credit
+ expect(resid(checks, "R1").pass).toBe(false);
+ expect(resid(checks, "R1").residual).toBeCloseTo(500, 2);
+ });
+
+ it("R2 and R4 are structural in a GL-derived model (mirror R1)", () => {
+ // Because the statements derive from one balanced ledger, R2 residual == R1
+ // residual == R4 residual: they pass together and fail together. Independent
+ // bugs (sub-ledger drift, type misclassification of A/R) surface in R7/R8.
+ const balanced = reconcile(base([dr("ar", 1000), cr("rev", 1000)], { openInvoices: 1000 }));
+ expect(resid(balanced, "R1").pass && resid(balanced, "R2").pass && resid(balanced, "R4").pass).toBe(true);
+
+ const unbalanced = reconcile(base([dr("cash", 500)]));
+ expect(resid(unbalanced, "R1").pass).toBe(false);
+ expect(resid(unbalanced, "R2").pass).toBe(false);
+ expect(resid(unbalanced, "R4").pass).toBe(false);
+ });
+
+ it("fault: payment not applied to invoice → R7 fails (A/R overstated)", () => {
+ // Invoice 1000 billed and cash collected in GL, but the invoice still shows
+ // open (payment not applied): A/R control 0, openInvoices 1000 → mismatch.
+ const lines = [dr("ar", 1000), cr("rev", 1000), dr("cash", 1000), cr("ar", 1000)];
+ const checks = reconcile(base(lines, { openInvoices: 1000 }));
+ expect(resid(checks, "R7").pass).toBe(false);
+ expect(resid(checks, "R7").residual).toBeCloseTo(-1000, 2);
+ });
+
+ it("ledger 5: partial settlement — A/R open balance is 600, not 1000 gross (§1.5)", () => {
+ // Invoice 1000, payment 400 applied → A/R control 600, openInvoices 600
+ const lines = [dr("ar", 1000), cr("rev", 1000), dr("cash", 400), cr("ar", 400)];
+ const checks = reconcile(base(lines, { openInvoices: 600 }));
+ expect(resid(checks, "R7").pass).toBe(true);
+ // and summing gross (1000) would fail by exactly the 400 collected
+ const gross = reconcile(base(lines, { openInvoices: 1000 }));
+ expect(resid(gross, "R7").residual).toBeCloseTo(-400, 2);
+ });
+
+ 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:
+ // net income +1000, A/R +1000, cash unchanged → CFO 0.
+ const balanced = reconcile(base([dr("ar", 1000), cr("rev", 1000)], { openInvoices: 1000 }));
+ expect(resid(balanced, "R4").pass).toBe(true);
+
+ // An unbalanced (single-sided) entry breaks the identity → R1 and R4 both fail.
+ const broken = reconcile(base([cr("rev", 1000)]));
+ expect(resid(broken, "R1").pass).toBe(false);
+ expect(resid(broken, "R4").pass).toBe(false);
+ });
+});
diff --git a/src/pages/accounting/lib/reconcile.ts b/src/pages/accounting/lib/reconcile.ts
new file mode 100644
index 0000000..b113e42
--- /dev/null
+++ b/src/pages/accounting/lib/reconcile.ts
@@ -0,0 +1,89 @@
+// Reference implementation of the Financial Reports reconciliation matrix (§9).
+// Pure functions over a minimal ledger model so they can be unit-tested on
+// synthetic ledgers (§10) and reused by the in-app Reconciliation report.
+
+export type AccountType = "asset" | "liability" | "equity" | "income" | "expense";
+
+export interface RecAccount {
+ id: string;
+ type: AccountType;
+ name?: string;
+ /** Cash / cash-equivalent account (bank or undeposited funds). */
+ is_cash?: boolean;
+}
+
+export interface RecLine {
+ account_id: string;
+ date: string; // ISO yyyy-mm-dd
+ debit: number;
+ credit: number;
+}
+
+export interface ReconcileInput {
+ accounts: RecAccount[];
+ /** Cumulative GL lines dated <= asOf. */
+ lines: RecLine[];
+ /** Flow-report window start (period_start). Lines dated < this are "beginning". */
+ periodStart: string;
+ /** Sum of OPEN customer invoice balances as of asOf (§1.5). */
+ openInvoices: number;
+ /** Sum of OPEN vendor bill balances as of asOf (§1.5). */
+ openBills: number;
+}
+
+export interface RecCheck { id: string; label: string; residual: number; pass: boolean }
+
+const TOL = 0.005;
+const near = (n: number) => Math.abs(n) < TOL;
+
+/** R1, R2, R4, R7, R8 residuals. Residual 0 == pass; never plug a residual (§10). */
+export function reconcile(input: ReconcileInput): RecCheck[] {
+ const acctById = new Map(input.accounts.map((a) => [a.id, a]));
+
+ let dr = 0, cr = 0;
+ let assets = 0, liab = 0, equity = 0, income = 0, expense = 0;
+ let arControl = 0, apControl = 0;
+ const beginRaw = new Map();
+ const endRaw = new Map();
+
+ for (const l of input.lines) {
+ const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
+ const raw = debit - credit;
+ dr += debit; cr += credit;
+ const a = acctById.get(l.account_id);
+ if (!a) continue;
+ const name = String(a.name || "").toLowerCase();
+ switch (a.type) {
+ case "asset": assets += raw; if (name.includes("receivable")) arControl += raw; break;
+ case "liability": liab += -raw; if (name.includes("payable")) apControl += -raw; break;
+ case "equity": equity += -raw; break;
+ case "income": income += -raw; break;
+ case "expense": expense += raw; break;
+ }
+ endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw);
+ if (l.date < input.periodStart) beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
+ }
+
+ const netIncomeCum = income - expense;
+
+ // R4: indirect CFO+CFI+CFF (= net income + all non-cash movements) == ΔCash.
+ let deltaCash = 0, periodNI = 0, nonCashImpact = 0;
+ const ids = new Set([...endRaw.keys(), ...beginRaw.keys()]);
+ for (const id of ids) {
+ const a = acctById.get(id); if (!a) continue;
+ const delta = (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0);
+ if (a.is_cash) { deltaCash += delta; continue; }
+ if (a.type === "income") periodNI += -delta;
+ else if (a.type === "expense") periodNI -= delta;
+ else nonCashImpact += -delta;
+ }
+
+ const checks: Omit[] = [
+ { id: "R1", label: "Trial Balance — total debits = total credits", residual: dr - cr },
+ { 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 },
+ { id: "R7", label: "A/R = open invoice balances (§1.5)", residual: arControl - input.openInvoices },
+ { id: "R8", label: "A/P = open bill balances (§1.5)", residual: apControl - input.openBills },
+ ];
+ return checks.map((c) => ({ ...c, pass: near(c.residual) }));
+}