import { Link } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Fragment, useEffect, useMemo, useState } from "react"; import { accounting } from "@/lib/accountingClient"; import { supabase } from "@/integrations/supabase/client"; import { useCompanyId } from "./lib/useCompanyId"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RTooltip, Legend, ResponsiveContainer } from "recharts"; import { FileText, Download, FileDown, Eye, RefreshCw, Layers, Trash2, Loader2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { generateBatchPdf, BATCHABLE_REPORTS } from "./lib/batchReports"; import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; import { renderReportPdf, fmtAmount, type StructuredReport, type StructuredRow, } from "./lib/reportPdf"; import { reconcile, type RecAccount, type RecLine } from "./lib/reconcile"; import { computePnL, computeMargins, toMinor, fromMinor, PnlValidationError, type PnlAccount, type PnlClassification, type Posting as PnlPosting, type PnlResult, } from "./lib/pnl"; 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"; 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" | "ar-aging-property" | "prepaid-homeowners" | "homeowner-summary" | "delinquency" | "expense-summary" | "vendor-balances" | "ap-aging" | "cash-disbursement" | "reconciliation" | "reserve-fund"; const APP_NAME = "Cozy Books"; const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"]; const GROUPS = [ { name: "Business Overview", reports: [ { id: "pnl" as ReportId, name: "Profit & Loss" }, { id: "income-statement" as ReportId, name: "Income Statement" }, { id: "balance-sheet" as ReportId, name: "Balance Sheet" }, { id: "cash-flow" as ReportId, name: "Cash Flow Statement" }, { id: "movement-of-equity" as ReportId, name: "Movement of Equity" }, { id: "trial-balance" as ReportId, name: "Trial Balance" }, { id: "general-ledger" as ReportId, name: "General Ledger" }, { 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" }, { id: "invoice-summary" as ReportId, name: "Invoice Summary" }, { 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" }, ]}, { name: "Reserves", reports: [ { id: "reserve-fund" as ReportId, name: "Reserve Fund Schedule" }, ]}, { name: "Audit", reports: [ { id: "reconciliation" as ReportId, name: "Reconciliation Checks" }, ]}, ]; const TZ_ET = "America/New_York"; function etDateStr(d = new Date()) { return d.toLocaleDateString("en-CA", { timeZone: TZ_ET }); } // YYYY-MM-DD in ET function startOfYear() { return `${new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric" })}-01-01`; } function today() { return etDateStr(); } function shiftBack(from: string, to: string) { const d1 = new Date(from), d2 = new Date(to); const span = d2.getTime() - d1.getTime(); const pTo = new Date(d1.getTime() - 86400000); const pFrom = new Date(pTo.getTime() - span); return { from: pFrom.toISOString().slice(0, 10), to: pTo.toISOString().slice(0, 10) }; } // PostgREST caps each response at 1000 rows. Reports aggregate GL lines // client-side, so for companies with >1000 journal lines we must page through // all of them — otherwise balances are truncated (accounts whose activity // falls past row 1000 silently read as $0). Order by a stable key (id) so the // pages don't overlap or skip rows. const GL_PAGE = 1000; async function fetchAllGLLines(cid: string, to: string, select: string, from?: string): Promise { const out: any[] = []; for (let offset = 0; ; offset += GL_PAGE) { let q = accounting .from("journal_entry_lines") .select(select) .eq("journal_entries.company_id", cid) // Archived accounts stay off the financial statements (their history // remains visible in the General Ledger report, which queries directly). .eq("accounts.is_archived", false) .lte("journal_entries.date", to); if (from) q = q.gte("journal_entries.date", from); const { data, error } = await q.order("id", { ascending: true }).range(offset, offset + GL_PAGE - 1); if (error) throw error; const rows = (data ?? []) as any[]; out.push(...rows); if (rows.length < GL_PAGE) break; } return out; } // Shared fetch for the financial reports (also used by the report-batch engine). export async function fetchReportData(cid: string, from: string, to: string) { 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([ 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("accounts").select("id,name,code,type,subtype,balance,is_bank").eq("company_id", cid).eq("is_archived", false), accounting.from("expenses").select("date,category,amount,vendor_name,vendors(name)").eq("company_id", cid).gte("date", from).lte("date", to), accounting.from("customers").select("id,name,balance,email,phone,property_address,lot_number").eq("company_id", cid).order("name"), accounting.from("vendors").select("id,name").eq("company_id", cid), accounting.from("opening_balances").select("account_id,debit,credit").eq("company_id", cid), accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to), accounting.from("expenses").select("amount,date").eq("company_id", cid).gte("date", ytdStart).lte("date", to), // YTD bills (accrual — for net income in Balance Sheet / Movement of Equity) accounting.from("bills").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to), // All bills (not date-filtered) for AP aging accounting.from("bills").select("id,vendor_id,total,paid_amount,status,due_date,issue_date,vendors(id,name)").eq("company_id", cid), // General-ledger lines in period — P&L is built from these, grouped by account fetchAllGLLines(cid, to, "id,debit,credit,accounts!inner(id,name,code,type,parent_account_id),journal_entries!inner(company_id,date)", from), // Cumulative GL through `to` — Balance Sheet is built from these (as-of balances) fetchAllGLLines(cid, to, "id,debit,credit,account_id,accounts!inner(type),journal_entries!inner(company_id,date)"), // All invoices (not date-filtered) — Accounts Receivable = unpaid invoices accounting.from("invoices").select("total,paid_amount,status").eq("company_id", cid), // Whether the platform manages this company's GL (A/R-A/P sub-ledgers tie to the GL). // 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. accounting.from("companies").select("gl_auto_post").eq("id", cid).maybeSingle(), ]); return { invoices: inv.data ?? [], bills: bills.data ?? [], accounts: accs.data ?? [], expenses: exp.data ?? [], customers: custs.data ?? [], vendors: vends.data ?? [], openingBalances: ob.data ?? [], ytdInvoices: ytdInv.data ?? [], ytdExpenses: ytdExp.data ?? [], ytdBills: ytdBills.data ?? [], allBills: allBills.data ?? [], glLines: glRes ?? [], glCumulative: glCumRes ?? [], allInvoices: allInvRes.data ?? [], glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true, from, asOf: to, }; } function useReportData(cid: string, from: string, to: string) { return useQuery({ queryKey: ["reports-data", cid, from, to], enabled: !!cid, queryFn: () => fetchReportData(cid, from, to), }); } // ── Period preset helpers ──────────────────────────────────────────────────── function etNow() { // Current date/time as a Date object normalised to ET by parsing the ET date string const etStr = new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric", month: "2-digit", day: "2-digit" }); return new Date(etStr); // local Date at ET midnight } function startOfMonth(d = etNow()) { return new Date(d.getFullYear(), d.getMonth(), 1).toLocaleDateString("en-CA"); } function endOfMonth(d = etNow()) { return new Date(d.getFullYear(), d.getMonth() + 1, 0).toLocaleDateString("en-CA"); } function startOfLastMonth() { const d = etNow(); d.setMonth(d.getMonth() - 1); return startOfMonth(d); } function endOfLastMonth() { const d = etNow(); d.setMonth(d.getMonth() - 1); return endOfMonth(d); } function startOfLastYear() { return `${etNow().getFullYear() - 1}-01-01`; } function endOfLastYear() { return `${etNow().getFullYear() - 1}-12-31`; } type Preset = "this-month" | "last-month" | "this-quarter" | "ytd" | "last-year" | "custom"; type CompareMode = "none" | "prior-period" | "prior-year" | "custom"; function presetDates(p: Preset): { from: string; to: string } { const now = new Date(); const q = Math.floor(now.getMonth() / 3); switch (p) { case "this-month": return { from: startOfMonth(), to: endOfMonth() }; case "last-month": return { from: startOfLastMonth(), to: endOfLastMonth() }; case "this-quarter": return { from: new Date(now.getFullYear(), q * 3, 1).toISOString().slice(0, 10), to: today() }; case "ytd": return { from: startOfYear(), to: today() }; case "last-year": return { from: startOfLastYear(), to: endOfLastYear() }; default: return { from: startOfYear(), to: today() }; } } function compareDates(mode: CompareMode, from: string, to: string, customFrom: string, customTo: string) { if (mode === "none") return null; if (mode === "prior-period") return shiftBack(from, to); if (mode === "prior-year") { const py = (d: string) => `${parseInt(d.slice(0, 4)) - 1}${d.slice(4)}`; return { from: py(from), to: py(to) }; } if (mode === "custom") return { from: customFrom, to: customTo }; return null; } export default function AccountingReportsPage({ association }: { association?: { id: string; name?: string } | null } = {}) { const { companyId, associationName, associationId } = useCompanyId(association); const qc = useQueryClient(); const [refreshing, setRefreshing] = useState(false); const refreshReport = async () => { setRefreshing(true); await qc.invalidateQueries(); setTimeout(() => setRefreshing(false), 600); }; const cid = companyId ?? ""; const cur = "USD"; const [active, setActive] = useState("pnl"); // Drill-down: clicking an account amount in a financial report opens the // General Ledger focused on that account (its transaction list for the COA). const [drillAccountId, setDrillAccountId] = useState(null); const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); }; // Period const [preset, setPreset] = useState("ytd"); const [from, setFrom] = useState(startOfMonth()); const [to, setTo] = useState(today()); // ── Report batches (saved per-company report packets) ── const [batchOpen, setBatchOpen] = useState(false); const [batchName, setBatchName] = useState(""); const [batchReportIds, setBatchReportIds] = useState(["balance-sheet", "pnl", "trial-balance", "general-ledger"]); const [batchLoadedId, setBatchLoadedId] = useState(null); const [generatingBatch, setGeneratingBatch] = useState(false); const { data: savedBatches = [], refetch: refetchBatches } = useQuery({ queryKey: ["report-batches", cid], enabled: !!cid, queryFn: async () => (await accounting.from("report_batches").select("*").eq("company_id", cid).order("name")).data ?? [], }); const toggleBatchReport = (id: string) => setBatchReportIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); const saveBatch = async () => { const name = batchName.trim(); if (!name) { toast.error("Name the batch first"); return; } if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; } if (batchLoadedId) { const { error } = await accounting.from("report_batches").update({ name, report_ids: batchReportIds, updated_at: new Date().toISOString() }).eq("id", batchLoadedId); if (error) { toast.error(error.message); return; } } else { const { data, error } = await accounting.from("report_batches").insert({ company_id: cid, name, report_ids: batchReportIds }).select("id").single(); if (error) { toast.error(error.message); return; } setBatchLoadedId(data.id); } toast.success(`Batch "${name}" saved`); refetchBatches(); }; const loadBatch = (b: any) => { setBatchLoadedId(b.id); setBatchName(b.name); setBatchReportIds(Array.isArray(b.report_ids) ? b.report_ids : []); }; const deleteBatch = async (id: string) => { await accounting.from("report_batches").delete().eq("id", id); if (batchLoadedId === id) { setBatchLoadedId(null); setBatchName(""); } refetchBatches(); toast.success("Batch deleted"); }; const generateBatch = async () => { if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; } setGeneratingBatch(true); try { const doc = await generateBatchPdf({ companyId: cid, companyName: associationName ?? "Company", from, to, currency: cur, fetchReportData, buildFinancial, }, batchReportIds); const slug = (associationName ?? "report").replace(/[^a-z0-9]+/gi, "-").toLowerCase().slice(0, 40); doc.save(`${slug}-report-packet-${from}-to-${to}.pdf`); toast.success("Report package generated"); } catch (e: any) { toast.error(e?.message || "Could not generate package"); } finally { setGeneratingBatch(false); } }; const applyPreset = (p: Preset) => { setPreset(p); if (p !== "custom") { const d = presetDates(p); setFrom(d.from); setTo(d.to); } }; // Comparison const [compareMode, setCompareMode] = useState("none"); const [compareFrom, setCompareFrom] = useState(startOfLastYear()); const [compareTo, setCompareTo] = useState(endOfLastYear()); const showCompare = compareMode !== "none"; const comparePeriod = compareDates(compareMode, from, to, compareFrom, compareTo); // Toggles const [showCodes, setShowCodes] = useState(false); const [showZero, setShowZero] = useState(false); const { data: companyMeta } = useQuery({ queryKey: ["company-fy", cid], enabled: !!cid, queryFn: async () => (await accounting.from("companies").select("fiscal_year_start").eq("id", cid).maybeSingle()).data, }); const fiscalYearStart = (companyMeta as any)?.fiscal_year_start || "01-01"; // Association logo for branded reports (ACM fallback handled downstream). const { data: assocMeta } = useQuery({ queryKey: ["assoc-logo", associationId], enabled: !!associationId, queryFn: async () => (await supabase.from("associations").select("logo_url").eq("id", associationId!).maybeSingle()).data, }); const logoUrl = (assocMeta as any)?.logo_url || null; // Preloaded logo dataURL for synchronous PDF header drawing. const { data: brandedLogo } = useQuery({ queryKey: ["branded-logo", logoUrl], queryFn: async () => await loadBrandedLogo(logoUrl), }); const { data } = useReportData(cid, from, to); const { data: prevData } = useReportData( showCompare && comparePeriod ? cid : "", comparePeriod?.from ?? from, comparePeriod?.to ?? to ); // AR open invoices — used by AR aging, homeowner summary, delinquency const arReports: ReportId[] = ["customer-balances", "ar-aging", "homeowner-summary", "delinquency"]; const { data: arOpen = [] } = useQuery({ queryKey: ["ar-aging", cid], enabled: !!cid && arReports.includes(active), queryFn: async () => { const { data } = await accounting .from("invoices") .select("id,customer_id,total,paid_amount,due_date,issue_date,status,number,customers(id,name)") .eq("company_id", cid); return data ?? []; }, }); const isFinancial = FINANCIAL.includes(active); const activeMeta = GROUPS.flatMap(g => g.reports).find(r => r.id === active)!; const rangeLabel = active === "balance-sheet" ? `As of ${fmtDate(to)}` : `${fmtDate(from)} – ${fmtDate(to)}`; const structured = useMemo(() => { if (!data || !isFinancial) return null; return buildFinancial(active, data, prevData, showCompare); }, [active, data, prevData, showCompare, isFinancial]); const flat = useMemo(() => (data && !isFinancial) ? buildFlat(active, data, cur) : null, [active, data, cur, isFinancial]); // Build exportable flat data for custom-rendered reports (aging, delinquency, etc.) const exportFlat = useMemo((): Flat | null => { if (structured || flat) return null; // already have a better source const m = (n: number) => money(n, cur); const now = new Date(); if (active === "ar-aging" || active === "customer-balances") { type AR = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number }; const byC = new Map(); for (const inv of arOpen as any[]) { const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); if (open <= 0) continue; const cid2 = inv.customer_id; if (!cid2) continue; const name = inv.customers?.name ?? "—"; const days = Math.floor((now.getTime() - new Date(inv.due_date ?? inv.issue_date).getTime()) / 86400000); const r = byC.get(cid2) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }; if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open; r.total += open; byC.set(cid2, r); } const list = [...byC.values()].sort((a, b) => b.total - a.total); const tot = list.reduce((s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }), { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }); return { title: activeMeta.name, columns: ["Homeowner", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"], rows: [...list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]), ["TOTAL", m(tot.current), m(tot.d30), m(tot.d60), m(tot.d90), m(tot.d90p), m(tot.total)]], boldRows: [list.length], }; } if (active === "ap-aging") { type AP = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number }; const byV = new Map(); for (const b of ((data as any)?.allBills ?? []) as any[]) { const open = Number(b.total ?? 0) - Number(b.paid_amount ?? 0); if (open <= 0) continue; const vid = b.vendor_id ?? b.id; const name = b.vendors?.name ?? "Unknown"; const days = Math.floor((now.getTime() - new Date(b.due_date ?? b.issue_date ?? Date.now()).getTime()) / 86400000); const r = byV.get(vid) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }; if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open; r.total += open; byV.set(vid, r); } const list = [...byV.values()].sort((a, b) => b.total - a.total); return { title: "AP Aging Details", columns: ["Vendor", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"], rows: list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]), }; } if (active === "homeowner-summary") { const customers = (data as any)?.customers ?? []; const openByC = new Map(); for (const inv of arOpen as any[]) { const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); 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[]) .map((c: any) => [c.name, c.property_address ?? "—", c.lot_number ? `Lot ${c.lot_number}` : "—", String(openByC.get(c.id)?.count ?? 0), m(openByC.get(c.id)?.open ?? 0)]) .sort((a, b) => parseFloat(String(b[4]).replace(/[^0-9.]/g, "")) - parseFloat(String(a[4]).replace(/[^0-9.]/g, ""))); return { title: "Homeowner Balance Summary", columns: ["Homeowner", "Property", "Lot", "Open Invoices", "Outstanding"] , rows }; } if (active === "delinquency") { const customers = (data as any)?.customers ?? []; const overdue = new Map(); for (const inv of arOpen as any[]) { const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); if (open <= 0) continue; const due = new Date(inv.due_date ?? inv.issue_date); const days = Math.floor((now.getTime() - due.getTime()) / 86400000); if (days <= 0) continue; const cust = (customers as any[]).find((c: any) => c.id === inv.customer_id); const r = overdue.get(inv.customer_id) ?? { name: cust?.name ?? "—", property: cust?.property_address ?? "—", email: cust?.email ?? "—", phone: cust?.phone ?? "—", amount: 0, oldest: 0 }; r.amount += open; r.oldest = Math.max(r.oldest, days); overdue.set(inv.customer_id, r); } const list = [...overdue.values()].sort((a, b) => b.amount - a.amount); return { title: "Delinquency Report", columns: ["Homeowner", "Property", "Email", "Phone", "Days Overdue", "Amount Overdue"], rows: list.map(r => [r.name, r.property, r.email, r.phone, String(r.oldest), m(r.amount)]), }; } return null; }, [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" || active === "ar-aging-property" || active === "prepaid-homeowners" || active === "cash-disbursement"; const anyExportable = !!(structured || flat || exportFlat); const doExportPDF = async () => { const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`; const src = flat ?? exportFlat; const logo = brandedLogo ?? (await loadBrandedLogo(logoUrl)); if (structured) { const doc = renderReportPdf( structured, { companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero, logo }, ); doc.save(`${fileBase}.pdf`); } else if (src) { const doc = new jsPDF({ unit: "pt", format: "letter", orientation: src.columns.length > 6 ? "landscape" : "portrait" }); const startY = drawBrandedHeader(doc, { logo, title: src.title, metaLines: [{ label: "Properties:", value: associationName ?? "" }, { label: "Period:", value: rangeLabel }], }); const lastCol = src.columns.length - 1; autoTable(doc, { head: [src.columns], body: src.rows.map(r => r.map(String)), startY, margin: { left: 40, right: 40 }, styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 }, headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 }, alternateRowStyles: { fillColor: [247, 248, 250] }, columnStyles: { [lastCol]: { halign: "right" } }, didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; }, }); drawBrandedFooter(doc); doc.save(`${fileBase}.pdf`); } else { toast.error("No data to export for this report"); } }; const doExportCSV = () => { const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`; let lines: string[] = []; if (structured) { lines.push(["Account", "Amount", showCompare ? "Previous" : ""].filter(Boolean).join(",")); for (const r of structured.rows) { if (r.kind === "spacer") continue; if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) continue; const label = (r.kind === "sub" ? " " : "") + (showCodes && r.code ? `${r.code} ${r.label}` : r.label); const cells = [`"${label}"`, fmtAmount(r.amount)]; if (showCompare) cells.push(fmtAmount(r.compare)); lines.push(cells.join(",")); } } else { const src = flat ?? exportFlat; if (src) { lines.push(src.columns.map(c => `"${c}"`).join(",")); for (const r of src.rows) lines.push(r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(",")); } } if (!lines.length) { toast.error("No data to export"); return; } const blob = new Blob([lines.join("\n")], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${fileBase}.csv`; a.click(); }; // Keep old names for any remaining references const exportPDF = doExportPDF; const exportCSV = doExportCSV; return (

