Conform reports to financial spec: A/R open-balance + reconciliation checks

Per the Financial Reports Master Spec:
- §1.5 A/R fix: invoice settlements now post to the GL (Dr Undeposited / Cr A/R
  from invoice.paid_amount); payments are the cash sub-ledger only and no longer
  separately credit A/R (avoids double-count). A/R control = open balance, so
  recon R7 passes for managed companies (Ashley Manor 39,248 -> 0). Bills already
  settled (R8 ok). Migration applied + backfilled managed companies.
- §9/§10: add a "Reconciliation Checks" report that surfaces R1/R2/R7/R8
  residuals (never plugged) so imbalances are visible — e.g. Bridgewater's
  imported GL is unbalanced (R1) and its sub-ledgers don't tie (R7/R8).

Imported companies (Bridgewater/Bent Oak) left untouched per decision; their
residuals now surface in the Reconciliation report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:55:18 -04:00
parent a3a0b706a1
commit 03286f865a
2 changed files with 150 additions and 2 deletions
+79 -2
View File
@@ -34,7 +34,7 @@ type ReportId =
| "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
| "trial-balance" | "general-ledger"
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
| "expense-summary" | "vendor-balances" | "ap-aging";
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation";
const APP_NAME = "Cozy Books";
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];
@@ -61,6 +61,9 @@ const GROUPS = [
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
]},
{ name: "Audit", reports: [
{ id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
]},
];
const TZ_ET = "America/New_York";
@@ -525,7 +528,10 @@ export default function AccountingReportsPage() {
{active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} />
)}
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && (
{active === "reconciliation" && (
<ReconciliationReport d={data} currency={cur} />
)}
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reconciliation" && (
<Card>
<CardContent>
{!data ? (
@@ -726,6 +732,77 @@ function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare,
// ---------- Financial report builders (structured) ----------
// 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 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 ok = (r: number) => Math.abs(r) < 0.005;
const allPass = checks.every((c) => ok(c.residual));
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Reconciliation Checks
<span className={`text-xs rounded px-2 py-0.5 ${allPass ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"}`}>
{allPass ? "All passing" : "Residuals present"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Check</TableHead>
<TableHead>Assertion</TableHead>
<TableHead className="text-right">Residual</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{checks.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-mono">{c.id}</TableCell>
<TableCell>{c.label}</TableCell>
<TableCell className={`text-right tabular-nums ${ok(c.residual) ? "" : "text-red-600 font-semibold"}`}>{money(c.residual, currency)}</TableCell>
<TableCell className="text-right">{ok(c.residual) ? "✓" : "✗"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-xs text-muted-foreground p-4">
A non-zero residual is a bug signal (§9), not to be plugged. R1/R2 failing means the ledger is
unbalanced (often an imported single-sided entry). R7/R8 failing means A/R or A/P is summing gross
billings instead of open balances, or a sub-ledger doesn't tie to the GL control account.
</p>
</CardContent>
</Card>
);
}
function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport {
if (id === "pnl") return buildPnL(d, p, useCompare);
if (id === "balance-sheet") return buildBalanceSheet(d);