From e510a76dfce71bc2dc2ef15f324910366d7537ea Mon Sep 17 00:00:00 2001 From: renee-png Date: Fri, 12 Jun 2026 17:26:30 -0400 Subject: [PATCH] Accounting reports: AR Aging (Property), Pre-Paid Homeowners, Cash Disbursement Buildium-style reports built on the owner ledger and GL: - AR Aging (Property): FIFO-aged buckets (0-30/over 30/60/90) per unit with charge-type breakdown, collection status, summary + distribution bar - Pre-Paid Homeowners: units with net credit balances as of a date - Cash Disbursement: bank-credit GL entries grouped by bank account with check#/vendor/invoice enrichment from the banking register and GL line detail All with branded PDF/CSV exports; shared owner-ledger helpers in lib/ownerLedger.ts Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingReportsPage.tsx | 24 +- .../components/ARAgingPropertyReport.tsx | 432 ++++++++++++++++++ .../components/CashDisbursementReport.tsx | 334 ++++++++++++++ .../components/PrepaidHomeownersReport.tsx | 180 ++++++++ src/pages/accounting/lib/ownerLedger.ts | 126 +++++ 5 files changed, 1092 insertions(+), 4 deletions(-) create mode 100644 src/pages/accounting/components/ARAgingPropertyReport.tsx create mode 100644 src/pages/accounting/components/CashDisbursementReport.tsx create mode 100644 src/pages/accounting/components/PrepaidHomeownersReport.tsx create mode 100644 src/pages/accounting/lib/ownerLedger.ts diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index d3bbc16..4127fb4 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -31,6 +31,9 @@ import { Lock } from "lucide-react"; import { TrialBalanceReport } from "./components/TrialBalanceReport"; import { GeneralLedgerReport } from "./components/GeneralLedgerReport"; import { ReserveFundReport } from "./components/ReserveFundReport"; +import { ARAgingPropertyReport } from "./components/ARAgingPropertyReport"; +import { PrepaidHomeownersReport } from "./components/PrepaidHomeownersReport"; +import { CashDisbursementReport } from "./components/CashDisbursementReport"; import { ReportSheet } from "./components/ReportSheet"; import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader"; import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf"; @@ -38,8 +41,8 @@ import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf"; type ReportId = | "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals" | "trial-balance" | "general-ledger" - | "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency" - | "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation" + | "invoice-summary" | "customer-balances" | "ar-aging" | "ar-aging-property" | "prepaid-homeowners" | "homeowner-summary" | "delinquency" + | "expense-summary" | "vendor-balances" | "ap-aging" | "cash-disbursement" | "reconciliation" | "reserve-fund"; const APP_NAME = "Cozy Books"; @@ -57,6 +60,8 @@ const GROUPS = [ { id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" }, ]}, { name: "Receivables", reports: [ + { id: "ar-aging-property" as ReportId, name: "AR Aging (Property)" }, + { id: "prepaid-homeowners" as ReportId, name: "Pre-Paid Homeowners" }, { id: "ar-aging" as ReportId, name: "AR Aging Details" }, { id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" }, { id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" }, @@ -64,6 +69,7 @@ const GROUPS = [ { id: "delinquency" as ReportId, name: "Delinquency Report" }, ]}, { name: "Payables", reports: [ + { id: "cash-disbursement" as ReportId, name: "Cash Disbursement" }, { id: "ap-aging" as ReportId, name: "AP Aging Details" }, { id: "expense-summary" as ReportId, name: "Expense Summary" }, { id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" }, @@ -396,7 +402,8 @@ export default function AccountingReportsPage({ association }: { association?: { }, [active, arOpen, data, flat, structured, cur, activeMeta.name]); // Reports whose export is handled internally (own PDF/CSV buttons inside the component) - const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals" || active === "income-statement"; + const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals" || active === "income-statement" + || active === "ar-aging-property" || active === "prepaid-homeowners" || active === "cash-disbursement"; const anyExportable = !!(structured || flat || exportFlat); const doExportPDF = async () => { @@ -588,6 +595,15 @@ export default function AccountingReportsPage({ association }: { association?: { {active === "reserve-fund" && ( )} + {active === "ar-aging-property" && ( + + )} + {active === "prepaid-homeowners" && ( + + )} + {active === "cash-disbursement" && ( + + )} {active === "reconciliation" && ( @@ -604,7 +620,7 @@ export default function AccountingReportsPage({ association }: { association?: {
No data for this report in the selected range.
) )} - {!isFinancial && active !== "budget-vs-actuals" && active !== "income-statement" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && ( + {!isFinancial && active !== "budget-vs-actuals" && active !== "income-statement" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && active !== "ar-aging-property" && active !== "prepaid-homeowners" && active !== "cash-disbursement" && ( {!data ? (
Loading…
diff --git a/src/pages/accounting/components/ARAgingPropertyReport.tsx b/src/pages/accounting/components/ARAgingPropertyReport.tsx new file mode 100644 index 0000000..fc38403 --- /dev/null +++ b/src/pages/accounting/components/ARAgingPropertyReport.tsx @@ -0,0 +1,432 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { FileDown, Download } from "lucide-react"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import { ReportSheet } from "./ReportSheet"; +import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader"; +import { + fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners, + unitLabel, chargeTypeLabel, money, + type OwnerLedgerEntry, type UnitInfo, type OwnerInfo, +} from "../lib/ownerLedger"; + +const TEAL: [number, number, number] = [0, 137, 123]; +const BUCKETS = ["0-30", "Over 30", "Over 60", "Over 90"] as const; +const BUCKET_COLORS: [number, number, number][] = [ + [141, 178, 85], // green (0-30) + [234, 179, 8], // amber (over 30) + [234, 124, 8], // orange (over 60) + [220, 68, 68], // red (over 90) +]; + +type Buckets = [number, number, number, number]; + +type UnitAging = { + key: string; + label: string; + collStatus: string | null; + buckets: Buckets; + total: number; + byType: Map; +}; + +function emptyBuckets(): Buckets { return [0, 0, 0, 0]; } +function bucketIndex(asOf: string, chargeDate: string): number { + const days = Math.floor((new Date(asOf + "T00:00:00").getTime() - new Date(chargeDate + "T00:00:00").getTime()) / 86400000); + if (days <= 30) return 0; + if (days <= 60) return 1; + if (days <= 90) return 2; + return 3; +} + +const prettyStatus = (s: string | null | undefined) => + s ? s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : null; + +const dash = (n: number) => (n ? money(n) : "-"); + +/** + * Buildium-style AR Aging: per-property open charge balances aged into + * 0-30 / Over 30 / Over 60 / Over 90 buckets, with charge-type breakdown, + * collection status, summary and distribution. Payments and credits apply to + * charges oldest-first (FIFO), so only genuinely open charge amounts age. + */ +export function ARAgingPropertyReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) { + const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10)); + + const { data, isLoading } = useQuery({ + queryKey: ["ar-aging-property", companyId, asOf], + enabled: !!companyId, + queryFn: async () => { + const associationId = await fetchAssociationId(companyId); + if (!associationId) return null; + const [entries, { units, owners }, collectionsRes] = await Promise.all([ + fetchOwnerLedger(associationId, asOf), + fetchUnitsAndOwners(associationId), + supabase.from("collections").select("unit_id, owner_id, status, updated_at").eq("association_id", associationId).order("updated_at", { ascending: false }), + ]); + return { entries, units, owners, collections: (collectionsRes.data ?? []) as any[] }; + }, + }); + + const report = useMemo(() => { + if (!data) return null; + const { entries, units, owners, collections } = data; + + const unitById = new Map(); + for (const u of units) unitById.set(u.id, u); + const ownerById = new Map(); + for (const o of owners) ownerById.set(o.id, o); + const ownerByUnit = new Map(); + for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o); + + // Latest collection status per unit (rows came back newest-first) + const collByUnit = new Map(); + const collByOwner = new Map(); + for (const c of collections) { + if (c.unit_id && !collByUnit.has(c.unit_id)) collByUnit.set(c.unit_id, c.status); + if (c.owner_id && !collByOwner.has(c.owner_id)) collByOwner.set(c.owner_id, c.status); + } + + // Group ledger entries per unit (fall back to owner when the entry has no unit) + const byUnit = new Map(); + for (const e of entries) { + const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null; + if (!key) continue; + const list = byUnit.get(key) ?? []; + list.push(e); + byUnit.set(key, list); + } + + const rows: UnitAging[] = []; + for (const [key, list] of byUnit) { + list.sort((a, b) => a.date.localeCompare(b.date)); + // FIFO: total credits pay down the oldest charges first + let creditPool = list.reduce((s, e) => s + e.credit, 0); + const buckets = emptyBuckets(); + const byType = new Map(); + let total = 0; + + for (const e of list) { + if (e.debit <= 0) continue; + let open = e.debit; + if (creditPool > 0) { + const applied = Math.min(creditPool, open); + creditPool -= applied; + open -= applied; + } + if (open <= 0.004) continue; + const bi = bucketIndex(asOf, e.date); + buckets[bi] += open; + total += open; + const label = chargeTypeLabel(e.transaction_type); + const t = byType.get(label) ?? { buckets: emptyBuckets(), total: 0 }; + t.buckets[bi] += open; + t.total += open; + byType.set(label, t); + } + if (total <= 0.004) continue; + + const unitId = key.startsWith("u:") ? key.slice(2) : null; + const ownerId = key.startsWith("o:") ? key.slice(2) : null; + const unit = unitId ? unitById.get(unitId) : undefined; + const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null; + const collStatus = prettyStatus((unitId && collByUnit.get(unitId)) || (owner && collByOwner.get(owner.id)) || null); + + rows.push({ + key, + label: unitLabel(unit, owner?.last_name ?? null), + collStatus, + buckets, + total, + byType, + }); + } + + rows.sort((a, b) => a.label.localeCompare(b.label)); + + // Charge-type summary across all properties + const summary = new Map(); + for (const r of rows) { + for (const [label, t] of r.byType) { + const s = summary.get(label) ?? { count: 0, balance: 0 }; + s.count += 1; + s.balance += t.total; + summary.set(label, s); + } + } + const summaryRows = [...summary.entries()].sort((a, b) => b[1].balance - a[1].balance); + + const totals = rows.reduce((t, r) => [t[0] + r.buckets[0], t[1] + r.buckets[1], t[2] + r.buckets[2], t[3] + r.buckets[3]], emptyBuckets()); + const counts = rows.reduce<[number, number, number, number]>( + (t, r) => [t[0] + (r.buckets[0] > 0.004 ? 1 : 0), t[1] + (r.buckets[1] > 0.004 ? 1 : 0), t[2] + (r.buckets[2] > 0.004 ? 1 : 0), t[3] + (r.buckets[3] > 0.004 ? 1 : 0)], + [0, 0, 0, 0], + ); + const grandTotal = totals.reduce((s, n) => s + n, 0); + const distribution = totals.map((n) => (grandTotal > 0 ? (n / grandTotal) * 100 : 0)); + + return { rows, summaryRows, totals, counts, grandTotal, distribution }; + }, [data, asOf]); + + const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" }); + + const exportPDF = async () => { + if (!report) return; + const doc = new jsPDF({ unit: "pt", format: "letter" }); + const ML = 40; + const pageW = doc.internal.pageSize.getWidth(); + const logo = await loadBrandedLogo(logoUrl); + let y = drawBrandedHeader(doc, { + logo, title: "AR Aging", subtitle: `As of ${asOfLabel}`, + metaLines: [{ label: "Properties:", value: companyName || "" }], + }); + + // Summary (left) — charge types + autoTable(doc, { + startY: y, + head: [["Charge", "Balance"]], + body: [ + ...report.summaryRows.map(([label, s]) => [`${label} (${s.count})`, money(s.balance)]), + [{ content: "Total", styles: { fontStyle: "bold" } } as any, { content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } } as any], + ], + styles: { fontSize: 8, cellPadding: 4 }, + headStyles: { fillColor: TEAL, textColor: 255 }, + columnStyles: { 1: { halign: "right" } }, + margin: { left: ML, right: pageW / 2 + 20 }, + tableWidth: pageW / 2 - ML - 30, + }); + const summaryEndY = (doc as any).lastAutoTable.finalY; + + // Distribution (right) — stacked bucket bar with % legend + const barX = pageW / 2 + 20; + const barW = pageW - ML - barX; + let dy = y + 4; + doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(100); + doc.text("DISTRIBUTION", barX, dy); + dy += 8; + if (report.grandTotal > 0) { + let x = barX; + report.distribution.forEach((pct, i) => { + const w = (pct / 100) * barW; + if (w <= 0) return; + const c = BUCKET_COLORS[i]; + doc.setFillColor(c[0], c[1], c[2]); + doc.rect(x, dy, w, 14, "F"); + x += w; + }); + dy += 24; + doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); doc.setTextColor(60); + report.distribution.forEach((pct, i) => { + if (pct <= 0) return; + const c = BUCKET_COLORS[i]; + doc.setFillColor(c[0], c[1], c[2]); + doc.rect(barX, dy - 6, 7, 7, "F"); + doc.text(`${BUCKETS[i]}: ${pct.toFixed(2)} %`, barX + 11, dy); + dy += 12; + }); + } + y = Math.max(summaryEndY, dy) + 16; + + // Property detail + const body: any[] = []; + for (const r of report.rows) { + body.push([ + { content: r.label + (r.collStatus ? `\nColl Status: ${r.collStatus}` : ""), styles: { fontStyle: "bold" } }, + { content: dash(r.buckets[0]), styles: { fontStyle: "bold", halign: "right" } }, + { content: dash(r.buckets[1]), styles: { fontStyle: "bold", halign: "right" } }, + { content: dash(r.buckets[2]), styles: { fontStyle: "bold", halign: "right" } }, + { content: dash(r.buckets[3]), styles: { fontStyle: "bold", halign: "right" } }, + { content: money(r.total), styles: { fontStyle: "bold", halign: "right" } }, + ]); + for (const [label, t] of r.byType) { + body.push([ + { content: ` ${label}`, styles: {} }, + { content: dash(t.buckets[0]), styles: { halign: "right" } }, + { content: dash(t.buckets[1]), styles: { halign: "right" } }, + { content: dash(t.buckets[2]), styles: { halign: "right" } }, + { content: dash(t.buckets[3]), styles: { halign: "right" } }, + { content: money(t.total), styles: { halign: "right" } }, + ]); + } + } + body.push([ + { content: "Total:", styles: { fontStyle: "bold" } }, + ...report.totals.map((n) => ({ content: money(n), styles: { fontStyle: "bold", halign: "right" } })), + { content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } }, + ]); + body.push([ + { content: "Property Count:", styles: { fontStyle: "bold" } }, + ...report.counts.map((n) => ({ content: String(n), styles: { fontStyle: "bold", halign: "right" } })), + { content: "", styles: {} }, + ]); + + autoTable(doc, { + startY: y, + head: [["Property", "0-30", "Over 30", "Over 60", "Over 90", "Balance"]], + body, + styles: { fontSize: 8, cellPadding: 3 }, + headStyles: { fillColor: TEAL, textColor: 255 }, + columnStyles: { 0: { cellWidth: 220 }, 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" }, 5: { halign: "right" } }, + margin: { left: ML, right: ML }, + }); + + drawBrandedFooter(doc); + doc.save(`ar-aging-${asOf}.pdf`); + }; + + const exportCSV = () => { + if (!report) return; + const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2); + const lines = [["Property", "Coll Status", "Charge Type", "0-30", "Over 30", "Over 60", "Over 90", "Balance"].join(",")]; + const q = (s: string) => `"${s.replace(/"/g, '""')}"`; + for (const r of report.rows) { + lines.push([q(r.label), q(r.collStatus ?? ""), "", f(r.buckets[0]), f(r.buckets[1]), f(r.buckets[2]), f(r.buckets[3]), f(r.total)].join(",")); + for (const [label, t] of r.byType) { + lines.push([q(r.label), "", q(label), f(t.buckets[0]), f(t.buckets[1]), f(t.buckets[2]), f(t.buckets[3]), f(t.total)].join(",")); + } + } + lines.push(["TOTAL", "", "", ...report.totals.map(f), f(report.grandTotal)].join(",")); + const blob = new Blob([lines.join("\n")], { type: "text/csv" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `ar-aging-${asOf}.csv`; + a.click(); + URL.revokeObjectURL(a.href); + }; + + return ( +
+ + +
+ + setAsOf(e.target.value || asOf)} className="w-44 mt-1" /> +
+ {report && report.rows.length > 0 && ( +
+ + +
+ )} +
+
+ + {isLoading ? ( + Loading… + ) : !report || report.rows.length === 0 ? ( + + No open balances as of {asOfLabel}. 🎉 + + ) : ( + + {/* Summary + Distribution */} +
+
+

Summary

+ + + + + + + + + {report.summaryRows.map(([label, s]) => ( + + + + + ))} + + + + + +
ChargeBalance
{label} ({s.count}){money(s.balance)}
Total{money(report.grandTotal)}
+
+
+

Distribution

+
+ {report.distribution.map((pct, i) => pct > 0 && ( +
+ ))} +
+
+ {report.distribution.map((pct, i) => pct > 0 && ( +
+ + {BUCKETS[i]}: {pct.toFixed(2)} % +
+ ))} +
+
+
+ + {/* Property detail */} +
+ + + + + + + + + + + + + {report.rows.map((r) => ( + <> + + + + + + + + + {[...r.byType.entries()].map(([label, t]) => ( + + + + + + + + + ))} + + ))} + + + + + {report.totals.map((n, i) => )} + + + + + {report.counts.map((n, i) => )} + + +
Property0-30Over 30Over 60Over 90Balance
+ {r.label} + {r.collStatus &&
Coll Status: {r.collStatus}
} +
{dash(r.buckets[0])}{dash(r.buckets[1])}{dash(r.buckets[2])}{dash(r.buckets[3])}{money(r.total)}
{label}{dash(t.buckets[0])}{dash(t.buckets[1])}{dash(t.buckets[2])}{dash(t.buckets[3])}{money(t.total)}
Total:{money(n)}{money(report.grandTotal)}
Property Count:{n} +
+
+ + )} +
+ ); +} diff --git a/src/pages/accounting/components/CashDisbursementReport.tsx b/src/pages/accounting/components/CashDisbursementReport.tsx new file mode 100644 index 0000000..7cee68e --- /dev/null +++ b/src/pages/accounting/components/CashDisbursementReport.tsx @@ -0,0 +1,334 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { accounting } from "@/lib/accountingClient"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { FileDown, Download } from "lucide-react"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import { ReportSheet } from "./ReportSheet"; +import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader"; +import { num } from "../lib/ownerLedger"; +import { fmtDate } from "../lib/format"; + +const TEAL: [number, number, number] = [0, 137, 123]; + +type GLAccount = { id: string; code: string | null; name: string; is_bank: boolean }; + +type GLLineRow = { + code: string | null; + name: string; + description: string | null; + amount: number; +}; + +type Disbursement = { + jeId: string; + date: string; + checkNo: string; + description: string; + invoiceDate: string | null; + amount: number; + lines: GLLineRow[]; +}; + +type BankGroup = { bankLabel: string; entries: Disbursement[]; subtotal: number }; + +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); } + +const acctLabel = (a: GLAccount | undefined) => (a ? `${a.code ? a.code + " - " : ""}${a.name}` : "Unknown account"); + +/** + * Buildium-style Cash Disbursement: every payment out of a bank account in the + * period, grouped by bank account, with the GL expense breakdown under each. + * Built from the GL (bank-account credit lines), so it works for both + * platform-managed and Buildium-imported companies. Platform entries are + * enriched with check #, vendor and bill info from the banking register. + */ +export function CashDisbursementReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) { + const [from, setFrom] = useState(monthStart()); + const [to, setTo] = useState(today()); + + const { data, isLoading } = useQuery({ + queryKey: ["cash-disbursement", companyId, from, to], + enabled: !!companyId, + queryFn: async () => { + const [accountsRes, linesRes] = await Promise.all([ + accounting.from("accounts").select("id,code,name,is_bank").eq("company_id", companyId), + accounting + .from("journal_entry_lines") + .select("id,debit,credit,description,account_id,journal_entries!inner(id,company_id,date,description,reference,external_source,external_id)") + .eq("journal_entries.company_id", companyId) + .gte("journal_entries.date", from) + .lte("journal_entries.date", to) + .limit(50000), + ]); + const accounts = (accountsRes.data ?? []) as GLAccount[]; + const lines = (linesRes.data ?? []) as any[]; + + // Enrich register-posted entries with check # / vendor / bill from banking + const txnIds = [...new Set(lines + .filter((l) => l.journal_entries?.external_source === "acmacc_txn" && l.journal_entries?.external_id) + .map((l) => String(l.journal_entries.external_id)))]; + let txns: any[] = []; + if (txnIds.length > 0) { + const { data: t } = await accounting + .from("transactions") + .select("id,reference,description,vendor_id,bill_id,vendors(name),bills(number,issue_date,vendors(name))") + .in("id", txnIds); + txns = t ?? []; + } + return { accounts, lines, txns }; + }, + }); + + const report = useMemo(() => { + if (!data) return null; + const acctById = new Map(); + for (const a of data.accounts) acctById.set(a.id, a); + const txnById = new Map(); + for (const t of data.txns) txnById.set(String(t.id), t); + + // Group lines per journal entry + const byJe = new Map(); + for (const l of data.lines) { + const je = l.journal_entries; + if (!je?.id) continue; + const g = byJe.get(je.id) ?? { je, lines: [] }; + g.lines.push(l); + byJe.set(je.id, g); + } + + const groups = new Map(); + let grandTotal = 0; + + for (const { je, lines } of byJe.values()) { + if (je.external_source === "acmacc_xfer") continue; // bank-to-bank transfers aren't disbursements + + const bankCredits = lines.filter((l) => Number(l.credit || 0) > 0 && acctById.get(l.account_id)?.is_bank); + if (bankCredits.length === 0) continue; + + const nonBankDebits = lines.filter((l) => Number(l.debit || 0) > 0 && !acctById.get(l.account_id)?.is_bank); + 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 + 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; + + 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}` : ""}` + : (txn?.description || je.description || "—"); + + const entry: Disbursement = { + jeId: je.id, + date: String(je.date).slice(0, 10), + checkNo: checkNo || "—", + description, + invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null, + amount, + lines: nonBankDebits.map((l) => ({ + code: acctById.get(l.account_id)?.code ?? null, + name: acctById.get(l.account_id)?.name ?? "Unknown account", + description: l.description ?? null, + amount: Number(l.debit || 0), + })), + }; + + const g = groups.get(bankLabel) ?? { bankLabel, entries: [], subtotal: 0 }; + g.entries.push(entry); + g.subtotal += amount; + groups.set(bankLabel, g); + grandTotal += amount; + } + + const out = [...groups.values()].sort((a, b) => a.bankLabel.localeCompare(b.bankLabel)); + for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date)); + return { groups: out, grandTotal }; + }, [data]); + + const rangeLabel = `${fmtDate(from)} – ${fmtDate(to)}`; + + const exportPDF = async () => { + if (!report) return; + const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" }); + const ML = 40; + const logo = await loadBrandedLogo(logoUrl); + const startY = drawBrandedHeader(doc, { + logo, title: "Cash Disbursement", subtitle: rangeLabel, + metaLines: [{ label: "Properties:", value: companyName || "" }], + }); + + const body: any[] = []; + for (const g of report.groups) { + body.push([{ + content: g.bankLabel, + colSpan: 5, + 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.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] } }, + "", + { content: num(l.amount), styles: { halign: "right", textColor: [90, 90, 90] } }, + ]); + } + } + body.push([ + { content: `Total ${g.bankLabel}`, colSpan: 4, 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: num(report.grandTotal), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } }, + ]); + + autoTable(doc, { + startY, + head: [["Paid Date", "CheckNo", "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" } }, + margin: { left: ML, right: ML }, + }); + + drawBrandedFooter(doc); + doc.save(`cash-disbursement-${from}-to-${to}.pdf`); + }; + + const exportCSV = () => { + 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(",")]; + 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(",")); + 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.bankLabel), "", "", q(`Total ${g.bankLabel}`), "", "", f(g.subtotal)].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); + a.download = `cash-disbursement-${from}-to-${to}.csv`; + a.click(); + URL.revokeObjectURL(a.href); + }; + + const hasData = !!report && report.groups.length > 0; + + return ( +
+ + +
+ + setFrom(e.target.value || from)} className="w-44 mt-1" /> +
+
+ + setTo(e.target.value || to)} className="w-44 mt-1" /> +
+ {hasData && ( +
+ + +
+ )} +
+
+ + {isLoading ? ( + Loading… + ) : !hasData ? ( + No disbursements in this period. + ) : ( + +
+ + + + + + + + + + + + {report!.groups.map((g) => ( + <> + + + + {g.entries.map((e) => ( + <> + + + + + + + + {e.lines.map((l, li) => ( + + + + + ))} + + ))} + + + + + + ))} + + + + + + + +
Paid DateCheckNoDescriptionInvoice DateAmount
+ {g.bankLabel} +
{fmtDate(e.date)}{e.checkNo}{e.description}{e.invoiceDate ? fmtDate(e.invoiceDate) : ""}{num(e.amount)}
+ + + {l.code ? `${l.code} - ` : ""}{l.name}{l.description ? ` - ${l.description}` : ""} + + {num(l.amount)}
Total {g.bankLabel}{num(g.subtotal)}
Total Disbursements{num(report!.grandTotal)}
+
+
+ )} +
+ ); +} diff --git a/src/pages/accounting/components/PrepaidHomeownersReport.tsx b/src/pages/accounting/components/PrepaidHomeownersReport.tsx new file mode 100644 index 0000000..7b168d5 --- /dev/null +++ b/src/pages/accounting/components/PrepaidHomeownersReport.tsx @@ -0,0 +1,180 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { FileDown, Download } from "lucide-react"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import { ReportSheet } from "./ReportSheet"; +import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader"; +import { + fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners, num, + type UnitInfo, type OwnerInfo, +} from "../lib/ownerLedger"; + +const TEAL: [number, number, number] = [0, 137, 123]; + +type Row = { account: string; property: string; ownerName: string; credit: number }; + +/** + * Buildium-style "Pre Paid Homeowners": every unit whose owner ledger nets to + * a credit (payments exceed charges) as of the chosen date, with the credit amount. + */ +export function PrepaidHomeownersReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) { + const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10)); + + const { data, isLoading } = useQuery({ + queryKey: ["prepaid-homeowners", companyId, asOf], + enabled: !!companyId, + queryFn: async () => { + const associationId = await fetchAssociationId(companyId); + if (!associationId) return null; + const [entries, { units, owners }] = await Promise.all([ + fetchOwnerLedger(associationId, asOf), + fetchUnitsAndOwners(associationId), + ]); + return { entries, units, owners }; + }, + }); + + const { rows, total } = useMemo(() => { + if (!data) return { rows: [] as Row[], total: 0 }; + const unitById = new Map(); + for (const u of data.units) unitById.set(u.id, u); + const ownerById = new Map(); + for (const o of data.owners) ownerById.set(o.id, o); + const ownerByUnit = new Map(); + for (const o of data.owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o); + + const bal = new Map(); + for (const e of data.entries) { + const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null; + if (!key) continue; + bal.set(key, (bal.get(key) ?? 0) + e.debit - e.credit); + } + + const out: Row[] = []; + for (const [key, b] of bal) { + if (b >= -0.004) continue; // only credit balances + const unitId = key.startsWith("u:") ? key.slice(2) : null; + const ownerId = key.startsWith("o:") ? key.slice(2) : null; + const unit = unitId ? unitById.get(unitId) : undefined; + const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null; + const property = [unit?.address, unit?.unit_number && !unit?.address?.includes(unit.unit_number) ? unit.unit_number : null] + .filter(Boolean).join(" ") || (unit?.unit_number ? `Unit ${unit.unit_number}` : "—"); + out.push({ + account: unit?.account_number || unit?.unit_number || "—", + property, + ownerName: owner ? `${owner.first_name ?? ""} ${owner.last_name ?? ""}`.trim() || "—" : "—", + credit: -b, + }); + } + out.sort((a, b) => b.credit - a.credit); + return { rows: out, total: out.reduce((s, r) => s + r.credit, 0) }; + }, [data]); + + const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" }); + + const exportPDF = async () => { + const doc = new jsPDF({ unit: "pt", format: "letter" }); + const ML = 40; + const logo = await loadBrandedLogo(logoUrl); + const startY = drawBrandedHeader(doc, { + logo, title: "Pre Paid Homeowners", subtitle: `For ${asOfLabel}`, + metaLines: [{ label: "Properties:", value: companyName || "" }], + }); + autoTable(doc, { + startY, + head: [["Account", "Property", "Owner Name", "Credit Amount"]], + body: [ + ...rows.map((r) => [r.account, r.property, r.ownerName, num(r.credit)]), + [ + { content: "", styles: {} } as any, + { content: "", styles: {} } as any, + { content: "Total", styles: { fontStyle: "bold", halign: "right" } } as any, + { content: num(total), styles: { fontStyle: "bold", halign: "right" } } as any, + ], + ], + styles: { fontSize: 8.5, cellPadding: 4 }, + headStyles: { fillColor: TEAL, textColor: 255 }, + columnStyles: { 3: { halign: "right" } }, + margin: { left: ML, right: ML }, + }); + drawBrandedFooter(doc); + doc.save(`prepaid-homeowners-${asOf}.pdf`); + }; + + const exportCSV = () => { + const q = (s: string) => `"${s.replace(/"/g, '""')}"`; + const f = (n: number) => n.toFixed(2); + const lines = [["Account", "Property", "Owner Name", "Credit Amount"].join(",")]; + for (const r of rows) lines.push([q(r.account), q(r.property), q(r.ownerName), f(r.credit)].join(",")); + lines.push(["", "", "Total", f(total)].join(",")); + const blob = new Blob([lines.join("\n")], { type: "text/csv" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `prepaid-homeowners-${asOf}.csv`; + a.click(); + URL.revokeObjectURL(a.href); + }; + + return ( +
+ + +
+ + setAsOf(e.target.value || asOf)} className="w-44 mt-1" /> +
+ {rows.length > 0 && ( +
+ + +
+ )} +
+
+ + {isLoading ? ( + Loading… + ) : rows.length === 0 ? ( + No prepaid balances as of {asOfLabel}. + ) : ( + +
+ + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + ))} + + + + + + + +
AccountPropertyOwner NameCredit Amount
{r.account}{r.property}{r.ownerName}{num(r.credit)}
+ Total + {num(total)}
+
+
+ )} +
+ ); +} diff --git a/src/pages/accounting/lib/ownerLedger.ts b/src/pages/accounting/lib/ownerLedger.ts new file mode 100644 index 0000000..16ae243 --- /dev/null +++ b/src/pages/accounting/lib/ownerLedger.ts @@ -0,0 +1,126 @@ +import { supabase } from "@/integrations/supabase/client"; +import { accounting } from "@/lib/accountingClient"; + +/** + * Shared data access for the owner-ledger-based accounting reports + * (AR Aging by property, Pre-Paid Homeowners). The accounting dashboard is + * keyed by company; the owner ledger lives in the public schema keyed by + * association — companies.association_id bridges the two. + */ + +export type OwnerLedgerEntry = { + unit_id: string | null; + owner_id: string | null; + date: string; + transaction_type: string | null; + debit: number; + credit: number; +}; + +export type UnitInfo = { + id: string; + unit_number: string | null; + address: string | null; + account_number: string | null; +}; + +export type OwnerInfo = { + id: string; + first_name: string | null; + last_name: string | null; + unit_id: string | null; +}; + +export async function fetchAssociationId(companyId: string): Promise { + const { data } = await accounting + .from("companies") + .select("association_id") + .eq("id", companyId) + .maybeSingle(); + return (data as any)?.association_id ?? null; +} + +/** All owner-ledger entries for an association up to (and including) asOf, paged past the 1000-row cap. */ +export async function fetchOwnerLedger(associationId: string, asOf: string): Promise { + const PAGE = 1000; + const out: OwnerLedgerEntry[] = []; + for (let offset = 0; ; offset += PAGE) { + const { data, error } = await supabase + .from("owner_ledger_entries") + .select("unit_id, owner_id, date, transaction_type, debit, credit") + .eq("association_id", associationId) + .lte("date", asOf) + .order("id", { ascending: true }) + .range(offset, offset + PAGE - 1); + if (error) throw error; + const rows = (data ?? []) as any[]; + for (const r of rows) { + out.push({ + unit_id: r.unit_id ?? null, + owner_id: r.owner_id ?? null, + date: String(r.date ?? "").slice(0, 10), + transaction_type: r.transaction_type ?? null, + debit: Number(r.debit || 0), + credit: Number(r.credit || 0), + }); + } + if (rows.length < PAGE) break; + } + return out; +} + +export async function fetchUnitsAndOwners(associationId: string): Promise<{ units: UnitInfo[]; owners: OwnerInfo[] }> { + const [unitsRes, ownersRes] = await Promise.all([ + supabase.from("units").select("id, unit_number, address, account_number").eq("association_id", associationId), + supabase.from("owners").select("id, first_name, last_name, unit_id").eq("association_id", associationId), + ]); + return { + units: (unitsRes.data ?? []) as UnitInfo[], + owners: (ownersRes.data ?? []) as OwnerInfo[], + }; +} + +/** Buildium-style label for a unit row: "account# - address unit# - Owner last name". */ +export function unitLabel(unit: UnitInfo | undefined, ownerLastName: string | null): string { + const parts: string[] = []; + if (unit?.account_number) parts.push(unit.account_number); + const addr = [unit?.address, unit?.unit_number && !unit?.address?.includes(unit.unit_number) ? unit.unit_number : null] + .filter(Boolean) + .join(" "); + if (addr) parts.push(addr); + else if (unit?.unit_number) parts.push(`Unit ${unit.unit_number}`); + if (ownerLastName) parts.push(ownerLastName); + return parts.join(" - ") || "Unknown property"; +} + +const CHARGE_TYPE_LABELS: Record = { + assessment: "Assessments", + special_assessment: "Special Assessments", + interest: "Interest", + late_fee: "Late Fees", + admin_fee: "Admin Fees", + legal_fee: "Legal Fees", + violation: "Violation Fines", + bank_fee: "Bank Fees", + prepayment: "Prepayments", + charge: "Charges", +}; + +export function chargeTypeLabel(t: string | null | undefined): string { + const key = String(t || "charge").toLowerCase(); + if (CHARGE_TYPE_LABELS[key]) return CHARGE_TYPE_LABELS[key]; + return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Always two decimals, thousands-separated, negatives in parentheses. */ +export function num(n: number): string { + const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0; + const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return v < 0 ? `(${abs})` : abs; +} + +export function money(n: number): string { + const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0; + const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return v < 0 ? `($${abs})` : `$${abs}`; +}