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" | "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
| "trial-balance" | "general-ledger" | "trial-balance" | "general-ledger"
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency" | "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 APP_NAME = "Cozy Books";
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"]; 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: "expense-summary" as ReportId, name: "Expense Summary" },
{ id: "vendor-balances" as ReportId, name: "Vendor Balance 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"; const TZ_ET = "America/New_York";
@@ -525,7 +528,10 @@ export default function AccountingReportsPage() {
{active === "general-ledger" && ( {active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} /> <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> <Card>
<CardContent> <CardContent>
{!data ? ( {!data ? (
@@ -726,6 +732,77 @@ function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare,
// ---------- Financial report builders (structured) ---------- // ---------- 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 { function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport {
if (id === "pnl") return buildPnL(d, p, useCompare); if (id === "pnl") return buildPnL(d, p, useCompare);
if (id === "balance-sheet") return buildBalanceSheet(d); if (id === "balance-sheet") return buildBalanceSheet(d);
@@ -0,0 +1,71 @@
-- §1.5 conformance: Accounts Receivable must be the OPEN balance (invoices net of
-- payments applied), not gross invoiced. Previously invoices debited A/R but only
-- payments_received credited it — so native invoices marked paid (paid_amount set,
-- no payment row) left A/R overstated (recon R7 failed).
--
-- Fix: settle A/R from invoice.paid_amount (the canonical "payments applied"):
-- invoice -> Dr A/R / Cr income (acmacc_inv)
-- invoice settled -> Dr Undeposited / Cr A/R (acmacc_invpay, = paid_amount)
-- Payments are the cash sub-ledger only; they no longer post a separate A/R credit
-- (that would double-count, since paid_amount already reflects applied payments).
-- Net A/R control = total invoiced total paid = open balance. Bills already
-- settle (Dr A/P / Cr bank), so A/P was already correct.
create or replace function accounting.post_invoice_gl(_invoice_id uuid) returns void
language plpgsql security definer set search_path to 'public','accounting' as $$
declare i accounting.invoices%rowtype; _ar uuid; _inc uuid; _cash uuid; _je uuid;
begin
select * into i from accounting.invoices where id=_invoice_id;
if not found then return; end if;
perform accounting._gl_clear(i.company_id, 'acmacc_inv', i.id::text);
perform accounting._gl_clear(i.company_id, 'acmacc_invpay', i.id::text);
if not accounting.gl_managed(i.company_id) then return; end if;
if coalesce(i.total,0) = 0 or i.status = 'void' then return; end if;
_ar := accounting.coa_ar(i.company_id);
_inc := accounting.coa_income_for(i.company_id, coalesce(nullif(i.notes,''), i.number));
-- Billing: Dr A/R / Cr income (gross)
insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id)
values (i.company_id, i.issue_date, coalesce(nullif(i.notes,''), 'Invoice ' || i.number), i.number, 'acmacc_inv', i.id::text)
returning id into _je;
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values
(_je, _ar, i.total, 0, 'Invoice ' || i.number),
(_je, _inc, 0, i.total, 'Invoice ' || i.number);
-- Settlement: Dr Undeposited / Cr A/R for the amount paid (open balance falls out)
if coalesce(i.paid_amount,0) > 0 then
_cash := accounting.coa_undeposited(i.company_id);
insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id)
values (i.company_id, coalesce(i.updated_at::date, i.issue_date), 'Payment on Invoice ' || i.number, i.number, 'acmacc_invpay', i.id::text)
returning id into _je;
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values
(_je, _cash, i.paid_amount, 0, 'Payment on Invoice ' || i.number),
(_je, _ar, 0, i.paid_amount, 'Payment on Invoice ' || i.number);
end if;
end$$;
-- Payments are the cash/undeposited sub-ledger only; A/R settlement is posted from
-- invoice.paid_amount above. Drop any legacy payment JE to avoid double-crediting A/R.
create or replace function accounting.post_payment_gl(_payment_id uuid) returns void
language plpgsql security definer set search_path to 'public','accounting' as $$
declare p accounting.payments_received%rowtype;
begin
select * into p from accounting.payments_received where id=_payment_id;
if not found then return; end if;
perform accounting._gl_clear(p.company_id, 'acmacc_pay', p.id::text);
end$$;
-- Re-post managed companies: invoices gain the settlement leg; payments drop theirs.
do $$
declare r record;
begin
for r in select i.id from accounting.invoices i join accounting.companies c on c.id=i.company_id
where accounting.gl_managed(c.id) loop
perform accounting.post_invoice_gl(r.id);
end loop;
for r in select p.id from accounting.payments_received p join accounting.companies c on c.id=p.company_id
where accounting.gl_managed(c.id) loop
perform accounting.post_payment_gl(r.id);
end loop;
end$$;