From b243256e80c41394f3ddd69d82e38da25cd07262 Mon Sep 17 00:00:00 2001 From: renee-png Date: Sat, 13 Jun 2026 12:01:31 -0400 Subject: [PATCH] Cash Disbursement report: group payments by vendor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/CashDisbursementReport.tsx | 69 ++++++++++++------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/src/pages/accounting/components/CashDisbursementReport.tsx b/src/pages/accounting/components/CashDisbursementReport.tsx index 15a2e0b..1703d40 100644 --- a/src/pages/accounting/components/CashDisbursementReport.tsx +++ b/src/pages/accounting/components/CashDisbursementReport.tsx @@ -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(); + const groups = new Map(); 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: Paid Date - CheckNo + CheckNo + Bank Description Invoice Date Amount @@ -282,9 +297,9 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from: {report!.groups.map((g) => ( <> - - - {g.bankLabel} + + + {g.vendor} {g.entries.map((e) => ( @@ -292,12 +307,14 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from: {fmtDate(e.date)} {e.checkNo} + {e.bank} {e.description} {e.invoiceDate ? fmtDate(e.invoiceDate) : ""} {num(e.amount)} {e.lines.map((l, li) => ( + @@ -310,7 +327,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from: ))} - Total {g.bankLabel} + Total — {g.vendor} {num(g.subtotal)} @@ -318,7 +335,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from: - Total Disbursements + Total Disbursements {num(report!.grandTotal)}