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). // Shared fetch for the financial reports (also used by the report-batch engine).
export async function fetchReportData(cid: string, from: string, to: string) { export async function fetchReportData(cid: string, from: string, to: string) {
const ytdStart = new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0,10); 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("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("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), 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 // 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. // vs GL control reconciliation (R7/R8) does not apply to them.
accounting.from("companies").select("gl_auto_post").eq("id", cid).maybeSingle(), 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 { return {
invoices: inv.data ?? [], bills: bills.data ?? [], accounts: accs.data ?? [], 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 ?? [], glCumulative: glCumRes ?? [],
allInvoices: allInvRes.data ?? [], allInvoices: allInvRes.data ?? [],
glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true, glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true,
periodBillItems: periodBillItemsRes.data ?? [],
from, asOf: to, 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))]), rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
}; };
case "expense-summary": { case "expense-summary": {
// GL-driven so it follows the same recognition rule as the P&L: a bill's // GL-driven, billed-date recognition (a bill's expense counts on the bill
// expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment // date Dr Expense / Cr A/P and a direct vendor payment on the payment
// with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the // date Dr Expense / Cr Bank), then we back out the expense of bills issued
// ledger avoids double-counting and never misses direct payments. // 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> = {}; const byAcct: Record<string, number> = {};
for (const l of (d.glLines ?? []) as any[]) { for (const l of (d.glLines ?? []) as any[]) {
const acc = l.accounts; const acc = l.accounts;
@@ -1808,9 +1814,23 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
const name = acc.name ?? "Expense"; const name = acc.name ?? "Expense";
byAcct[name] = (byAcct[name] ?? 0) + amt; byAcct[name] = (byAcct[name] ?? 0) + amt;
} }
const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]); // Remove the unpaid portion of period bills, prorated, keyed by account name.
const total = Object.values(byAcct).reduce((s, v) => s + v, 0); const acctNameById = new Map((d.accounts ?? []).map((a: any) => [a.id, a.name]));
return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] }; 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": { case "vendor-balances": {
const byVendor: Record<string, number> = {}; const byVendor: Record<string, number> = {};