mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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 <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||
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 (
|
||||
<Card>
|
||||
@@ -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<string, any>((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<string, number>();
|
||||
const endRaw = new Map<string, number>();
|
||||
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<string>([...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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user