mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
db20226d62
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>
124 lines
5.2 KiB
TypeScript
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) }));
|
|
}
|