From 91882a042262507880e1053a50ce22827c6b2feb Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 14 Jun 2026 00:52:26 -0400 Subject: [PATCH] Expense Summary: exclude unpaid bills (billed-but-paid view) Expense Summary kept billed-date recognition but counted bills the association hasn't paid yet. Back out the unpaid (prorated) portion of period bills per expense account, so the report reflects amounts actually paid. Direct payments are unaffected (cash already out). Bent Oak's open 5/27 City of Titusville water bill ($136) now drops out: Water/Sewer 757.25 -> 621.25. Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingReportsPage.tsx | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 17c719a..095e535 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -125,7 +125,7 @@ async function fetchAllGLLines(cid: string, to: string, select: string, from?: s // Shared fetch for the financial reports (also used by the report-batch engine). export async function fetchReportData(cid: string, from: string, to: string) { const ytdStart = new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0,10); - const [inv, bills, accs, exp, custs, vends, ob, ytdInv, ytdExp, ytdBills, allBills, glRes, glCumRes, allInvRes, companyRes] = await Promise.all([ + const [inv, bills, accs, exp, custs, vends, ob, ytdInv, ytdExp, ytdBills, allBills, glRes, glCumRes, allInvRes, companyRes, periodBillItemsRes] = await Promise.all([ accounting.from("invoices").select("number,total,paid_amount,status,issue_date,customers(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to), accounting.from("bills").select("number,total,paid_amount,status,issue_date,due_date,vendors(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to), accounting.from("accounts").select("id,name,code,type,subtype,balance,is_bank").eq("company_id", cid).eq("is_archived", false), @@ -149,6 +149,9 @@ export async function fetchReportData(cid: string, from: string, to: string) { // Imported-GL companies (gl_auto_post=false) keep their own AR/AP, so the sub-ledger // vs GL control reconciliation (R7/R8) does not apply to them. accounting.from("companies").select("gl_auto_post").eq("id", cid).maybeSingle(), + // Bill items for bills issued in the period — lets the Expense Summary + // back out the expense of bills that aren't paid yet (cash-aware view). + accounting.from("bill_items").select("account_id,amount,bills!inner(total,paid_amount,status,issue_date,company_id)").eq("bills.company_id", cid).gte("bills.issue_date", from).lte("bills.issue_date", to).not("account_id", "is", null), ]); return { invoices: inv.data ?? [], bills: bills.data ?? [], accounts: accs.data ?? [], @@ -160,6 +163,7 @@ export async function fetchReportData(cid: string, from: string, to: string) { glCumulative: glCumRes ?? [], allInvoices: allInvRes.data ?? [], glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true, + periodBillItems: periodBillItemsRes.data ?? [], from, asOf: to, }; } @@ -1795,10 +1799,12 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null { rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]), }; case "expense-summary": { - // GL-driven so it follows the same recognition rule as the P&L: a bill's - // expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment - // with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the - // ledger avoids double-counting and never misses direct payments. + // GL-driven, billed-date recognition (a bill's expense counts on the bill + // date — Dr Expense / Cr A/P — and a direct vendor payment on the payment + // date — Dr Expense / Cr Bank), then we back out the expense of bills issued + // in the period that aren't paid yet, so the summary reflects amounts the + // association has actually paid. Direct payments are cash already, so they + // stay; only the unpaid portion of open bills is removed. const byAcct: Record = {}; for (const l of (d.glLines ?? []) as any[]) { const acc = l.accounts; @@ -1808,9 +1814,23 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null { const name = acc.name ?? "Expense"; byAcct[name] = (byAcct[name] ?? 0) + amt; } - const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]); - const total = Object.values(byAcct).reduce((s, v) => s + v, 0); - return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] }; + // Remove the unpaid portion of period bills, prorated, keyed by account name. + const acctNameById = new Map((d.accounts ?? []).map((a: any) => [a.id, a.name])); + for (const bi of (d.periodBillItems ?? []) as any[]) { + const b = bi.bills; + if (!b || b.status === "void") continue; + const total = Number(b.total) || 0; + if (total <= 0) continue; + const unpaid = total - (Number(b.paid_amount) || 0); + if (unpaid <= 0) continue; + const name = acctNameById.get(bi.account_id); + if (!name || byAcct[name] === undefined) continue; + byAcct[name] -= Number(bi.amount) * (unpaid / total); + } + const kept = Object.entries(byAcct).filter(([, amt]) => Math.abs(amt) >= 0.005); + const rows = kept.sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]); + const total = kept.reduce((s, [, v]) => s + v, 0); + return { title: "Expense Summary (Paid)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] }; } case "vendor-balances": { const byVendor: Record = {};