A/R reports: exclude void/paid/draft invoices from open balances

The AR Aging, Invoice Summary by Customer, Homeowner Summary, and
Delinquency reports computed open A/R as total - paid_amount across all
invoices, which counted (1) voided invoices and (2) ledger-synced "paid"
invoices whose paid_amount was left at 0. Both showed huge phantom
past-due balances even when everything was paid.

Add a shared invoiceOpen() helper that treats void/paid/draft invoices as
zero open (status is the source of truth, since paid_amount is unreliable
on synced invoices) and apply it to all A/R computation sites plus the
reconciliation control. Also drop voided invoices from the flat Invoice
Summary list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:45:18 -04:00
parent 7eb08ad29f
commit 7b1d6a59e9
+20 -9
View File
@@ -125,6 +125,16 @@ async function fetchAllGLLines(cid: string, to: string, select: string, from?: s
return out; return out;
} }
// An invoice contributes to A/R only while it is an open receivable. `paid_amount`
// is unreliable on ledger-synced invoices (the owner_ledger→AR sync often leaves it
// at 0 even when status is "paid"), so the *status* is the source of truth here:
// void / paid / draft invoices never count as open, regardless of paid_amount.
const AR_CLOSED_STATUSES = new Set(["void", "paid", "draft"]);
export function invoiceOpen(inv: any): number {
if (AR_CLOSED_STATUSES.has(String(inv?.status))) return 0;
return Number(inv?.total ?? 0) - Number(inv?.paid_amount ?? 0);
}
// 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);
@@ -373,7 +383,8 @@ export default function AccountingReportsPage({ association }: { association?: {
const { data } = await accounting const { data } = await accounting
.from("invoices") .from("invoices")
.select("id,customer_id,total,paid_amount,due_date,issue_date,status,number,customers(id,name)") .select("id,customer_id,total,paid_amount,due_date,issue_date,status,number,customers(id,name)")
.eq("company_id", cid); .eq("company_id", cid)
.neq("status", "void");
return data ?? []; return data ?? [];
}, },
}); });
@@ -412,7 +423,7 @@ export default function AccountingReportsPage({ association }: { association?: {
type AR = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number }; type AR = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
const byC = new Map<string, AR>(); const byC = new Map<string, AR>();
for (const inv of arOpen as any[]) { for (const inv of arOpen as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const open = invoiceOpen(inv);
if (open <= 0) continue; if (open <= 0) continue;
const cid2 = inv.customer_id; if (!cid2) continue; const cid2 = inv.customer_id; if (!cid2) continue;
const name = inv.customers?.name ?? "—"; const name = inv.customers?.name ?? "—";
@@ -454,7 +465,7 @@ export default function AccountingReportsPage({ association }: { association?: {
const customers = (data as any)?.customers ?? []; const customers = (data as any)?.customers ?? [];
const openByC = new Map<string, { open: number; count: number }>(); const openByC = new Map<string, { open: number; count: number }>();
for (const inv of arOpen as any[]) { for (const inv of arOpen as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const open = invoiceOpen(inv);
if (open > 0) { const e = openByC.get(inv.customer_id) ?? { open: 0, count: 0 }; e.open += open; e.count++; openByC.set(inv.customer_id, e); } if (open > 0) { const e = openByC.get(inv.customer_id) ?? { open: 0, count: 0 }; e.open += open; e.count++; openByC.set(inv.customer_id, e); }
} }
const rows = (customers as any[]) const rows = (customers as any[])
@@ -467,7 +478,7 @@ export default function AccountingReportsPage({ association }: { association?: {
const customers = (data as any)?.customers ?? []; const customers = (data as any)?.customers ?? [];
const overdue = new Map<string, { name: string; property: string; email: string; phone: string; amount: number; oldest: number }>(); const overdue = new Map<string, { name: string; property: string; email: string; phone: string; amount: number; oldest: number }>();
for (const inv of arOpen as any[]) { for (const inv of arOpen as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const open = invoiceOpen(inv);
if (open <= 0) continue; if (open <= 0) continue;
const due = new Date(inv.due_date ?? inv.issue_date); const due = new Date(inv.due_date ?? inv.issue_date);
const days = Math.floor((now.getTime() - due.getTime()) / 86400000); const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
@@ -1296,7 +1307,7 @@ function buildReconChecks(d: any): RecCheck[] {
account_id: l.account_id, date: String(l.journal_entries?.date ?? ""), account_id: l.account_id, date: String(l.journal_entries?.date ?? ""),
debit: Number(l.debit || 0), credit: Number(l.credit || 0), debit: Number(l.debit || 0), credit: Number(l.credit || 0),
})); }));
const openInv = ((d.allInvoices ?? []) as any[]).filter((i) => i.status !== "void").reduce((s, i) => s + (Number(i.total || 0) - Number(i.paid_amount || 0)), 0); const openInv = ((d.allInvoices ?? []) as any[]).reduce((s, i) => s + invoiceOpen(i), 0);
const openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0); const openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0);
const pl = buildPnL(d, undefined, false); const pl = buildPnL(d, undefined, false);
const plNI = pl.rows.find((r) => r.kind === "grand" && /net income/i.test(r.label))?.amount; const plNI = pl.rows.find((r) => r.kind === "grand" && /net income/i.test(r.label))?.amount;
@@ -1887,7 +1898,7 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
case "invoice-summary": case "invoice-summary":
return { return {
title: "Invoice Summary", columns: ["Invoice #", "Customer", "Date", "Status", "Amount"], title: "Invoice Summary", columns: ["Invoice #", "Customer", "Date", "Status", "Amount"],
rows: d.invoices.map((i: any) => [i.number, i.customers?.name ?? "—", fmtDate(i.issue_date), i.status, m(Number(i.total))]), rows: d.invoices.filter((i: any) => i.status !== "void").map((i: any) => [i.number, i.customers?.name ?? "—", fmtDate(i.issue_date), i.status, m(Number(i.total))]),
}; };
case "customer-balances": case "customer-balances":
return { return {
@@ -2427,7 +2438,7 @@ function HomeownerSummaryTable({ customers, invoices, currency }: { customers: a
for (const inv of invoices) { for (const inv of invoices) {
const cid = inv.customer_id; const cid = inv.customer_id;
if (!cid) continue; if (!cid) continue;
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const open = invoiceOpen(inv);
const isPaid = inv.status === "paid"; const isPaid = inv.status === "paid";
const cur = m.get(cid) ?? { open: 0, count: 0, lastPaid: null }; const cur = m.get(cid) ?? { open: 0, count: 0, lastPaid: null };
if (open > 0) { cur.open += open; cur.count++; } if (open > 0) { cur.open += open; cur.count++; }
@@ -2486,7 +2497,7 @@ function DelinquencyTable({ customers, invoices, currency }: { customers: any[];
const byCustomer = new Map<string, DelRow>(); const byCustomer = new Map<string, DelRow>();
for (const inv of invoices) { for (const inv of invoices) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const open = invoiceOpen(inv);
if (open <= 0) continue; if (open <= 0) continue;
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date ?? Date.now()); const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date ?? Date.now());
const days = Math.floor((now.getTime() - due.getTime()) / 86400000); const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
@@ -2574,7 +2585,7 @@ function ARAgingTable({ rows, currency, detailed = false }: { rows: any[]; curre
type AgingRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number }; type AgingRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
const byCustomer = new Map<string, AgingRow>(); const byCustomer = new Map<string, AgingRow>();
for (const inv of rows) { for (const inv of rows) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const open = invoiceOpen(inv);
if (open <= 0) continue; if (open <= 0) continue;
const cid = inv.customer_id ?? inv.customers?.id; const cid = inv.customer_id ?? inv.customers?.id;
if (!cid) continue; if (!cid) continue;