Files
acmcc/src/pages/accounting/lib/reconcile.ts
T
admin db20226d62 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>
2026-06-02 12:52:05 -04:00

124 lines
5.2 KiB
TypeScript

// 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;
/**
* Whether the A/R-A/P sub-ledger ties to the GL control accounts (R7/R8).
* Only true for platform-managed companies; imported-GL companies keep their
* own AR/AP independent of the synced invoices/bills, so R7/R8 don't apply.
* 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 }
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 },
];
// 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(
{ 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 },
);
}
checks.sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
return checks.map((c) => ({ ...c, pass: near(c.residual) }));
}