Cash Disbursement report: group payments by vendor

Top-level grouping is now by vendor (name as the section header) with a
per-vendor subtotal, instead of by bank account. The bank account each payment
was drawn from moves to a column on the row. Vendor is resolved from the
reliable payment→vendor / payment→bill→vendor linkage; disbursements with no
vendor on record (e.g. Buildium GL-pull entries) collect under a 'No vendor on
record' group sorted last rather than being guessed. PDF + CSV updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:01:31 -04:00
parent ab6c2747fa
commit b243256e80
@@ -28,13 +28,16 @@ type Disbursement = {
jeId: string;
date: string;
checkNo: string;
bank: string;
description: string;
invoiceDate: string | null;
amount: number;
lines: GLLineRow[];
};
type BankGroup = { bankLabel: string; entries: Disbursement[]; subtotal: number };
type VendorGroup = { vendor: string; entries: Disbursement[]; subtotal: number };
const NO_VENDOR = "No vendor on record";
function monthStart() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; }
function today() { return new Date().toISOString().slice(0, 10); }
@@ -103,7 +106,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
byJe.set(je.id, g);
}
const groups = new Map<string, BankGroup>();
const groups = new Map<string, VendorGroup>();
let grandTotal = 0;
for (const { je, lines } of byJe.values()) {
@@ -116,26 +119,30 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
if (nonBankDebits.length === 0) continue; // pure transfer between banks
const amount = bankCredits.reduce((s, l) => s + Number(l.credit || 0), 0);
// Attribute the disbursement to the (largest) credited bank account
// Which bank account paid (largest credited bank line)
const mainBank = bankCredits.reduce((a, b) => (Number(b.credit) > Number(a.credit) ? b : a));
const bankLabel = acctLabel(acctById.get(mainBank.account_id));
const txn = je.external_source === "acmacc_txn" && je.external_id ? txnById.get(String(je.external_id)) : null;
const bill = txn?.bills ?? null;
const vendorName = bill?.vendors?.name || txn?.vendors?.name || null;
// Only attribute a vendor when it's reliably linked (payment→vendor or
// payment→bill→vendor). GL-pull (Buildium) disbursements carry no vendor,
// so they fall into the "No vendor on record" group rather than a guess.
const vendor = bill?.vendors?.name || txn?.vendors?.name || NO_VENDOR;
let checkNo = (txn?.reference || je.reference || "").toString().trim();
const descSource = `${txn?.description ?? ""} ${je.description ?? ""}`.toLowerCase();
if (!checkNo && /\bach\b|autopay|auto-pay|eft/.test(descSource)) checkNo = "ACH";
const description = vendorName
? `${vendorName}${bill?.number ? ` Inv # ${bill.number}` : ""}`
const description = bill?.number
? `Inv # ${bill.number}`
: (txn?.description || je.description || "—");
const entry: Disbursement = {
jeId: je.id,
date: String(je.date).slice(0, 10),
checkNo: checkNo || "—",
bank: bankLabel,
description,
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
amount,
@@ -147,14 +154,19 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
})),
};
const g = groups.get(bankLabel) ?? { bankLabel, entries: [], subtotal: 0 };
const g = groups.get(vendor) ?? { vendor, entries: [], subtotal: 0 };
g.entries.push(entry);
g.subtotal += amount;
groups.set(bankLabel, g);
groups.set(vendor, g);
grandTotal += amount;
}
const out = [...groups.values()].sort((a, b) => a.bankLabel.localeCompare(b.bankLabel));
// Vendors alphabetical; the unattributed bucket always sorts last.
const out = [...groups.values()].sort((a, b) => {
if (a.vendor === NO_VENDOR) return 1;
if (b.vendor === NO_VENDOR) return -1;
return a.vendor.localeCompare(b.vendor);
});
for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date));
return { groups: out, grandTotal };
}, [data]);
@@ -174,20 +186,22 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
const body: any[] = [];
for (const g of report.groups) {
body.push([{
content: g.bankLabel,
colSpan: 5,
content: g.vendor,
colSpan: 6,
styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10, fillColor: [255, 255, 255] },
}]);
for (const e of g.entries) {
body.push([
{ content: fmtDate(e.date), styles: { fontStyle: "bold" } },
{ content: e.checkNo, styles: { fontStyle: "bold" } },
{ content: e.bank, styles: { fontStyle: "bold" } },
{ content: e.description, styles: { fontStyle: "bold" } },
{ content: e.invoiceDate ? fmtDate(e.invoiceDate) : "", styles: { fontStyle: "bold", halign: "right" } },
{ content: num(e.amount), styles: { fontStyle: "bold", halign: "right" } },
]);
for (const l of e.lines) {
body.push([
"",
"",
"",
{ content: ` ${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`, styles: { textColor: [90, 90, 90] } },
@@ -197,22 +211,22 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
}
}
body.push([
{ content: `Total ${g.bankLabel}`, colSpan: 4, styles: { fontStyle: "bold", halign: "right" } },
{ content: `Total ${g.vendor}`, colSpan: 5, styles: { fontStyle: "bold", halign: "right" } },
{ content: num(g.subtotal), styles: { fontStyle: "bold", halign: "right" } },
]);
}
body.push([
{ content: "Total Disbursements", colSpan: 4, styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
{ content: "Total Disbursements", colSpan: 5, styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
{ content: num(report.grandTotal), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
]);
autoTable(doc, {
startY,
head: [["Paid Date", "CheckNo", "Description", "Invoice Date", "Amount"]],
head: [["Paid Date", "CheckNo", "Bank", "Description", "Invoice Date", "Amount"]],
body,
styles: { fontSize: 8, cellPadding: 3 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 70 }, 1: { cellWidth: 80 }, 3: { cellWidth: 80, halign: "right" }, 4: { cellWidth: 80, halign: "right" } },
columnStyles: { 0: { cellWidth: 64 }, 1: { cellWidth: 64 }, 2: { cellWidth: 110 }, 4: { cellWidth: 70, halign: "right" }, 5: { cellWidth: 75, halign: "right" } },
margin: { left: ML, right: ML },
});
@@ -224,17 +238,17 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
if (!report) return;
const q = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
const f = (n: number) => n.toFixed(2);
const lines = [["Bank Account", "Paid Date", "CheckNo", "Description", "GL Account", "Invoice Date", "Amount"].join(",")];
const lines = [["Vendor", "Paid Date", "CheckNo", "Bank Account", "Description", "GL Account", "Invoice Date", "Amount"].join(",")];
for (const g of report.groups) {
for (const e of g.entries) {
lines.push([q(g.bankLabel), e.date, q(e.checkNo), q(e.description), "", e.invoiceDate ?? "", f(e.amount)].join(","));
lines.push([q(g.vendor), e.date, q(e.checkNo), q(e.bank), q(e.description), "", e.invoiceDate ?? "", f(e.amount)].join(","));
for (const l of e.lines) {
lines.push([q(g.bankLabel), "", "", "", q(`${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`), "", f(l.amount)].join(","));
lines.push([q(g.vendor), "", "", "", "", q(`${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`), "", f(l.amount)].join(","));
}
}
lines.push([q(g.bankLabel), "", "", q(`Total ${g.bankLabel}`), "", "", f(g.subtotal)].join(","));
lines.push([q(g.vendor), "", "", "", q(`Total ${g.vendor}`), "", "", f(g.subtotal)].join(","));
}
lines.push(["", "", "", "Total Disbursements", "", "", f(report.grandTotal)].join(","));
lines.push(["", "", "", "", "Total Disbursements", "", "", f(report.grandTotal)].join(","));
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
@@ -273,7 +287,8 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
<thead>
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold w-24">Paid Date</th>
<th className="px-3 py-2 text-left font-semibold w-28">CheckNo</th>
<th className="px-3 py-2 text-left font-semibold w-24">CheckNo</th>
<th className="px-3 py-2 text-left font-semibold w-44">Bank</th>
<th className="px-3 py-2 text-left font-semibold">Description</th>
<th className="px-3 py-2 text-right font-semibold w-28">Invoice Date</th>
<th className="px-3 py-2 text-right font-semibold w-28">Amount</th>
@@ -282,9 +297,9 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
<tbody>
{report!.groups.map((g) => (
<>
<tr key={g.bankLabel}>
<td colSpan={5} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
{g.bankLabel}
<tr key={g.vendor}>
<td colSpan={6} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
{g.vendor}
</td>
</tr>
{g.entries.map((e) => (
@@ -292,12 +307,14 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
<tr key={e.jeId} className="border-t">
<td className="px-3 py-1.5 font-medium whitespace-nowrap">{fmtDate(e.date)}</td>
<td className="px-3 py-1.5 font-medium">{e.checkNo}</td>
<td className="px-3 py-1.5">{e.bank}</td>
<td className="px-3 py-1.5 font-medium">{e.description}</td>
<td className="px-3 py-1.5 text-right whitespace-nowrap">{e.invoiceDate ? fmtDate(e.invoiceDate) : ""}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-medium">{num(e.amount)}</td>
</tr>
{e.lines.map((l, li) => (
<tr key={`${e.jeId}-${li}`}>
<td />
<td />
<td />
<td className="px-3 py-0.5 pl-8 text-muted-foreground text-[13px]">
@@ -310,7 +327,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
</>
))}
<tr className="border-t font-semibold">
<td colSpan={4} className="px-3 py-1.5 text-right">Total {g.bankLabel}</td>
<td colSpan={5} className="px-3 py-1.5 text-right">Total {g.vendor}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{num(g.subtotal)}</td>
</tr>
</>
@@ -318,7 +335,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
</tbody>
<tfoot>
<tr className="border-t-2 border-b-2 font-bold">
<td colSpan={4} className="px-3 py-2 text-right">Total Disbursements</td>
<td colSpan={5} className="px-3 py-2 text-right">Total Disbursements</td>
<td className="px-3 py-2 text-right tabular-nums">{num(report!.grandTotal)}</td>
</tr>
</tfoot>