Budget vs Actuals: source actuals from the GL

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:07:10 -04:00
parent d7d53c5022
commit fe5d897139
+26 -39
View File
@@ -1904,46 +1904,33 @@ function orderAccountsHierarchically(accs: any[]): any[] {
} }
async function fetchBvaActuals(companyId: string, f: string, t: string) { async function fetchBvaActuals(companyId: string, f: string, t: string) {
const [inv, exp, txns, billItemsRes] = await Promise.all([ // Actuals come straight from the General Ledger so Budget vs. Actuals ties to
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")'), // the Income Statement and works for GL-import / import-mode associations whose
accounting.from("expenses").select("amount,category,date").eq("company_id", companyId).gte("date", f).lte("date", t), // activity lives only in journal entries. Sourcing from the operational tables
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), // (bills + payment transactions + a budget-weighted invoice "plug") both
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), // double-counted (a bill counted as its line item AND its payment, plus any
]); // redundant imported bills) and diverged from the posted books.
return { invoices: inv.data ?? [], expenses: exp.data ?? [], transactions: txns.data ?? [], billItems: billItemsRes.data ?? [] }; 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, // Actuals per account, netted from the GL: income = credit debit,
// expenses, and accrual invoice income distributed by budget weight). // expense = debit credit (the same convention as every other report here).
function computeBvaActuals(actualsData: any, grouped: Record<string, any[]>, budgetByAcct: Record<string, number>): Record<string, number> { function computeBvaActuals(actualsData: any): Record<string, number> {
const m: Record<string, number> = {}; const m: Record<string, number> = {};
if (!actualsData) return m; for (const l of (actualsData?.lines ?? []) as any[]) {
const expAccs = grouped.expense ?? []; const acctId = l.account_id ?? l.accounts?.id;
for (const tx of actualsData.transactions as any[]) { if (!acctId) continue;
if (!tx.coa_account_id) continue; const type = l.accounts?.type;
m[tx.coa_account_id] = (m[tx.coa_account_id] ?? 0) + Number(tx.amount); if (type !== "income" && type !== "expense") continue;
} const debit = Number(l.debit || 0);
for (const bi of actualsData.billItems as any[]) { const credit = Number(l.credit || 0);
if (!bi.account_id) continue; m[acctId] = (m[acctId] ?? 0) + (type === "income" ? credit - debit : debit - credit);
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; return m;
} }
@@ -2039,8 +2026,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
return m; return m;
}, [entries, selectedBudget, actFrom, actTo]); }, [entries, selectedBudget, actFrom, actTo]);
const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]); const actualByAcct = useMemo(() => computeBvaActuals(actualsData), [actualsData]);
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]); const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData), [cmpActualsData]);
// Comparison-window budget (pro-rated like budgetByAcct, over [cmpFrom, cmpTo]). // Comparison-window budget (pro-rated like budgetByAcct, over [cmpFrom, cmpTo]).
const cmpBudgetByAcct = useMemo(() => { const cmpBudgetByAcct = useMemo(() => {