diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 268339d..3f4cb7a 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -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" && ( )} - {!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && ( + {active === "reconciliation" && ( + + )} + {!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reconciliation" && ( {!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
Loading…
; + 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 ( + + + + Reconciliation Checks + + {allPass ? "All passing" : "Residuals present"} + + + + + + + + Check + Assertion + Residual + Status + + + + {checks.map((c) => ( + + {c.id} + {c.label} + {money(c.residual, currency)} + {ok(c.residual) ? "✓" : "✗"} + + ))} + +
+

+ 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. +

+
+
+ ); +} + 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); diff --git a/supabase/migrations/20260601180000_accounting_ar_open_balance_settlement.sql b/supabase/migrations/20260601180000_accounting_ar_open_balance_settlement.sql new file mode 100644 index 0000000..741aa68 --- /dev/null +++ b/supabase/migrations/20260601180000_accounting_ar_open_balance_settlement.sql @@ -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$$;