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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 00:52:26 -04:00
parent a1926b0623
commit 91882a0422
+28 -8
View File
@@ -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<string, number> = {};
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<string, number> = {};