From 3d11980b8cb75ab17b87bd47b9f7bfab37144f78 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 2 Jun 2026 01:49:47 -0400 Subject: [PATCH] =?UTF-8?q?Part=20B:=20=CE=94/=CE=94%=20columns,=20Balance?= =?UTF-8?q?=20Sheet=20comparison,=20Budget=20vs=20Actuals=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StructuredTable now renders Comparative + Change + Change % columns when a comparison period is on (P&L, Cash Flow, Movement of Equity, Balance Sheet). - Balance Sheet supports an as-of comparison period (prior as-of balances per account + totals); comparison toggle enabled for it (buildBalanceSheet takes prior dataset). Current Year Earnings stays independently computed. - Budget vs Actuals: optional "Compare to" date range adds Compare + Δ-vs-Compare columns; actuals computation factored into a shared helper for both windows. Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingReportsPage.tsx | 244 +++++++++--------- 1 file changed, 129 insertions(+), 115 deletions(-) diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 1992a62..7f212e1 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -488,7 +488,7 @@ export default function AccountingReportsPage() { {/* Comparison period — only for financial reports */} - {isFinancial && active !== "balance-sheet" && ( + {isFinancial && (
Compare {([ @@ -582,7 +582,7 @@ export default function AccountingReportsPage() {
- setCompareMode(v ? "prior-year" : "none")} label="Show comparative period" disabled={active === "balance-sheet"} /> + setCompareMode(v ? "prior-year" : "none")} label="Show comparative period" />
@@ -618,24 +618,31 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }: report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string; }) { let alt = false; + const span = showCompare ? 5 : 2; + const pctStr = (amount?: number, compare?: number) => { + if (amount === undefined || compare === undefined || Math.abs(compare) < 0.005) return "—"; + return `${(((amount - compare) / Math.abs(compare)) * 100).toFixed(1)}%`; + }; return ( - {showCompare && } + {showCompare && } + {showCompare && } + {showCompare && } {report.rows.map((r, i) => { - if (r.kind === "spacer") { alt = false; return ; } + if (r.kind === "spacer") { alt = false; return ; } if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) return null; if (r.kind === "section") { alt = false; return ( - + ); } @@ -643,13 +650,14 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }: alt = false; return ( - + ); } const bold = r.kind === "total" || r.kind === "grand"; const shaded = r.kind === "sub" && alt; if (r.kind === "sub") alt = !alt; + const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined; return ( {r.code}} {r.label} - {showCompare && } + {showCompare && } + {showCompare && } + {showCompare && } ); })} @@ -668,7 +678,7 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }: {report.balanced !== undefined && ( - -
AccountPreviousAmountComparativeChangeChange %
{r.label}{r.label}
{r.label}{r.label}
{r.amount !== undefined ? pctStr(r.amount, r.compare) : ""}
+
{report.balanced ? "Balance Sheet is balanced ✓" : `Balance Sheet is OUT OF BALANCE by ${money(report.outOfBalanceAmount ?? 0, currency)} (Assets − Liabilities − Equity)`}
@@ -678,7 +688,7 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }: )} {report.cashHighlight && (
+
{report.cashHighlight.label} {fmtAmount(report.cashHighlight.amount)} @@ -794,7 +804,7 @@ function ReconciliationReport({ d, currency }: { d: any; currency: string }) { 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); + if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare); if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare); return buildCashFlow(d, p, useCompare); } @@ -1053,24 +1063,13 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe return { title: "Profit & Loss", rows }; } -function buildBalanceSheet(d: any): StructuredReport { - // Balance Sheet is computed from the general ledger (journal entries) + - // opening balances, so every account with GL activity appears with its real - // as-of balance. Sign convention (natural balance, positive): - // asset & expense → debit − credit - // liability/equity/income → credit − debit - const accounts = (d.accounts ?? []) as any[]; - const fyStart = `${String(d.asOf ?? "").slice(0, 4)}-01-01`; +// Per-account natural balances + retained-earnings split from a dataset's GL. +function bsBalances(ds: any) { + const fyStart = `${String(ds?.asOf ?? "").slice(0, 4)}-01-01`; const isDebitNormal = (t: string) => t === "asset" || t === "expense"; - - // Opening balances are posted to the GL (acmacc_opening) for managed companies - // and are already inside the imported GL for the rest — so the Balance Sheet - // reads balances from the GL alone (no separate opening-balance add). - - // Cumulative GL (natural) per account, plus income/expense net split by FY const glByAcct = new Map(); let incomeAll = 0, expenseAll = 0, incomePrior = 0, expensePrior = 0; - for (const l of (d.glCumulative ?? []) as any[]) { + for (const l of (ds?.glCumulative ?? []) as any[]) { const t = l.accounts?.type; if (!t) continue; const debit = Number(l.debit || 0), credit = Number(l.credit || 0); @@ -1080,50 +1079,56 @@ function buildBalanceSheet(d: any): StructuredReport { if (t === "income") { incomeAll += credit - debit; if (isPrior) incomePrior += credit - debit; } else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; } } + const rePrior = incomePrior - expensePrior; + const cye = (incomeAll - expenseAll) - rePrior; + return { glByAcct, rePrior, cye }; +} - const balOf = (a: any) => (glByAcct.get(a.id) ?? 0); +function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredReport { + // GL-derived (opening balances posted to the GL). Current-Year Earnings is + // computed independently from actuals (income − expense), never plugged. + const accounts = (d.accounts ?? []) as any[]; + const cur = bsBalances(d); + const prev = useCompare && p ? bsBalances(p) : undefined; + const balOf = (a: any) => (cur.glByAcct.get(a.id) ?? 0); + const balOfP = (a: any) => (prev ? (prev.glByAcct.get(a.id) ?? 0) : undefined); + const cmp = (v: number | undefined) => (prev ? v : undefined); const byType = (t: string) => accounts.filter((a) => a.type === t); const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0); - - const rePrior = incomePrior - expensePrior; // prior-year retained earnings (from GL) - const cye = (incomeAll - expenseAll) - rePrior; // current-year earnings - - // A/P and A/R come from the general ledger (native bills/invoices are posted - // to the GL by syncBillsInvoicesToLedger), so they reflect the outstanding - // balances AND keep the sheet in balance — no out-of-GL override. + const sumBalP = (rows: any[]) => (prev ? rows.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined); const rows: StructuredRow[] = []; // Assets rows.push({ kind: "section", label: "Assets" }); const assets = byType("asset"); - for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a) }); + for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) }); const totalA = sumBal(assets); - rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA }); + rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) }); rows.push({ kind: "spacer", label: "" }); - // Liabilities (Accounts Payable = unpaid bills) + // Liabilities rows.push({ kind: "section", label: "Liabilities" }); const liabs = byType("liability"); - for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a) }); + for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) }); const totalL = sumBal(liabs); - rows.push({ kind: "total", label: "Total Liabilities", amount: totalL }); + rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) }); rows.push({ kind: "spacer", label: "" }); - // Equity — equity accounts (opening + GL) plus calculated RE / CYE from the ledger + // Equity — equity accounts + calculated RE / current-year earnings rows.push({ kind: "section", label: "Equity" }); const equityAccs = byType("equity"); - for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a) }); - rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: rePrior }); - rows.push({ kind: "sub", label: "Current Year Earnings", amount: cye }); - const totalE = sumBal(equityAccs) + rePrior + cye; - rows.push({ kind: "total", label: "Total Equity", amount: totalE }); + for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) }); + rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) }); + rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) }); + const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye; + const totalEP = prev ? (sumBalP(equityAccs)! + prev.rePrior + prev.cye) : undefined; + rows.push({ kind: "total", label: "Total Equity", amount: totalE, compare: cmp(totalEP) }); rows.push({ kind: "spacer", label: "" }); - rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE }); + rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE, compare: cmp(prev ? (sumBalP(liabs)! + totalEP!) : undefined) }); - // Invariant check in integer cents — exact, no binary-float drift (spec §6/§8). - // The residual is surfaced (never plugged): residual = Assets − (Liab + Equity). + // Residual surfaced (never plugged): Assets − (Liabilities + Equity). const cents = (n: number) => Math.round(n * 100); const residualCents = cents(totalA) - cents(totalL + totalE); const balanced = residualCents === 0; @@ -1302,6 +1307,51 @@ function orderAccountsHierarchically(accs: any[]): any[] { return out; } +async function fetchBvaActuals(companyId: string, f: string, t: string) { + const [inv, exp, txns, billItemsRes] = await Promise.all([ + accounting.from("invoices").select("total,status,issue_date").eq("company_id", companyId).gte("issue_date", f).lte("issue_date", t).not("status", "in", '("void","draft")'), + accounting.from("expenses").select("amount,category,date").eq("company_id", companyId).gte("date", f).lte("date", t), + accounting.from("transactions").select("coa_account_id,amount,type").eq("company_id", companyId).gte("date", f).lte("date", t).not("coa_account_id", "is", null), + accounting.from("bill_items").select("account_id,amount,bills!inner(issue_date,company_id)").eq("bills.company_id", companyId).gte("bills.issue_date", f).lte("bills.issue_date", t).not("account_id", "is", null), + ]); + return { invoices: inv.data ?? [], expenses: exp.data ?? [], transactions: txns.data ?? [], billItems: billItemsRes.data ?? [] }; +} + +// Actuals per account for a given actualsData window (transactions, bill items, +// expenses, and accrual invoice income distributed by budget weight). +function computeBvaActuals(actualsData: any, grouped: Record, budgetByAcct: Record): Record { + const m: Record = {}; + if (!actualsData) return m; + const expAccs = grouped.expense ?? []; + for (const tx of actualsData.transactions as any[]) { + if (!tx.coa_account_id) continue; + m[tx.coa_account_id] = (m[tx.coa_account_id] ?? 0) + Number(tx.amount); + } + for (const bi of actualsData.billItems as any[]) { + if (!bi.account_id) continue; + m[bi.account_id] = (m[bi.account_id] ?? 0) + Number(bi.amount); + } + for (const e of actualsData.expenses as any[]) { + const cat = String(e.category ?? "").toLowerCase().trim(); + const match = expAccs.find((a) => a.name.toLowerCase().trim() === cat || (a.code && a.code.toLowerCase().trim() === cat)); + if (match) m[match.id] = (m[match.id] ?? 0) + Number(e.amount); + } + const totalPaidInvoices = actualsData.invoices + .filter((i: any) => i.status !== "void" && i.status !== "draft") + .reduce((s: number, i: any) => s + Number(i.total), 0); + const alreadyCountedIncome = (grouped.income ?? []).reduce((s: number, a: any) => s + (m[a.id] ?? 0), 0); + const remainingInvoiceIncome = Math.max(0, totalPaidInvoices - alreadyCountedIncome); + if (remainingInvoiceIncome > 0 && (grouped.income ?? []).length > 0) { + const incomeAccs = grouped.income ?? []; + const totalBudgeted = incomeAccs.reduce((s: number, a: any) => s + (budgetByAcct[a.id] ?? 0), 0); + for (const a of incomeAccs) { + const weight = totalBudgeted > 0 ? (budgetByAcct[a.id] ?? 0) / totalBudgeted : 1 / incomeAccs.length; + m[a.id] = (m[a.id] ?? 0) + remainingInvoiceIncome * weight; + } + } + return m; +} + function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string }) { const { data: budgets = [] } = useQuery({ queryKey: ["budgets-active", companyId], @@ -1331,27 +1381,21 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe queryFn: async () => (await accounting.from("budget_entries").select("*").eq("budget_id", budgetId)).data ?? [], }); + // Optional comparison window (B3): compare actuals to another date range. + const [cmpOn, setCmpOn] = useState(false); + const [cmpFrom, setCmpFrom] = useState(""); + const [cmpTo, setCmpTo] = useState(""); + const { data: actualsData } = useQuery({ queryKey: ["bva-actuals", companyId, actFrom, actTo], enabled: !!companyId, - queryFn: async () => { - const [inv, exp, txns, billItemsRes] = await Promise.all([ - // All issued invoices (accrual — not filtered by payment status) - accounting.from("invoices").select("total,status,issue_date").eq("company_id", companyId).gte("issue_date", actFrom).lte("issue_date", actTo).not("status", "in", '("void","draft")'), - // Expenses table (tertiary for expense matching) - accounting.from("expenses").select("amount,category,date").eq("company_id", companyId).gte("date", actFrom).lte("date", actTo), - // Transactions with coa_account_id (primary — covers banking + receive-payments flow) - accounting.from("transactions").select("coa_account_id,amount,type").eq("company_id", companyId).gte("date", actFrom).lte("date", actTo).not("coa_account_id", "is", null), - // Bill items joined to bills in range (secondary — covers bills paid via Bills page) - accounting.from("bill_items").select("account_id,amount,bills!inner(issue_date,company_id)").eq("bills.company_id", companyId).gte("bills.issue_date", actFrom).lte("bills.issue_date", actTo).not("account_id", "is", null), - ]); - return { - invoices: inv.data ?? [], - expenses: exp.data ?? [], - transactions: txns.data ?? [], - billItems: billItemsRes.data ?? [], - }; - }, + queryFn: () => fetchBvaActuals(companyId, actFrom, actTo), + }); + + const { data: cmpActualsData } = useQuery({ + queryKey: ["bva-actuals-cmp", companyId, cmpFrom, cmpTo], + enabled: !!companyId && cmpOn && !!cmpFrom && !!cmpTo, + queryFn: () => fetchBvaActuals(companyId, cmpFrom, cmpTo), }); const TYPES_LOCAL = [ @@ -1399,56 +1443,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe return m; }, [entries, selectedBudget, actFrom, actTo]); - const actualByAcct = useMemo(() => { - const m: Record = {}; - if (!actualsData) return m; - const incomeAccIds = new Set((grouped.income ?? []).map((a) => a.id)); - const expAccs = grouped.expense ?? []; - - // ── 1. Transactions with coa_account_id (highest accuracy) ────────────── - for (const tx of actualsData.transactions as any[]) { - if (!tx.coa_account_id) continue; - m[tx.coa_account_id] = (m[tx.coa_account_id] ?? 0) + Number(tx.amount); - } - - // ── 2. Bill items with account_id (expense accounts via Bills page) ────── - for (const bi of actualsData.billItems as any[]) { - if (!bi.account_id) continue; - // Only add if not already counted via a transaction (avoid double-counting) - // Bill items are supplemental — add to total - m[bi.account_id] = (m[bi.account_id] ?? 0) + Number(bi.amount); - } - - // ── 3. Expenses table — match by account name or code (tertiary) ───────── - for (const e of actualsData.expenses as any[]) { - const cat = String(e.category ?? "").toLowerCase().trim(); - const match = expAccs.find((a) => - a.name.toLowerCase().trim() === cat || - (a.code && a.code.toLowerCase().trim() === cat) - ); - if (match) m[match.id] = (m[match.id] ?? 0) + Number(e.amount); - } - - // ── 4. Income: all issued invoices distributed proportionally by budget weight (accrual) ── - const totalPaidInvoices = actualsData.invoices - .filter((i: any) => i.status !== "void" && i.status !== "draft") - .reduce((s: number, i: any) => s + Number(i.total), 0); - const alreadyCountedIncome = (grouped.income ?? []).reduce((s: number, a: any) => s + (m[a.id] ?? 0), 0); - const remainingInvoiceIncome = Math.max(0, totalPaidInvoices - alreadyCountedIncome); - - if (remainingInvoiceIncome > 0 && (grouped.income ?? []).length > 0) { - const incomeAccs = grouped.income ?? []; - const totalBudgeted = incomeAccs.reduce((s: number, a: any) => s + (budgetByAcct[a.id] ?? 0), 0); - for (const a of incomeAccs) { - const weight = totalBudgeted > 0 - ? (budgetByAcct[a.id] ?? 0) / totalBudgeted // proportional to budget - : 1 / incomeAccs.length; // equal if no budget - m[a.id] = (m[a.id] ?? 0) + remainingInvoiceIncome * weight; - } - } - - return m; - }, [actualsData, grouped, budgetByAcct]); + const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]); + const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]); const chartData = useMemo(() => { const sumGroup = (type: "income" | "expense") => { @@ -1539,6 +1535,16 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe setActTo(e.target.value)} className="h-9 w-40" />
+
+ + {cmpOn && (<> + setCmpFrom(e.target.value)} className="h-9 w-40" /> + + setCmpTo(e.target.value)} className="h-9 w-40" /> + )} +
@@ -1576,6 +1582,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe Actual Variance Variance % + {cmpOn && Compare} + {cmpOn && Δ vs Compare} @@ -1584,6 +1592,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe if (!accs.length) return null; const totalB = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0); const totalA = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0); + const totalC = accs.reduce((s, a) => s + (cmpActualByAcct[a.id] ?? 0), 0); const totalVar = totalA - totalB; const totalPct = totalB ? (totalVar / totalB) * 100 : 0; const totalFavorable = t.favorableWhen === "over" ? totalVar >= 0 : totalVar <= 0; @@ -1595,10 +1604,13 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe {money(totalA, currency)} {money(totalVar, currency)} {totalB ? `${totalPct.toFixed(1)}%` : "—"} + {cmpOn && {money(totalC, currency)}} + {cmpOn && {money(totalA - totalC, currency)}} {(groupedOrdered[t.value] ?? []).map((a: any) => { const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; + const c = cmpActualByAcct[a.id] ?? 0; const v = ac - b; const pct = b ? (v / b) * 100 : 0; const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0; @@ -1613,6 +1625,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe {money(ac, currency)} {money(v, currency)} {b ? `${pct.toFixed(1)}%` : "—"} + {cmpOn && {money(c, currency)}} + {cmpOn && {money(ac - c, currency)}} ); })}