// 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(); 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 }, ]; // 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) })); }