{activeMeta.name}

{rangeLabel}

{hasOwnExport ? ( Export available inside the report ↓ ) : ( <> )}
{/* Period presets */}
Period {([ { v: "this-month", l: "This Month" }, { v: "last-month", l: "Last Month" }, { v: "this-quarter", l: "This Quarter" }, { v: "ytd", l: "YTD" }, { v: "last-year", l: "Last Year" }, { v: "custom", l: "Custom" }, ] as { v: Preset; l: string }[]).map(({ v, l }) => ( ))} {preset === "custom" && (
setFrom(e.target.value)} className="h-7 w-36 text-xs" /> to setTo(e.target.value)} className="h-7 w-36 text-xs" />
)}
{/* Comparison period — only for financial reports */} {isFinancial && (
Compare {([ { v: "none", l: "None" }, { v: "prior-period", l: "Prior Period" }, { v: "prior-year", l: "Prior Year" }, { v: "custom", l: "Custom" }, ] as { v: CompareMode; l: string }[]).map(({ v, l }) => ( ))} {compareMode === "custom" && (
setCompareFrom(e.target.value)} className="h-7 w-36 text-xs" /> to setCompareTo(e.target.value)} className="h-7 w-36 text-xs" />
)}
)} {/* Display toggles */}
{active === "budget-vs-actuals" && ( )} {active === "income-statement" && ( )} {active === "trial-balance" && ( )} {active === "general-ledger" && ( )} {active === "reserve-fund" && ( )} {active === "ar-aging-property" && ( )} {active === "prepaid-homeowners" && ( )} {active === "cash-disbursement" && ( )} {active === "reconciliation" && ( )} {isFinancial && ( !data ? (
Loading…
) : structured ? ( ) : (
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" && active !== "ar-aging-property" && active !== "prepaid-homeowners" && active !== "cash-disbursement" && ( {!data ? (
Loading…
) : structured ? ( ) : active === "customer-balances" ? ( ) : active === "ar-aging" ? ( ) : active === "ap-aging" ? ( ) : active === "homeowner-summary" ? ( ) : active === "delinquency" ? ( ) : flat && flat.rows.length > 0 ? ( {flat.columns.map((c, i) => ( {c} ))} {flat.rows.map((row, i) => ( {row.map((cell, j) => ( {cell} ))} ))}
) : (
No data for this report in the selected range.
)}
)}
{/* ── Report Batches dialog ── */} Report Batches

