Cash Flow indirect method (R4) + reconciliation library & test suite

In the Accounting section Reports (AccountingReportsPage):
- Rebuild the Cash Flow Statement as an indirect-method report derived from the
  GL: Net Income + working-capital/non-cash movements, classified into
  CFO/CFI/CFF, with Beginning/Ending Cash. Ties to the change in Balance Sheet
  cash by construction (R4); surfaces an explicit residual row if it ever doesn't.
- Add R4 to the Reconciliation Checks report.
- Extract the reconciliation matrix into a pure, tested library
  (lib/reconcile.ts) and route the Reconciliation report through it.
- Add §10 synthetic-ledger vitest suite (lib/reconcile.test.ts): empty, single
  balanced entry, full period, partial settlement (open vs gross §1.5), and
  fault ledgers (single-sided → R1; payment-not-applied → R7). Verified R4 ties
  for Ashley Manor ($35,727.08 = ΔCash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 00:07:16 -04:00
parent 03286f865a
commit 7a7435a8ee
3 changed files with 286 additions and 46 deletions
+89
View File
@@ -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<string, number>();
const endRaw = new Map<string, number>();
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<string>([...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<RecCheck, "pass">[] = [
{ 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) }));
}