mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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$$;
|
||||||
Reference in New Issue
Block a user