Pick a set of reports to combine into one PDF for {associationName ?? "this association"}. The package uses the period selected on this page ({rangeLabel}).

{savedBatches.length > 0 && (
{(savedBatches as any[]).map((b) => (
))}
)}
setBatchName(e.target.value)} placeholder="e.g. Monthly Board Package" className="mt-1" />
{BATCHABLE_REPORTS.map((r) => ( ))}
{batchLoadedId && ( )}
); } function Toggle({ id, checked, onChange, label, disabled }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) { return (
); } // ── Income Statement (multi-period: by month / quarter / year) ───────────────── type ISGran = "month" | "quarter" | "year"; const IS_TEAL: [number, number, number] = [0, 137, 123]; function isPad2(n: number) { return String(n).padStart(2, "0"); } /** Period columns spanning [from, to] at the chosen granularity. */ function isBuildPeriods(from: string, to: string, gran: ISGran): { key: string; label: string }[] { const out: { key: string; label: string }[] = []; const fy = Number(from.slice(0, 4)), fm = Number(from.slice(5, 7)); const ty = Number(to.slice(0, 4)), tm = Number(to.slice(5, 7)); if (gran === "month") { let cy = fy, cm = fm; while (cy < ty || (cy === ty && cm <= tm)) { out.push({ key: `${cy}-${isPad2(cm)}`, label: `${isPad2(cm)}-${cy}` }); cm++; if (cm > 12) { cm = 1; cy++; } } } else if (gran === "quarter") { let cy = fy, cq = Math.floor((fm - 1) / 3) + 1; const tq = Math.floor((tm - 1) / 3) + 1; while (cy < ty || (cy === ty && cq <= tq)) { out.push({ key: `${cy}-Q${cq}`, label: `Q${cq} ${cy}` }); cq++; if (cq > 4) { cq = 1; cy++; } } } else { for (let cy = fy; cy <= ty; cy++) out.push({ key: `${cy}`, label: `${cy}` }); } return out; } /** Period key a given YYYY-MM-DD date falls into, for the chosen granularity. */ function isPeriodKey(date: string, gran: ISGran): string { if (gran === "month") return date.slice(0, 7); if (gran === "year") return date.slice(0, 4); const q = Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1; return `${date.slice(0, 4)}-Q${q}`; } /** Detail-cell number: 2dp, thousands-separated, parens for negatives, blank for zero. */ function isNum(n: number): string { const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0; if (v === 0) return ""; const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return v < 0 ? `(${abs})` : abs; } type ISAcct = { id: string; code: string | null; name: string; type: string; category: string | null; by: Map; total: number }; type ISTotal = { by: Map; total: number }; type ISGroup = { name: string | null; accts: ISAcct[] } & ISTotal; const isByCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name); function isSumRows(rows: { by: Map; total: number }[], periods: { key: string }[]): ISTotal { const by = new Map(); let total = 0; for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0)); for (const r of rows) total += r.total; return { by, total }; } /** Group accounts by their `category` into Buildium-style subgroups; ungrouped (null) sorts last. */ function isGroupAccts(accts: ISAcct[], periods: { key: string }[]): ISGroup[] { const map = new Map(); for (const a of accts) { const k = (a.category ?? "").trim(); (map.get(k) ?? map.set(k, []).get(k)!).push(a); } const groups: ISGroup[] = []; for (const [name, list] of map) { list.sort(isByCode); groups.push({ name: name || null, accts: list, ...isSumRows(list, periods) }); } groups.sort((a, b) => (a.name === null ? 1 : 0) - (b.name === null ? 1 : 0) || String(a.name).localeCompare(String(b.name))); return groups; } function IncomeStatementReport({ companyId, companyName, from, to, currency, logoUrl }: { companyId: string; companyName: string; from: string; to: string; currency: string; logoUrl?: string | null; }) { const [gran, setGran] = useState("month"); const { data: glLines = [], isLoading } = useQuery({ queryKey: ["income-statement-gl", companyId, from, to], enabled: !!companyId, queryFn: () => fetchAllGLLines( companyId, to, "id,debit,credit,accounts!inner(id,name,code,type,category),journal_entries!inner(company_id,date)", from, ), }); const periods = useMemo(() => isBuildPeriods(from, to, gran), [from, to, gran]); const model = useMemo(() => { const accts = new Map(); for (const l of glLines as any[]) { const a = l.accounts; if (!a) continue; const type = a.type as string; if (type !== "income" && type !== "expense") continue; const date: string = l.journal_entries?.date ?? ""; if (!date) continue; const debit = Number(l.debit || 0), credit = Number(l.credit || 0); const amt = type === "income" ? credit - debit : debit - credit; // both shown positive let rec = accts.get(a.id); if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, category: a.category ?? null, by: new Map(), total: 0 }; accts.set(a.id, rec); } const key = isPeriodKey(date, gran); rec.by.set(key, (rec.by.get(key) ?? 0) + amt); rec.total += amt; } const live = [...accts.values()].filter((a) => Math.abs(a.total) > 0.005); const income = live.filter((a) => a.type === "income"); const expense = live.filter((a) => a.type === "expense"); const incomeGroups = isGroupAccts(income, periods); const expenseGroups = isGroupAccts(expense, periods); const incTot = isSumRows(income, periods), expTot = isSumRows(expense, periods); const net: ISTotal = { by: new Map(periods.map((p) => [p.key, (incTot.by.get(p.key) ?? 0) - (expTot.by.get(p.key) ?? 0)])), total: incTot.total - expTot.total, }; return { incomeGroups, expenseGroups, incTot, expTot, net, hasRows: income.length > 0 || expense.length > 0 }; }, [glLines, periods, gran]); const subtitle = `${fmtDate(from)} – ${fmtDate(to)}, By ${gran[0].toUpperCase()}${gran.slice(1)}, Accrual basis`; const { hasRows } = model; const exportPdf = async () => { const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" }); const logo = await loadBrandedLogo(logoUrl); const startY = drawBrandedHeader(doc, { logo, title: "Income Statement", subtitle, metaLines: [{ label: "Properties:", value: companyName }], }); const head = [["Account", ...periods.map((p) => p.label), "Total"]]; const body: any[] = []; const fillRows = new Set(); // section headers (Income / Expense) const boldRows = new Set(); // group headers, subtotals, totals, net const pushRow = (label: string, t: ISTotal | null, kind: "section" | "group" | "account" | "subtotal" | "total") => { const asMoney = kind === "subtotal" || kind === "total"; const cells = [ label, ...periods.map((p) => (t ? (asMoney ? money(t.by.get(p.key) ?? 0, currency) : isNum(t.by.get(p.key) ?? 0)) : "")), t == null ? "" : (asMoney ? money(t.total, currency) : isNum(t.total)), ]; if (kind === "section") fillRows.add(body.length); if (kind !== "account") boldRows.add(body.length); body.push(cells); }; const emitSection = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => { pushRow(title, null, "section"); for (const g of groups) { if (g.name) pushRow(` ${g.name}`, null, "group"); for (const a of g.accts) pushRow(`${g.name ? " " : " "}${a.code ? a.code + " " : ""}${a.name}`, a, "account"); if (g.name) pushRow(` Total for ${g.name}`, g, "subtotal"); } pushRow(totalLabel, total, "total"); }; emitSection("Income", model.incomeGroups, "Total Income", model.incTot); emitSection("Expense", model.expenseGroups, "Total Expense", model.expTot); pushRow("Net Income", model.net, "total"); const colStyles: Record = { 0: { halign: "left", cellWidth: 160 } }; for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" }; autoTable(doc, { startY, head, body, styles: { fontSize: 7, cellPadding: 3, overflow: "linebreak" }, headStyles: { fillColor: IS_TEAL, textColor: 255, halign: "right", fontSize: 7 }, columnStyles: colStyles, margin: { left: 40, right: 40 }, didParseCell: (data: any) => { if (data.section !== "body") return; if (boldRows.has(data.row.index)) data.cell.styles.fontStyle = "bold"; if (fillRows.has(data.row.index)) data.cell.styles.fillColor = [241, 245, 249]; }, }); drawBrandedFooter(doc); doc.save(`income-statement-${gran}-${from}-to-${to}.pdf`); }; const exportCsv = () => { const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`; const lines = [["Account", ...periods.map((p) => p.label), "Total"].map(esc).join(",")]; const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2); const row = (label: string, t: ISTotal | null) => lines.push([esc(label), ...periods.map((p) => (t ? f(t.by.get(p.key) ?? 0) : "")), t == null ? "" : f(t.total)].join(",")); const section = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => { row(title, null); for (const g of groups) { if (g.name) row(g.name, null); for (const a of g.accts) row(`${a.code ? a.code + " " : ""}${a.name}`, a); if (g.name) row(`Total for ${g.name}`, g); } row(totalLabel, total); }; section("Income", model.incomeGroups, "Total Income", model.incTot); section("Expense", model.expenseGroups, "Total Expense", model.expTot); row("Net Income", model.net); const blob = new Blob([lines.join("\n")], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `income-statement-${gran}-${from}-to-${to}.csv`; a.click(); URL.revokeObjectURL(a.href); }; const numCell = "px-3 py-1.5 text-right tabular-nums whitespace-nowrap"; return (
{periods.length} period{periods.length !== 1 ? "s" : ""} · Accrual basis
{hasRows && (
)}
{isLoading ? ( Loading… ) : !hasRows ? ( No income or expense activity in this range. ) : (
{periods.map((p) => )} {periods.map((p) => )}
Account{p.label}Total
Net Income{money(model.net.by.get(p.key) ?? 0, currency)}{money(model.net.total, currency)}
)}
); } function ISSection({ title, groups, periods, totalLabel, total, currency, numCell }: { title: string; groups: ISGroup[]; periods: { key: string; label: string }[]; totalLabel: string; total: ISTotal; currency: string; numCell: string; }) { return ( <> {title} {groups.map((g, gi) => ( {g.name && ( {g.name} )} {g.accts.map((a) => ( {a.code && {a.code}}{a.name} {periods.map((p) => {isNum(a.by.get(p.key) ?? 0)})} {isNum(a.total)} ))} {g.name && ( Total for {g.name} {periods.map((p) => {money(g.by.get(p.key) ?? 0, currency)})} {money(g.total, currency)} )} ))} {totalLabel} {periods.map((p) => {money(total.by.get(p.key) ?? 0, currency)})} {money(total.total, currency)} ); } function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: { report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string; onDrill?: (accountId: string, label: string) => void; }) { let alt = false; const span = showCompare ? 5 : 2; const pctStr = (amount?: number, compare?: number) => { if (amount === undefined || compare === undefined || Math.abs(compare) < 0.005) return "—"; return `${(((amount - compare) / Math.abs(compare)) * 100).toFixed(1)}%`; }; return ( {showCompare && } {showCompare && } {showCompare && } {report.rows.map((r, i) => { if (r.kind === "spacer") { alt = false; return ; } if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) return null; if (r.kind === "section") { alt = false; return ( ); } if (r.kind === "group") { alt = false; return ( ); } const bold = r.kind === "total" || r.kind === "grand"; const shaded = r.kind === "sub" && alt; if (r.kind === "sub") alt = !alt; const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined; const drillable = r.kind === "sub" && !!r.accountId && !!onDrill; return ( onDrill!(r.accountId!, r.label) : undefined} title={drillable ? "View transactions for this account" : undefined} className={[ shaded ? "bg-muted/40" : "", bold ? "border-t font-semibold" : "", drillable ? "cursor-pointer hover:bg-primary/5" : "", ].join(" ")}> {showCompare && } {showCompare && } {showCompare && } ); })} {report.balanced !== undefined && ( )} {report.cashHighlight && ( )}
Account AmountComparativeChangeChange %
{r.label}
{r.label}
{showCodes && r.code && {r.code}} {r.label} {r.amount !== undefined ? pctStr(r.amount, r.compare) : ""}
{report.balanced ? "Balance Sheet is balanced ✓" : `Balance Sheet is OUT OF BALANCE by ${money(report.outOfBalanceAmount ?? 0, currency)} (Assets − Liabilities − Equity)`}
{report.cashHighlight.label} {fmtAmount(report.cashHighlight.amount)}
); } function AmountCell({ n, bold, doubleUnderline }: { n?: number; bold?: boolean; doubleUnderline?: boolean }) { if (n === undefined) return ; const cls = [ "py-1.5 px-2 text-right tabular-nums", n < 0 ? "text-red-600" : "", bold ? "font-semibold" : "", doubleUnderline ? "border-b-4 border-double border-primary" : "", ].join(" "); return {fmtAmount(n)}; } function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare, showZero }: { report: StructuredReport | null; companyName: string; rangeLabel: string; showCodes: boolean; showCompare: boolean; showZero: boolean; }) { if (!report) return
No data.
; return (
{(companyName[0] ?? "?").toUpperCase()}
{companyName}

{report.title}

{rangeLabel}
Page 1 of 1
Generated by {APP_NAME} on {new Date().toLocaleDateString()}
); } // ---------- Financial report builders (structured) ---------- // Reconciliation matrix (§9) surfaced as visible residuals — never plug a residual. function ReconciliationReport({ d, currency }: { d: any; currency: string }) { if (!d) return
Loading…
; const accounts: RecAccount[] = ((d.accounts ?? []) as any[]).map((a) => ({ id: a.id, type: a.type, name: a.name, is_cash: !!a.is_bank || /cash|undeposited/i.test(String(a.name || "")), })); const lines: RecLine[] = ((d.glCumulative ?? []) as any[]).map((l) => ({ account_id: l.account_id, date: String(l.journal_entries?.date ?? ""), 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 openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0); // Cross-path figures from the report builders so R3/R5 verify the builders agree // with the raw GL. (P&L net income vs GL; Movement-of-Equity ending vs Balance Sheet.) const pl = buildPnL(d, undefined, false); const plNI = pl.rows.find((r) => r.kind === "grand" && /net income/i.test(r.label))?.amount; const bs = buildBalanceSheet(d); const bsEquity = bs.rows.find((r) => r.kind === "total" && /total equity/i.test(r.label))?.amount; const sce = buildMovementOfEquity(d, undefined, false); const sceEnding = sce.rows.find((r) => r.kind === "grand" && /closing equity/i.test(r.label))?.amount; const checks = reconcile({ accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill, arApApplicable: d.glManaged, reportPLNetIncome: plNI, sceEndingEquity: sceEnding, bsTotalEquity: bsEquity, }); const ok = (r: number) => Math.abs(r) < 0.005; const allPass = checks.every((c) => c.pass); return ( Reconciliation Checks {allPass ? "All passing" : "Residuals present"} Check Assertion Residual Status {checks.map((c) => ( {c.id} {c.label} {money(c.residual, currency)} {ok(c.residual) ? "✓" : "✗"} ))} {/* R6 / R9 are not numeric residuals in this model — shown for matrix completeness. */} R6 GL closing balance = Trial Balance / Balance Sheet balance n/a R9 Direct-method CFO = Indirect-method CFO n/a

A non-zero residual is a bug signal (§9), not to be plugged. R1/R2 failing means the ledger is unbalanced (often an imported single-sided entry). R3 checks the P&L's net income against the raw GL; R5 checks the Movement of Equity against the Balance Sheet. R7/R8 failing means A/R or A/P is summing gross billings instead of open balances, or a sub-ledger doesn't tie to the GL control account. R6 is satisfied by construction (Trial Balance and Balance Sheet both derive from the GL); R9 is N/A because only the indirect-method cash flow is produced. {!d.glManaged && " R7/R8 are omitted for this company: its GL is imported, so its A/R/A/P are maintained in the GL rather than from the platform's invoice/bill sub-ledgers."}

); } export function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport { if (id === "pnl") return buildPnL(d, p, useCompare); if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare); if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare); return buildCashFlow(d, p, useCompare); } function buildMovementOfEquity(d: any, p: any | undefined, useCompare: boolean): StructuredReport { // GL-consistent with the Balance Sheet and P&L: net income is the GL's // current-year earnings (not a separate sub-ledger figure), and the equity // rolls forward to exactly the Balance Sheet's total equity (ties R5). const equityAccs = (d.accounts ?? []).filter((a: any) => a.type === "equity"); const draws = equityAccs.filter((a: any) => /draw|dividend/i.test(a.name)); const nonDraw = equityAccs.filter((a: any) => !/draw|dividend/i.test(a.name)); // Per-dataset equity figures derived entirely from the GL (via bsBalances). const eq = (ds: any) => { const bs = bsBalances(ds); const bal = (a: any) => bs.glByAcct.get(a.id) ?? 0; const nonDrawGL = nonDraw.reduce((s: number, a: any) => s + bal(a), 0); const drawsGL = draws.reduce((s: number, a: any) => s + bal(a), 0); const opening = nonDrawGL + bs.rePrior; // capital + opening RE + prior-year earnings const closing = opening + bs.cye + drawsGL; // = Balance Sheet total equity (by construction) return { bal, drawsGL, opening, closing, netIncome: bs.cye, rePrior: bs.rePrior }; }; const cur = eq(d); const prev = useCompare && p ? eq(p) : undefined; const cmp = (v: number | undefined) => (prev ? v : undefined); const rows: StructuredRow[] = [ { kind: "section", label: "Opening Equity" }, ...nonDraw.map((a: any) => ({ kind: "sub" as const, label: a.name, code: a.code ?? undefined, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })), { kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) }, { kind: "total", label: "Total Opening Equity", amount: cur.opening, compare: cmp(prev?.opening) }, { kind: "spacer", label: "" }, { kind: "section", label: "Period Activity" }, { kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) }, ...draws.map((a: any) => ({ kind: "sub" as const, label: `Less: ${a.name}`, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })), { kind: "total", label: "Net Change in Equity", amount: cur.netIncome + cur.drawsGL, compare: cmp(prev ? prev.netIncome + prev.drawsGL : undefined) }, { kind: "spacer", label: "" }, { kind: "grand", label: "Closing Equity", amount: cur.closing, compare: cmp(prev?.closing) }, ]; return { title: "Movement of Equity", rows }; } // ── P&L from the general ledger (grouped by parent account) ─────────────────── type GLAccountTotal = { name: string; code: string | null; amount: number; parentId: string | null }; /** * Group journal-entry lines by GL account, split into income/expense. * Income accounts net to credit−debit; expense accounts to debit−credit. */ function groupGLByAccount(lines: any[]) { const income = new Map(); const expense = new Map(); for (const l of lines ?? []) { const acc = l.accounts; if (!acc) continue; const debit = Number(l.debit ?? 0); const credit = Number(l.credit ?? 0); const parentId = acc.parent_account_id ?? null; if (acc.type === "income") { const e = income.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId }; e.amount += credit - debit; income.set(acc.id, e); } else if (acc.type === "expense") { const e = expense.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId }; e.amount += debit - credit; expense.set(acc.id, e); } } return { income, expense }; } /** * Emit a P&L section, grouping accounts under their parent account. Top-level * accounts (no parent) are listed directly; child accounts are nested under a * parent group header with a "Total for " subtotal. Returns section totals. */ function pushPnLSection( rows: StructuredRow[], label: string, accMap: Map, prevMap: Map, parentName: Map, useCompare: boolean ): { total: number; prevTotal: number } { rows.push({ kind: "section", label }); const ungrouped: [string, GLAccountTotal][] = []; const groups = new Map(); for (const entry of accMap.entries()) { const pid = entry[1].parentId; if (pid && parentName.has(pid)) { const g = groups.get(pid) ?? []; g.push(entry); groups.set(pid, g); } else ungrouped.push(entry); } let total = 0, prevTotal = 0; const byName = (a: [string, GLAccountTotal], b: [string, GLAccountTotal]) => a[1].name.localeCompare(b[1].name); const emit = (id: string, v: GLAccountTotal) => { total += v.amount; prevTotal += prevMap.get(id)?.amount ?? 0; rows.push({ kind: "sub", label: v.name, code: v.code ?? undefined, amount: v.amount, compare: useCompare ? prevMap.get(id)?.amount : undefined }); }; for (const [id, v] of ungrouped.sort(byName)) emit(id, v); const sortedGroups = [...groups.entries()].sort((a, b) => (parentName.get(a[0]) ?? "").localeCompare(parentName.get(b[0]) ?? "")); for (const [pid, entries] of sortedGroups) { const gName = parentName.get(pid) ?? "Group"; rows.push({ kind: "group", label: gName }); let gTotal = 0, gPrev = 0; for (const [id, v] of entries.sort(byName)) { gTotal += v.amount; gPrev += prevMap.get(id)?.amount ?? 0; emit(id, v); } rows.push({ kind: "total", label: `Total for ${gName}`, amount: gTotal, compare: useCompare ? gPrev : undefined }); } return { total, prevTotal }; } // P&L sub-classification is read from the account's explicit `subtype` field // (never inferred from names — see the P&L spec §4). Unclassified nominal // accounts default to plain revenue / operating expense, which is correct for // HOA charts that have no COGS, tax, or contra accounts. const PNL_SUBTYPE_MAP: Record = { revenue: "REVENUE", sales: "REVENUE", income: "REVENUE", operating_income: "REVENUE", contra_revenue: "CONTRA_REVENUE", sales_returns: "CONTRA_REVENUE", sales_allowances: "CONTRA_REVENUE", sales_discounts: "CONTRA_REVENUE", cogs: "COGS", cost_of_goods_sold: "COGS", cost_of_sales: "COGS", contra_cogs: "CONTRA_COGS", purchase_discounts: "CONTRA_COGS", purchase_returns: "CONTRA_COGS", operating_expense: "OPERATING_EXPENSE", opex: "OPERATING_EXPENSE", sga: "OPERATING_EXPENSE", selling_general_admin: "OPERATING_EXPENSE", depreciation: "OPERATING_EXPENSE", other_income: "OTHER_INCOME", non_operating_income: "OTHER_INCOME", interest_income: "OTHER_INCOME", other_expense: "OTHER_EXPENSE", non_operating_expense: "OTHER_EXPENSE", interest_expense: "OTHER_EXPENSE", tax: "TAX_EXPENSE", income_tax: "TAX_EXPENSE", tax_expense: "TAX_EXPENSE", }; function classifyAccount(a: any): PnlClassification { const key = String(a.subtype ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_"); if (key && PNL_SUBTYPE_MAP[key]) return PNL_SUBTYPE_MAP[key]; return a.type === "income" ? "REVENUE" : "OPERATING_EXPENSE"; } function runPnLEngine(d: any): PnlResult | { error: string } { const nominal = ((d.accounts ?? []) as any[]).filter((a) => a.type === "income" || a.type === "expense"); const accts: PnlAccount[] = nominal.map((a) => ({ id: a.id, name: a.name, type: a.type, classification: classifyAccount(a) })); const allow = new Set(nominal.map((a) => a.id)); const postings: PnlPosting[] = ((d.glLines ?? []) as any[]) .filter((l) => l.accounts && allow.has(l.accounts.id)) .map((l) => ({ accountId: l.accounts.id, debitMinor: toMinor(l.debit ?? 0), creditMinor: toMinor(l.credit ?? 0), date: l.journal_entries?.date ?? d.from ?? d.asOf, })); try { return computePnL(postings, accts, { periodStart: d.from ?? d.asOf, periodEnd: d.asOf }); } catch (e) { if (e instanceof PnlValidationError) return { error: e.message }; return { error: e instanceof Error ? e.message : String(e) }; } } function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredReport { const cur = runPnLEngine(d); if ("error" in cur) { return { title: "Profit & Loss", rows: [ { kind: "section", label: "Profit & Loss could not be computed (validation failed)" }, { kind: "section", label: cur.error }, ], }; } const prevRes = useCompare && p ? runPnLEngine(p) : undefined; const prevOk = prevRes && !("error" in prevRes) ? (prevRes as PnlResult) : undefined; const s = cur.subtotals; const ps = prevOk?.subtotals; const D = fromMinor; const cmp = (v?: number) => (useCompare && v !== undefined ? D(v) : undefined); const rows: StructuredRow[] = []; const prevByAcct = new Map((prevOk?.lines ?? []).map((l) => [l.accountId, l.amountMinor])); // Emit per-account line items for the given classifications. `displaySign` // shows contra/expense reductions as negative figures in the column. const lineRows = (classes: PnlClassification[], displaySign: 1 | -1) => { for (const l of cur.lines.filter((x) => classes.includes(x.classification)).sort((a, b) => a.name.localeCompare(b.name))) { rows.push({ kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign), compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined, accountId: l.accountId, }); } }; const has = (cs: PnlClassification[]) => cur.lines.some((l) => cs.includes(l.classification)); const hasContra = has(["CONTRA_REVENUE"]); const hasCogs = has(["COGS", "CONTRA_COGS"]) || s.costOfGoodsSold !== 0; const hasOther = has(["OTHER_INCOME", "OTHER_EXPENSE"]) || s.otherNet !== 0; const hasTax = has(["TAX_EXPENSE"]) || s.incomeTaxExpense !== 0; // Revenue → Net Revenue rows.push({ kind: "section", label: "Revenue" }); lineRows(["REVENUE"], 1); if (hasContra) lineRows(["CONTRA_REVENUE"], -1); rows.push({ kind: "total", label: "Net Revenue", amount: D(s.netRevenue), compare: cmp(ps?.netRevenue) }); rows.push({ kind: "spacer", label: "" }); // Cost of Goods Sold → Gross Profit (only when present) if (hasCogs) { rows.push({ kind: "section", label: "Cost of Goods Sold" }); lineRows(["COGS"], 1); if (has(["CONTRA_COGS"])) lineRows(["CONTRA_COGS"], -1); rows.push({ kind: "total", label: "Total Cost of Goods Sold", amount: D(s.costOfGoodsSold), compare: cmp(ps?.costOfGoodsSold) }); rows.push({ kind: "total", label: "Gross Profit", amount: D(s.grossProfit), compare: cmp(ps?.grossProfit) }); rows.push({ kind: "spacer", label: "" }); } // Operating Expenses → Operating Income (EBIT) rows.push({ kind: "section", label: "Operating Expenses" }); lineRows(["OPERATING_EXPENSE"], 1); rows.push({ kind: "total", label: "Total Operating Expenses", amount: D(s.operatingExpenses), compare: cmp(ps?.operatingExpenses) }); rows.push({ kind: "total", label: "Operating Income (EBIT)", amount: D(s.operatingIncome), compare: cmp(ps?.operatingIncome) }); // Other Income / (Expense) if (hasOther) { rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "section", label: "Other Income / (Expense)" }); lineRows(["OTHER_INCOME"], 1); lineRows(["OTHER_EXPENSE"], -1); rows.push({ kind: "total", label: "Total Other Income / (Expense)", amount: D(s.otherNet), compare: cmp(ps?.otherNet) }); } // Pre-Tax Income (EBT) — shown when it differs from Operating Income if (hasOther || hasTax) { rows.push({ kind: "total", label: "Pre-Tax Income (EBT)", amount: D(s.preTaxIncome), compare: cmp(ps?.preTaxIncome) }); } // Income Tax if (hasTax) { rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "section", label: "Income Tax" }); lineRows(["TAX_EXPENSE"], 1); rows.push({ kind: "total", label: "Income Tax Expense", amount: D(s.incomeTaxExpense), compare: cmp(ps?.incomeTaxExpense) }); } rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "grand", label: "Net Income", amount: D(s.netIncome), compare: cmp(ps?.netIncome) }); // Margins (presentation-only, §5) const m = computeMargins(cur); if (m.netMargin !== null) { const pct = (x: number | null) => (x === null ? "—" : `${(x * 100).toFixed(1)}%`); rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "section", label: `Margins — Gross ${pct(m.grossMargin)} · Operating ${pct(m.operatingMargin)} · Net ${pct(m.netMargin)}` }); } return { title: "Profit & Loss", rows }; } // Per-account natural balances + retained-earnings split from a dataset's GL. function bsBalances(ds: any) { const fyStart = `${String(ds?.asOf ?? "").slice(0, 4)}-01-01`; const isDebitNormal = (t: string) => t === "asset" || t === "expense"; const glByAcct = new Map(); let incomeAll = 0, expenseAll = 0, incomePrior = 0, expensePrior = 0; for (const l of (ds?.glCumulative ?? []) as any[]) { const t = l.accounts?.type; if (!t) continue; const debit = Number(l.debit || 0), credit = Number(l.credit || 0); const nat = isDebitNormal(t) ? debit - credit : credit - debit; glByAcct.set(l.account_id, (glByAcct.get(l.account_id) ?? 0) + nat); const isPrior = String(l.journal_entries?.date ?? "") < fyStart; if (t === "income") { incomeAll += credit - debit; if (isPrior) incomePrior += credit - debit; } else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; } } const rePrior = incomePrior - expensePrior; const cye = (incomeAll - expenseAll) - rePrior; return { glByAcct, rePrior, cye }; } function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredReport { // GL-derived (opening balances posted to the GL). Current-Year Earnings is // computed independently from actuals (income − expense), never plugged. const accounts = (d.accounts ?? []) as any[]; const cur = bsBalances(d); const prev = useCompare && p ? bsBalances(p) : undefined; const balOf = (a: any) => (cur.glByAcct.get(a.id) ?? 0); const balOfP = (a: any) => (prev ? (prev.glByAcct.get(a.id) ?? 0) : undefined); const cmp = (v: number | undefined) => (prev ? v : undefined); const byType = (t: string) => accounts.filter((a) => a.type === t); const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0); const sumBalP = (rows: any[]) => (prev ? rows.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined); const rows: StructuredRow[] = []; // Assets rows.push({ kind: "section", label: "Assets" }); const assets = byType("asset"); for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id }); const totalA = sumBal(assets); rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) }); rows.push({ kind: "spacer", label: "" }); // Liabilities rows.push({ kind: "section", label: "Liabilities" }); const liabs = byType("liability"); for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id }); const totalL = sumBal(liabs); rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) }); rows.push({ kind: "spacer", label: "" }); // Equity — equity accounts + calculated RE / current-year earnings rows.push({ kind: "section", label: "Equity" }); const equityAccs = byType("equity"); // A real "Retained Earnings" equity account is postable via journal entries. // Fold its posted balance into the calculated "Retained Earnings (prior years)" // line so manual JE adjustments show there instead of as a separate line. // Posted "Retained Earnings" and "Current Year Earnings" equity accounts are // postable via journal entries / opening balances. Fold their balances into the // calculated prior-years and Net Income lines (instead of separate lines) so the // figures the user enters in Opening Balances show up where expected. const reAccts = equityAccs.filter((a) => /retained\s+earnings/i.test(String(a.name || ""))); const cyeAccts = equityAccs.filter((a) => /current\s*year\s*(earnings|income)/i.test(String(a.name || "")) || /^\s*net\s*income\s*$/i.test(String(a.name || ""))); const foldedIds = new Set([...reAccts, ...cyeAccts].map((a) => a.id)); const otherEquity = equityAccs.filter((a) => !foldedIds.has(a.id)); for (const a of otherEquity) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id }); const rePosted = reAccts.reduce((s, a) => s + balOf(a), 0); const rePostedP = prev ? reAccts.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined; const cyePosted = cyeAccts.reduce((s, a) => s + balOf(a), 0); const cyePostedP = prev ? cyeAccts.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined; rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior + rePosted, compare: cmp(prev ? prev.rePrior + (rePostedP ?? 0) : undefined) }); rows.push({ kind: "sub", label: "Net Income", amount: cur.cye + cyePosted, compare: cmp(prev ? prev.cye + (cyePostedP ?? 0) : undefined) }); const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye; const totalEP = prev ? (sumBalP(equityAccs)! + prev.rePrior + prev.cye) : undefined; rows.push({ kind: "total", label: "Total Equity", amount: totalE, compare: cmp(totalEP) }); rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE, compare: cmp(prev ? (sumBalP(liabs)! + totalEP!) : undefined) }); // Residual surfaced (never plugged): Assets − (Liabilities + Equity). const cents = (n: number) => Math.round(n * 100); const residualCents = cents(totalA) - cents(totalL + totalE); const balanced = residualCents === 0; return { title: "Balance Sheet", rows, balanced, outOfBalanceAmount: balanced ? undefined : residualCents / 100, }; } // Indirect-method cash flow built from the GL (§5). It ties to the change in the // Balance Sheet cash accounts by construction (R4): because every entry balances, // the cash impact of net income + all non-cash balance movements equals ΔCash. type CashFlowCalc = { netIncome: number; operating: { label: string; amount: number }[]; cfo: number; cfi: number; cff: number; netChange: number; beginCash: number; endCash: number; residual: number; }; function computeCashFlow(d: any): CashFlowCalc { const from: string = d.from; const acctById = new Map((d.accounts ?? []).map((a: any) => [a.id, a])); const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || ""))); const beginRaw = new Map(); const endRaw = new Map(); for (const l of (d.glCumulative ?? []) as any[]) { const raw = Number(l.debit || 0) - Number(l.credit || 0); endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw); if (String(l.journal_entries?.date ?? "") < from) beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw); } const ids = new Set([...endRaw.keys(), ...beginRaw.keys()]); const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0); let beginCash = 0, endCash = 0, revenue = 0, expense = 0, cfi = 0, cff = 0; const operating: { label: string; amount: number }[] = []; for (const id of ids) { const a = acctById.get(id); if (!a) continue; if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; } if (a.type === "income") { revenue += -deltaRaw(id); continue; } if (a.type === "expense") { expense += deltaRaw(id); continue; } const impact = -deltaRaw(id); if (Math.abs(impact) < 0.005) continue; const name = String(a.name || "").toLowerCase(); if (a.type === "asset") { const naturalUp = deltaRaw(id) > 0; if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact; else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact }); } else if (a.type === "liability") { const naturalUp = -deltaRaw(id) > 0; if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact; else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact }); } else if (a.type === "equity") { cff += impact; } } const netIncome = revenue - expense; const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0); const netChange = cfo + cfi + cff; return { netIncome, operating, cfo, cfi, cff, netChange, beginCash, endCash, residual: netChange - (endCash - beginCash) }; } function buildCashFlow(d: any, p: any | undefined, useCompare: boolean): StructuredReport { const cur = computeCashFlow(d); const prev = useCompare && p ? computeCashFlow(p) : undefined; const cmp = (v: number | undefined) => (useCompare && prev ? v : undefined); // Match prior-period operating line items by label for the compare column. const prevByLabel = new Map((prev?.operating ?? []).map((r) => [r.label, r.amount])); const rows: StructuredRow[] = []; rows.push({ kind: "section", label: "Operating Activities" }); rows.push({ kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) }); for (const r of cur.operating) rows.push({ kind: "sub", label: r.label, amount: r.amount, compare: cmp(prevByLabel.get(r.label) ?? 0) }); rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cur.cfo, compare: cmp(prev?.cfo) }); rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "section", label: "Investing Activities" }); rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cur.cfi, compare: cmp(prev?.cfi) }); rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "section", label: "Financing Activities" }); rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cur.cff, compare: cmp(prev?.cff) }); rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "sub", label: "Beginning Cash", amount: cur.beginCash, compare: cmp(prev?.beginCash) }); rows.push({ kind: "sub", label: "Ending Cash", amount: cur.endCash, compare: cmp(prev?.endCash) }); if (Math.abs(cur.residual) >= 0.005) { rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF − ΔCash)", amount: cur.residual }); } return { title: "Cash Flow Statement", rows, cashHighlight: { label: "Net Change in Cash", amount: cur.netChange }, }; } // ---------- Flat (non-financial) report builders ---------- type Flat = { title: string; columns: string[]; rows: (string | number)[][]; boldRows?: number[] }; function buildFlat(id: ReportId, d: any, cur: string): Flat | null { const m = (n: number) => money(n, cur); switch (id) { case "invoice-summary": return { 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))]), }; case "customer-balances": return { title: "Customer Balances", columns: ["Customer", "Balance"], rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]), }; case "expense-summary": { // GL-driven so it follows the same recognition rule as the P&L: a bill's // expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment // with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the // ledger avoids double-counting and never misses direct payments. const byAcct: Record = {}; for (const l of (d.glLines ?? []) as any[]) { const acc = l.accounts; if (acc?.type !== "expense") continue; const amt = Number(l.debit) - Number(l.credit); if (amt === 0) continue; const name = acc.name ?? "Expense"; byAcct[name] = (byAcct[name] ?? 0) + amt; } const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]); const total = Object.values(byAcct).reduce((s, v) => s + v, 0); return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] }; } case "vendor-balances": { const byVendor: Record = {}; for (const b of d.bills) { const name = b.vendors?.name ?? "—"; const bal = Number(b.total) - Number(b.paid_amount ?? 0); if (bal > 0) byVendor[name] = (byVendor[name] ?? 0) + bal; } return { title: "Vendor Balances (open bills)", columns: ["Vendor", "Outstanding"], rows: Object.entries(byVendor).map(([n, v]) => [n, m(v)]), }; } default: return null; } } function flatToStructured(flat: Flat, title: string): StructuredReport { const rows: StructuredRow[] = [{ kind: "section", label: flat.columns.join(" · ") }]; for (const r of flat.rows) { const label = r.slice(0, r.length - 1).map(String).join(" · "); const last = r[r.length - 1]; const num = typeof last === "number" ? last : parseFloat(String(last).replace(/[^0-9.\-]/g, "")) || 0; rows.push({ kind: "sub", label, amount: num }); } return { title, rows }; } // ---------- Budget vs Actuals ---------- /** Order accounts as a tree (parents first, children indented) instead of by number. */ function orderAccountsHierarchically(accs: any[]): any[] { const byId = new Map(accs.map((a) => [a.id, a])); const childrenByParent = new Map(); const roots: any[] = []; for (const a of accs) { if (a.parent_account_id && byId.has(a.parent_account_id)) { const arr = childrenByParent.get(a.parent_account_id) ?? []; arr.push(a); childrenByParent.set(a.parent_account_id, arr); } else { roots.push(a); } } const byCode = (a: any, b: any) => String(a.code ?? "").localeCompare(String(b.code ?? "")); roots.sort(byCode); const out: any[] = []; const visit = (node: any, depth: number) => { out.push({ ...node, _depth: depth }); for (const k of (childrenByParent.get(node.id) ?? []).sort(byCode)) visit(k, depth + 1); }; for (const r of roots) visit(r, 0); return out; } async function fetchBvaActuals(companyId: string, f: string, t: string) { const [inv, exp, txns, billItemsRes] = await Promise.all([ accounting.from("invoices").select("total,status,issue_date").eq("company_id", companyId).gte("issue_date", f).lte("issue_date", t).not("status", "in", '("void","draft")'), accounting.from("expenses").select("amount,category,date").eq("company_id", companyId).gte("date", f).lte("date", t), accounting.from("transactions").select("coa_account_id,amount,type").eq("company_id", companyId).gte("date", f).lte("date", t).not("coa_account_id", "is", null), accounting.from("bill_items").select("account_id,amount,bills!inner(issue_date,company_id)").eq("bills.company_id", companyId).gte("bills.issue_date", f).lte("bills.issue_date", t).not("account_id", "is", null), ]); return { invoices: inv.data ?? [], expenses: exp.data ?? [], transactions: txns.data ?? [], billItems: billItemsRes.data ?? [] }; } // Actuals per account for a given actualsData window (transactions, bill items, // expenses, and accrual invoice income distributed by budget weight). function computeBvaActuals(actualsData: any, grouped: Record, budgetByAcct: Record): Record { const m: Record = {}; if (!actualsData) return m; const expAccs = grouped.expense ?? []; for (const tx of actualsData.transactions as any[]) { if (!tx.coa_account_id) continue; m[tx.coa_account_id] = (m[tx.coa_account_id] ?? 0) + Number(tx.amount); } for (const bi of actualsData.billItems as any[]) { if (!bi.account_id) continue; m[bi.account_id] = (m[bi.account_id] ?? 0) + Number(bi.amount); } for (const e of actualsData.expenses as any[]) { const cat = String(e.category ?? "").toLowerCase().trim(); const match = expAccs.find((a) => a.name.toLowerCase().trim() === cat || (a.code && a.code.toLowerCase().trim() === cat)); if (match) m[match.id] = (m[match.id] ?? 0) + Number(e.amount); } const totalPaidInvoices = actualsData.invoices .filter((i: any) => i.status !== "void" && i.status !== "draft") .reduce((s: number, i: any) => s + Number(i.total), 0); const alreadyCountedIncome = (grouped.income ?? []).reduce((s: number, a: any) => s + (m[a.id] ?? 0), 0); const remainingInvoiceIncome = Math.max(0, totalPaidInvoices - alreadyCountedIncome); if (remainingInvoiceIncome > 0 && (grouped.income ?? []).length > 0) { const incomeAccs = grouped.income ?? []; const totalBudgeted = incomeAccs.reduce((s: number, a: any) => s + (budgetByAcct[a.id] ?? 0), 0); for (const a of incomeAccs) { const weight = totalBudgeted > 0 ? (budgetByAcct[a.id] ?? 0) / totalBudgeted : 1 / incomeAccs.length; m[a.id] = (m[a.id] ?? 0) + remainingInvoiceIncome * weight; } } return m; } function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel, logoUrl }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string; logoUrl?: string | null }) { const { data: budgets = [] } = useQuery({ queryKey: ["budgets-active", companyId], enabled: !!companyId, queryFn: async () => (await accounting.from("budgets").select("*").eq("company_id", companyId).eq("status", "active").order("created_at", { ascending: false })).data ?? [], }); const [budgetId, setBudgetId] = useState(""); useEffect(() => { if (!budgetId && budgets.length) setBudgetId(budgets[0].id); }, [budgets, budgetId]); // Actuals comparison window — defaults to the report period, but the user can // choose any custom date range to compare against the budget. const [actFrom, setActFrom] = useState(from); const [actTo, setActTo] = useState(to); useEffect(() => { setActFrom(from); setActTo(to); }, [from, to]); const actualsLabel = `${actFrom} to ${actTo}`; const { data: accounts = [] } = useQuery({ queryKey: ["accounts", companyId, "budget-actuals"], enabled: !!companyId, queryFn: async () => (await accounting.from("accounts").select("*").eq("company_id", companyId).eq("is_archived", false).order("code")).data ?? [], }); const { data: entries = [] } = useQuery({ queryKey: ["budget-entries", budgetId], enabled: !!budgetId, queryFn: async () => (await accounting.from("budget_entries").select("*").eq("budget_id", budgetId)).data ?? [], }); // Optional comparison window (B3): compare actuals to another date range. const [cmpOn, setCmpOn] = useState(false); const [cmpFrom, setCmpFrom] = useState(""); const [cmpTo, setCmpTo] = useState(""); const { data: actualsData } = useQuery({ queryKey: ["bva-actuals", companyId, actFrom, actTo], enabled: !!companyId, queryFn: () => fetchBvaActuals(companyId, actFrom, actTo), }); const { data: cmpActualsData } = useQuery({ queryKey: ["bva-actuals-cmp", companyId, cmpFrom, cmpTo], enabled: !!companyId && cmpOn && !!cmpFrom && !!cmpTo, queryFn: () => fetchBvaActuals(companyId, cmpFrom, cmpTo), }); const TYPES_LOCAL = [ { value: "income", label: "Income", favorableWhen: "over" as const }, { value: "expense", label: "Expenses", favorableWhen: "under" as const }, ]; const grouped = useMemo(() => { const out: Record = { income: [], expense: [] }; for (const a of accounts) if (a.type === "income" || a.type === "expense") out[a.type].push(a); return out; }, [accounts]); // Same accounts, ordered as a parent→child tree (each carries `_depth`). const groupedOrdered = useMemo(() => ({ income: orderAccountsHierarchically(grouped.income ?? []), expense: orderAccountsHierarchically(grouped.expense ?? []), } as Record), [grouped]); const selectedBudget = useMemo(() => (budgets as any[]).find((b) => b.id === budgetId), [budgets, budgetId]); // Budget pro-rated to the selected actuals window (B2): sum each budget period // weighted by how much of it overlaps [actFrom, actTo]. period_index maps to // months (monthly), quarters (quarterly), or the whole year (annual). const budgetByAcct = useMemo(() => { const m: Record = {}; const pt = String(selectedBudget?.period_type ?? "annual"); const fy = Number(selectedBudget?.fiscal_year) || new Date(actFrom || actTo || Date.now()).getFullYear(); const fromT = actFrom ? new Date(actFrom).getTime() : -Infinity; const toT = actTo ? new Date(actTo).getTime() : Infinity; const DAY = 86400000; const span = (idx: number): [number, number] => { if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()]; if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()]; return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()]; }; for (const e of (entries as any[])) { const [s, en] = span(Number(e.period_index) || 0); const overlap = Math.max(0, Math.min(en, toT) - Math.max(s, fromT)); const full = en - s; const weight = full > 0 ? Math.min(1, (overlap + DAY) / (full + DAY)) : 1; if (weight <= 0) continue; m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount) * weight; } return m; }, [entries, selectedBudget, actFrom, actTo]); const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]); const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]); // Comparison-window budget (pro-rated like budgetByAcct, over [cmpFrom, cmpTo]). const cmpBudgetByAcct = useMemo(() => { const m: Record = {}; if (!cmpOn || !cmpFrom || !cmpTo) return m; const pt = String(selectedBudget?.period_type ?? "annual"); const fy = Number(selectedBudget?.fiscal_year) || new Date(cmpFrom || cmpTo || Date.now()).getFullYear(); const fromT = new Date(cmpFrom).getTime(); const toT = new Date(cmpTo).getTime(); const DAY = 86400000; const span = (idx: number): [number, number] => { if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()]; if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()]; return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()]; }; for (const e of (entries as any[])) { const [s, en] = span(Number(e.period_index) || 0); const overlap = Math.max(0, Math.min(en, toT) - Math.max(s, fromT)); const full = en - s; const weight = full > 0 ? Math.min(1, (overlap + DAY) / (full + DAY)) : 1; if (weight <= 0) continue; m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount) * weight; } return m; }, [entries, selectedBudget, cmpOn, cmpFrom, cmpTo]); // Full-year budget per account (no pro-ration) for the Annual Budget column. const annualBudgetByAcct = useMemo(() => { const m: Record = {}; for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount); return m; }, [entries]); const chartData = useMemo(() => { const sumGroup = (type: "income" | "expense") => { const accs = grouped[type] ?? []; const b = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0); const ac = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0); return { b, ac }; }; const inc = sumGroup("income"); const exp = sumGroup("expense"); return [ { name: "Income", Budget: inc.b, Actual: inc.ac }, { name: "Expenses", Budget: exp.b, Actual: exp.ac }, ]; }, [grouped, budgetByAcct, actualByAcct]); // Flattened rows (group totals + accounts) shared by the CSV and PDF exports. const exportRows = useMemo(() => { const rows: { label: string; budget: number; actual: number; variance: number; pct: string; group: boolean }[] = []; for (const t of TYPES_LOCAL) { const accs = grouped[t.value] ?? []; if (!accs.length) continue; const tb = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0); const ta = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0); const tv = ta - tb; rows.push({ label: t.label, budget: tb, actual: ta, variance: tv, pct: tb ? `${((tv / tb) * 100).toFixed(1)}%` : "—", group: true }); for (const a of groupedOrdered[t.value] ?? []) { const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const v = ac - b; const indent = " ".repeat(a._depth ?? 0); rows.push({ label: `${indent}${a.code ? `${a.name} (${a.code})` : a.name}`, budget: b, actual: ac, variance: v, pct: b ? `${((v / b) * 100).toFixed(1)}%` : "—", group: false }); } } return rows; }, [grouped, groupedOrdered, budgetByAcct, actualByAcct]); const fileBase = `budget-vs-actuals-${actFrom}-to-${actTo}`; const exportCSV = () => { const esc = (s: any) => `"${String(s).replace(/"/g, '""')}"`; const lines = [["Account", "Budget", "Actual", "Variance", "Variance %"].join(",")]; for (const r of exportRows) lines.push([esc(r.label), r.budget.toFixed(2), r.actual.toFixed(2), r.variance.toFixed(2), esc(r.pct)].join(",")); const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${fileBase}.csv`; a.click(); URL.revokeObjectURL(url); }; // Export using the shared branded Budget vs. Actuals generator (current period + // optional comparison + Annual Budget columns), matching the main report style. const exportPDF = async () => { const nameById = new Map((accounts as any[]).map((a) => [a.id, a.name])); const parentIds = new Set((accounts as any[]).filter((a) => a.parent_account_id).map((a) => a.parent_account_id)); const rows: any[] = []; for (const type of ["income", "expense"] as const) { for (const a of (grouped[type] ?? [])) { if (parentIds.has(a.id)) continue; // parent accounts render as group headers const budget = budgetByAcct[a.id] ?? 0; const actual = actualByAcct[a.id] ?? 0; const cmpA = cmpActualByAcct[a.id] ?? 0; const cmpB = cmpBudgetByAcct[a.id] ?? 0; const annual = annualBudgetByAcct[a.id] ?? 0; rows.push({ id: a.id, category: a.code ? `${a.code} ${a.name}` : a.name, accountType: type, parentId: a.parent_account_id ?? null, parentCategory: a.parent_account_id ? (nameById.get(a.parent_account_id) ?? null) : null, budget, annualBudget: annual, actual, variance: actual - budget, pctOfBudget: budget ? (actual / budget) * 100 : 0, comparisonActual: cmpA, comparisonBudget: cmpB, comparisonVariance: cmpA - cmpB, comparisonPctOfBudget: cmpB ? (cmpA / cmpB) * 100 : 0, cmpDelta: 0, }); } } const inc = rows.filter((r) => r.accountType === "income"); const exp = rows.filter((r) => r.accountType !== "income"); const sum = (rs: any[], k: string) => rs.reduce((s, r) => s + (Number(r[k]) || 0), 0); await generateBudgetVsActualPdf({ association: { name: companyName, logo_url: logoUrl ?? null }, fiscalYear: Number(selectedBudget?.fiscal_year) || new Date().getFullYear(), rangeLabel: actualsLabel, comparisonLabel: cmpOn && cmpFrom && cmpTo ? "Comparison" : null, comparisonRangeLabel: cmpOn && cmpFrom && cmpTo ? `${cmpFrom} to ${cmpTo}` : null, rows, totals: { incomeBudget: sum(inc, "budget"), incomeActual: sum(inc, "actual"), incomeCmp: sum(inc, "comparisonActual"), incomeCmpBudget: sum(inc, "comparisonBudget"), expenseBudget: sum(exp, "budget"), expenseActual: sum(exp, "actual"), expenseCmp: sum(exp, "comparisonActual"), expenseCmpBudget: sum(exp, "comparisonBudget"), }, comparisonBudgetMonths: null, }); }; if (!budgets.length) { return ( No active budgets yet. Create one to compare against actuals. ); } return (
setActFrom(e.target.value)} className="h-9 w-40" /> setActTo(e.target.value)} className="h-9 w-40" />
{cmpOn && (<> setCmpFrom(e.target.value)} className="h-9 w-40" /> setCmpTo(e.target.value)} className="h-9 w-40" /> )}
Budget pro-rated to the selected period ({String((selectedBudget as any)?.period_type ?? "annual")} budget).
Account Budget Actual Variance Variance % {cmpOn && Compare} {cmpOn && Δ vs Compare} {TYPES_LOCAL.map((t) => { const accs = grouped[t.value] ?? []; if (!accs.length) return null; const totalB = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0); const totalA = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0); const totalC = accs.reduce((s, a) => s + (cmpActualByAcct[a.id] ?? 0), 0); const totalVar = totalA - totalB; const totalPct = totalB ? (totalVar / totalB) * 100 : 0; const totalFavorable = t.favorableWhen === "over" ? totalVar >= 0 : totalVar <= 0; return ( {t.label} {money(totalB, currency)} {money(totalA, currency)} {money(totalVar, currency)} {totalB ? `${totalPct.toFixed(1)}%` : "—"} {cmpOn && {money(totalC, currency)}} {cmpOn && {money(totalA - totalC, currency)}} {(groupedOrdered[t.value] ?? []).map((a: any) => { const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const c = cmpActualByAcct[a.id] ?? 0; const v = ac - b; const pct = b ? (v / b) * 100 : 0; const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0; const depth = a._depth ?? 0; return ( {a.name} {a.code && {a.code}} {money(b, currency)} {money(ac, currency)} {money(v, currency)} {b ? `${pct.toFixed(1)}%` : "—"} {cmpOn && {money(c, currency)}} {cmpOn && {money(ac - c, currency)}} ); })} ); })}
); } // ── AP Aging ───────────────────────────────────────────────────────────────── function APAgingTable({ rows, currency }: { rows: any[]; currency: string }) { const now = new Date(); type APRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number }; const byVendor = new Map(); for (const bill of rows) { const open = Number(bill.total ?? 0) - Number(bill.paid_amount ?? 0); if (open <= 0) continue; const vid = bill.vendor_id ?? bill.vendors?.id ?? bill.id; const name = bill.vendors?.name ?? "Unknown Vendor"; const due = bill.due_date ? new Date(bill.due_date) : new Date(bill.issue_date ?? Date.now()); const days = Math.floor((now.getTime() - due.getTime()) / 86400000); const r = byVendor.get(vid) ?? { id: vid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }; if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open; r.total += open; byVendor.set(vid, r); } const list = Array.from(byVendor.values()).sort((a, b) => b.total - a.total); const totals = list.reduce( (s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }), { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 } ); return ( Vendor Current 1–30 days 31–60 days 61–90 days 90+ days Total {list.map((r) => ( {r.name} {money(r.current, currency)} {money(r.d30, currency)} {money(r.d60, currency)} {money(r.d90, currency)} {money(r.d90p, currency)} {money(r.total, currency)} ))} {list.length === 0 && No outstanding payables.} {list.length > 0 && ( Total {money(totals.current, currency)} {money(totals.d30, currency)} {money(totals.d60, currency)} {money(totals.d90, currency)} {money(totals.d90p, currency)} {money(totals.total, currency)} )}
); } // ── Homeowner Balance Summary ───────────────────────────────────────────────── function HomeownerSummaryTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) { const byCustomer = useMemo(() => { const m = new Map(); for (const inv of invoices) { const cid = inv.customer_id; if (!cid) continue; const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); const isPaid = inv.status === "paid"; const cur = m.get(cid) ?? { open: 0, count: 0, lastPaid: null }; if (open > 0) { cur.open += open; cur.count++; } if (isPaid && (!cur.lastPaid || inv.issue_date > cur.lastPaid)) cur.lastPaid = inv.issue_date; m.set(cid, cur); } return m; }, [invoices]); const rows = customers .map((c: any) => ({ ...c, ...(byCustomer.get(c.id) ?? { open: 0, count: 0, lastPaid: null }) })) .sort((a, b) => b.open - a.open); const totalBalance = rows.reduce((s, r) => s + Number(r.balance ?? 0), 0); const totalOpen = rows.reduce((s, r) => s + r.open, 0); return ( Homeowner Property Open Invoices Outstanding Balance Last Payment {rows.map((r: any) => ( {r.name} {r.property_address ?? "—"}{r.lot_number ? ` · Lot ${r.lot_number}` : ""} {r.count || "—"} 0 ? "text-red-600" : ""}`}>{money(r.open, currency)} {r.lastPaid ? fmtDate(r.lastPaid) : "—"} ))} {rows.length === 0 && No homeowners.} Total ({rows.length} homeowners) {money(totalOpen, currency)}
); } // ── Delinquency Report ──────────────────────────────────────────────────────── function DelinquencyTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) { const now = new Date(); type DelRow = { id: string; name: string; email: string | null; phone: string | null; property: string | null; overdue: number; oldest: number; invoiceCount: number }; const byCustomer = new Map(); for (const inv of invoices) { const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); if (open <= 0) continue; 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); if (days <= 0) continue; // not overdue yet const cid = inv.customer_id; if (!cid) continue; const cust = customers.find((c: any) => c.id === cid); const r = byCustomer.get(cid) ?? { id: cid, name: cust?.name ?? inv.customers?.name ?? "—", email: cust?.email ?? null, phone: cust?.phone ?? null, property: cust?.property_address ?? null, overdue: 0, oldest: 0, invoiceCount: 0, }; r.overdue += open; r.oldest = Math.max(r.oldest, days); r.invoiceCount++; byCustomer.set(cid, r); } const list = Array.from(byCustomer.values()).sort((a, b) => b.overdue - a.overdue); const totalOverdue = list.reduce((s, r) => s + r.overdue, 0); const ageBucket = (days: number) => { if (days <= 30) return { label: "1–30 days", cls: "bg-amber-100 text-amber-800" }; if (days <= 60) return { label: "31–60 days", cls: "bg-orange-100 text-orange-800" }; if (days <= 90) return { label: "61–90 days", cls: "bg-red-100 text-red-800" }; return { label: "90+ days", cls: "bg-red-200 text-red-900 font-semibold" }; }; return (
{totalOverdue > 0 && (
{list.length} homeowners with overdue balances {money(totalOverdue, currency)} total overdue
)} Homeowner Property Contact Invoices Age Amount Overdue {list.map((r) => { const { label, cls } = ageBucket(r.oldest); return ( {r.name} {r.property ?? "—"} {r.email && } {r.phone &&
{r.phone}
}
{r.invoiceCount} {label} {money(r.overdue, currency)}
); })} {list.length === 0 && No overdue accounts. All homeowners are current.} {list.length > 0 && ( Total overdue {money(totalOverdue, currency)} )}
); } // ── AR Aging ────────────────────────────────────────────────────────────────── function ARAgingTable({ rows, currency, detailed = false }: { rows: any[]; currency: string; detailed?: boolean }) { const now = new Date(); type AgingRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number }; const byCustomer = new Map(); for (const inv of rows) { const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0); if (open <= 0) continue; const cid = inv.customer_id ?? inv.customers?.id; if (!cid) continue; const name = inv.customers?.name ?? "—"; const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date); const days = Math.floor((now.getTime() - due.getTime()) / 86400000); const r = byCustomer.get(cid) ?? { id: cid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }; if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open; r.total += open; byCustomer.set(cid, r); } const list = Array.from(byCustomer.values()).sort((a, b) => b.total - a.total); const totals = list.reduce( (s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }), { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 } ); return ( Homeowner Current 1–30 days 31–60 days 61–90 days 90+ days Total {list.map((r) => ( {r.name} {money(r.current, currency)} {money(r.d30, currency)} {money(r.d60, currency)} {money(r.d90, currency)} {money(r.d90p, currency)} {money(r.total, currency)} ))} {list.length === 0 && No outstanding receivables.} {list.length > 0 && ( Total {money(totals.current, currency)} {money(totals.d30, currency)} {money(totals.d60, currency)} {money(totals.d90, currency)} {money(totals.d90p, currency)} {money(totals.total, currency)} )}
); }