From fe5d897139f9982aa732a3ce213caf6e749abe4c Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 16 Jun 2026 14:07:10 -0400 Subject: [PATCH] Budget vs Actuals: source actuals from the GL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accounting Budget vs. Actuals report computed actuals from operational tables (bill_items + payment transactions + a budget-weighted paid-invoice plug), which double-counted (a bill counted as its line item AND its payment, plus redundant imported bills) and diverged from the posted books — especially for Buildium GL-import / import-mode associations whose activity lives only in journal entries. Now fetch GL lines via fetchAllGLLines and net per account (income = credit - debit, expense = debit - credit), matching the Income Statement. Budget side was already correct (reads active accounting.budgets + budget_entries). Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingReportsPage.tsx | 65 ++++++++----------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 36d777c..e7ff9eb 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -1904,46 +1904,33 @@ function orderAccountsHierarchically(accs: any[]): any[] { } 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 come straight from the General Ledger so Budget vs. Actuals ties to + // the Income Statement and works for GL-import / import-mode associations whose + // activity lives only in journal entries. Sourcing from the operational tables + // (bills + payment transactions + a budget-weighted invoice "plug") both + // double-counted (a bill counted as its line item AND its payment, plus any + // redundant imported bills) and diverged from the posted books. + const lines = await fetchAllGLLines( + companyId, + t, + "debit,credit,account_id,accounts!inner(id,type),journal_entries!inner(company_id,date)", + f, + ); + return { lines }; } -// 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 { +// Actuals per account, netted from the GL: income = credit − debit, +// expense = debit − credit (the same convention as every other report here). +function computeBvaActuals(actualsData: any): 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; - } + for (const l of (actualsData?.lines ?? []) as any[]) { + const acctId = l.account_id ?? l.accounts?.id; + if (!acctId) continue; + const type = l.accounts?.type; + if (type !== "income" && type !== "expense") continue; + const debit = Number(l.debit || 0); + const credit = Number(l.credit || 0); + m[acctId] = (m[acctId] ?? 0) + (type === "income" ? credit - debit : debit - credit); } return m; } @@ -2039,8 +2026,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe return m; }, [entries, selectedBudget, actFrom, actTo]); - const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]); - const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]); + const actualByAcct = useMemo(() => computeBvaActuals(actualsData), [actualsData]); + const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData), [cmpActualsData]); // Comparison-window budget (pro-rated like budgetByAcct, over [cmpFrom, cmpTo]). const cmpBudgetByAcct = useMemo(() => {