Files
acmcc/src/pages/accounting/AccountingReportsPage.tsx
T
admin 7eb08ad29f P&L monthly: span the fiscal year so columns aren't limited to one month
The monthly P&L was bound to the page Period, which defaults to the
current month — so 'Monthly columns' showed only a single column. When the
selected period is a single month, widen the start to the fiscal year
containing the end date (per company fiscal_year_start) so every month of
the FY-to-date gets a column with none missing. A multi-month selected
range is still respected as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 02:36:57 -04:00

2642 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, type RecCheck } 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: "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, includeArchived = false): Promise<any[]> {
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)
.lte("journal_entries.date", to);
// Archived accounts are normally kept off the financial statements, BUT an
// archived account that still holds a balance MUST stay on them — otherwise
// its balance is silently dropped and the Balance Sheet goes out of balance
// by exactly that amount. Including archived accounts here is safe: this
// queries journal lines, so archived accounts with no activity never appear.
if (!includeArchived) q = q.eq("accounts.is_archived", false);
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, periodBillItemsRes] = 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,parent_account_id").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, true),
// Cumulative GL through `to` — Balance Sheet is built from these (as-of balances)
fetchAllGLLines(cid, to, "id,debit,credit,account_id,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)", undefined, true),
// 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(),
// Bill items for bills issued in the period — lets the Expense Summary
// back out the expense of bills that aren't paid yet (cash-aware view).
accounting.from("bill_items").select("account_id,amount,bills!inner(total,paid_amount,status,issue_date,company_id)").eq("bills.company_id", cid).gte("bills.issue_date", from).lte("bills.issue_date", to).not("account_id", "is", null),
]);
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,
periodBillItems: periodBillItemsRes.data ?? [],
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<ReportId>("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<string | null>(null);
const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); };
// Period
const [preset, setPreset] = useState<Preset>("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<string[]>(["balance-sheet", "pnl", "trial-balance", "general-ledger"]);
const [batchLoadedId, setBatchLoadedId] = useState<string | null>(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<CompareMode>("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 [pnlMonthView, setPnlMonthView] = 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<StructuredReport | null>(() => {
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 === "reconciliation") {
if (!data) return null;
const checks = buildReconChecks(data);
return {
title: "Reconciliation Checks",
columns: ["Check", "Residual", "Status"],
rows: checks.map((c) => [`${c.id} ${c.label}`, m(c.residual), c.pass ? "Pass" : "FAIL"]),
boldRows: [],
};
}
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<string, AR>();
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<string, AP>();
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<string, { open: number; count: number }>();
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<string, { name: string; property: string; email: string; phone: string; amount: number; oldest: number }>();
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 === "pnl" && pnlMonthView)
|| 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 (
<div className="flex gap-6">
<aside className="w-60 shrink-0">
<Card>
<CardContent className="p-3 space-y-4">
{GROUPS.map((g) => (
<div key={g.name}>
<div className="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{g.name}</div>
<ul className="space-y-0.5">
{g.reports.map((r) => (
<li key={r.id}>
<button
onClick={() => setActive(r.id)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${active === r.id ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{r.name}</span>
</button>
</li>
))}
</ul>
</div>
))}
</CardContent>
</Card>
</aside>
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">{activeMeta.name}</h1>
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setBatchOpen(true)}>
<Layers className="mr-1 h-4 w-4" /> Report Batches
</Button>
<Button variant="outline" onClick={refreshReport} disabled={refreshing}>
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} /> Refresh
</Button>
{hasOwnExport ? (
<span className="text-xs text-muted-foreground self-center">Export available inside the report </span>
) : (
<>
<Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</>
)}
</div>
</div>
{/* Period presets */}
<Card>
<CardContent className="py-3 px-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground w-14">Period</span>
{([
{ 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 }) => (
<button key={v} onClick={() => applyPreset(v)}
className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${preset === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
{l}
</button>
))}
{preset === "custom" && (
<div className="flex items-center gap-2 ml-2">
<Input type="date" value={from} onChange={e => setFrom(e.target.value)} className="h-7 w-36 text-xs" />
<span className="text-muted-foreground text-xs">to</span>
<Input type="date" value={to} onChange={e => setTo(e.target.value)} className="h-7 w-36 text-xs" />
</div>
)}
</div>
{/* Comparison period — only for financial reports */}
{isFinancial && (
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-xs font-medium text-muted-foreground w-14">Compare</span>
{([
{ 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 }) => (
<button key={v} onClick={() => setCompareMode(v)}
className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${compareMode === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
{l}
</button>
))}
{compareMode === "custom" && (
<div className="flex items-center gap-2 ml-2">
<Input type="date" value={compareFrom} onChange={e => setCompareFrom(e.target.value)} className="h-7 w-36 text-xs" />
<span className="text-muted-foreground text-xs">to</span>
<Input type="date" value={compareTo} onChange={e => setCompareTo(e.target.value)} className="h-7 w-36 text-xs" />
</div>
)}
</div>
)}
{/* Display toggles */}
<div className="flex flex-wrap gap-4 border-t pt-3">
<Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Account codes" />
<Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Zero-balance accounts" />
{active === "pnl" && (
<Toggle id="t-pnl-month" checked={pnlMonthView} onChange={setPnlMonthView} label="Monthly columns" />
)}
</div>
</CardContent>
</Card>
</div>
{active === "budget-vs-actuals" && (
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} logoUrl={logoUrl} />
)}
{active === "pnl" && pnlMonthView && (
<IncomeStatementReport companyId={cid} companyName={associationName ?? "Company"} from={from} to={to} fiscalYearStart={fiscalYearStart} currency={cur} logoUrl={logoUrl} />
)}
{active === "trial-balance" && (
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
)}
{active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} from={from} to={to} />
)}
{active === "reserve-fund" && (
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} to={to} />
)}
{active === "ar-aging-property" && (
<ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
)}
{active === "prepaid-homeowners" && (
<PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
)}
{active === "cash-disbursement" && (
<CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} from={from} to={to} />
)}
{active === "reconciliation" && (
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<ReconciliationReport d={data} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
</ReportSheet>
)}
{isFinancial && !(active === "pnl" && pnlMonthView) && (
!data ? (
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading</div></CardContent></Card>
) : structured ? (
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} onDrill={drillToAccount} />
</ReportSheet>
) : (
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
)
)}
{!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" && (
<ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
{!data ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : structured ? (
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
) : active === "customer-balances" ? (
<ARAgingTable rows={arOpen as any[]} currency={cur} />
) : active === "ar-aging" ? (
<ARAgingTable rows={arOpen as any[]} currency={cur} detailed />
) : active === "ap-aging" ? (
<APAgingTable rows={(data as any)?.allBills ?? []} currency={cur} />
) : active === "homeowner-summary" ? (
<HomeownerSummaryTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
) : active === "delinquency" ? (
<DelinquencyTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
) : flat && flat.rows.length > 0 ? (
<Table>
<TableHeader>
<TableRow>{flat.columns.map((c, i) => (
<TableHead key={c} className={i === flat.columns.length - 1 ? "text-right" : ""}>{c}</TableHead>
))}</TableRow>
</TableHeader>
<TableBody>
{flat.rows.map((row, i) => (
<TableRow key={i} className={flat.boldRows?.includes(i) ? "font-semibold bg-muted/30" : ""}>
{row.map((cell, j) => (
<TableCell key={j} className={j === row.length - 1 ? "text-right" : ""}>{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
)}
</ReportSheet>
)}
</div>
{/* ── Report Batches dialog ── */}
<Dialog open={batchOpen} onOpenChange={setBatchOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Report Batches</DialogTitle></DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Pick a set of reports to combine into one PDF for <strong>{associationName ?? "this association"}</strong>.
The package uses the period selected on this page (<span className="font-medium">{rangeLabel}</span>).
</p>
{savedBatches.length > 0 && (
<div>
<Label className="text-xs uppercase text-muted-foreground">Saved batches</Label>
<div className="mt-1 space-y-1">
{(savedBatches as any[]).map((b) => (
<div key={b.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${batchLoadedId === b.id ? "border-primary bg-primary/5" : ""}`}>
<button className="flex-1 text-left truncate" onClick={() => loadBatch(b)}>
{b.name} <span className="text-xs text-muted-foreground">· {(b.report_ids ?? []).length} reports</span>
</button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => deleteBatch(b.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
)}
<div>
<Label className="text-xs">Batch name</Label>
<Input value={batchName} onChange={(e) => setBatchName(e.target.value)} placeholder="e.g. Monthly Board Package" className="mt-1" />
</div>
<div>
<Label className="text-xs uppercase text-muted-foreground">Reports (in packet order)</Label>
<div className="mt-1 space-y-1 max-h-64 overflow-y-auto rounded border p-2">
{BATCHABLE_REPORTS.map((r) => (
<label key={r.id} className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-accent cursor-pointer text-sm">
<Checkbox checked={batchReportIds.includes(r.id)} onCheckedChange={() => toggleBatchReport(r.id)} />
{r.name}
</label>
))}
</div>
</div>
</div>
<DialogFooter className="flex-wrap gap-2">
{batchLoadedId && (
<Button variant="ghost" onClick={() => { setBatchLoadedId(null); setBatchName(""); }}>New batch</Button>
)}
<Button variant="outline" onClick={saveBatch}>{batchLoadedId ? "Update" : "Save"} batch</Button>
<Button onClick={generateBatch} disabled={generatingBatch || batchReportIds.length === 0}>
{generatingBatch ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <FileDown className="mr-1 h-4 w-4" />}
Generate PDF
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Toggle({ id, checked, onChange, label, disabled }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return (
<div className="flex items-center gap-2">
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
<Label htmlFor={id} className={disabled ? "text-muted-foreground" : ""}>{label}</Label>
</div>
);
}
// ── 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;
}
/** First day of the fiscal year that contains `to`, given an "MM-DD" FY start. */
function isFyStartFor(to: string, fyStart: string): string {
const [mm, dd] = (fyStart || "01-01").split("-").map(Number);
const ty = Number(to.slice(0, 4));
const candidate = `${ty}-${isPad2(mm || 1)}-${isPad2(dd || 1)}`;
return to >= candidate ? candidate : `${ty - 1}-${isPad2(mm || 1)}-${isPad2(dd || 1)}`;
}
/** 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<string, number>; total: number };
type ISTotal = { by: Map<string, number>; 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<string, number>; total: number }[], periods: { key: string }[]): ISTotal {
const by = new Map<string, number>(); 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<string, ISAcct[]>();
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, fiscalYearStart, currency, logoUrl }: {
companyId: string; companyName: string; from: string; to: string; fiscalYearStart?: string; currency: string; logoUrl?: string | null;
}) {
const [gran, setGran] = useState<ISGran>("month");
// The point of the monthly P&L is a column per period. If the page Period is a
// single month (the default), there'd be just one column — so widen the start
// to the fiscal year containing `to`, giving every month of the FY-to-date.
// A multi-month selected range is respected as-is.
const effFrom = useMemo(
() => (from.slice(0, 7) === to.slice(0, 7) ? isFyStartFor(to, fiscalYearStart || "01-01") : from),
[from, to, fiscalYearStart],
);
const { data: glLines = [], isLoading } = useQuery({
queryKey: ["income-statement-gl", companyId, effFrom, to],
enabled: !!companyId,
queryFn: () => fetchAllGLLines(
companyId, to,
"id,debit,credit,accounts!inner(id,name,code,type,category),journal_entries!inner(company_id,date)",
effFrom,
),
});
const periods = useMemo(() => isBuildPeriods(effFrom, to, gran), [effFrom, to, gran]);
const model = useMemo(() => {
const accts = new Map<string, ISAcct>();
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(effFrom)} ${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: "Profit & Loss", subtitle,
metaLines: [{ label: "Properties:", value: companyName }],
});
const head = [["Account", ...periods.map((p) => p.label), "Total"]];
const body: any[] = [];
const fillRows = new Set<number>(); // section headers (Income / Expense)
const boldRows = new Set<number>(); // 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<number, any> = { 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(`profit-loss-${gran}-${effFrom}-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 = `profit-loss-${gran}-${effFrom}-to-${to}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
const numCell = "px-3 py-1.5 text-right tabular-nums whitespace-nowrap";
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-wrap items-end gap-4 py-4">
<div>
<Label className="text-xs text-muted-foreground">By</Label>
<Select value={gran} onValueChange={(v) => setGran(v as ISGran)}>
<SelectTrigger className="w-36 mt-1 h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="month">Month</SelectItem>
<SelectItem value="quarter">Quarter</SelectItem>
<SelectItem value="year">Year</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-xs text-muted-foreground pb-1">{periods.length} period{periods.length !== 1 ? "s" : ""} · Accrual basis</div>
{hasRows && (
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={exportCsv}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</div>
)}
</CardContent>
</Card>
{isLoading ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading</CardContent></Card>
) : !hasRows ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No income or expense activity in this range.</CardContent></Card>
) : (
<ReportSheet title="Profit & Loss" subtitle={subtitle} companyName={companyName} logoUrl={logoUrl}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">Account</th>
{periods.map((p) => <th key={p.key} className="px-3 py-2 text-right font-semibold whitespace-nowrap">{p.label}</th>)}
<th className="px-3 py-2 text-right font-semibold">Total</th>
</tr>
</thead>
<tbody>
<ISSection title="Income" groups={model.incomeGroups} totalLabel="Total Income" total={model.incTot} periods={periods} currency={currency} numCell={numCell} />
<ISSection title="Expense" groups={model.expenseGroups} totalLabel="Total Expense" total={model.expTot} periods={periods} currency={currency} numCell={numCell} />
<tr className="border-t-2 border-primary font-bold">
<td className="px-3 py-2.5">Net Income</td>
{periods.map((p) => <td key={p.key} className={numCell}>{money(model.net.by.get(p.key) ?? 0, currency)}</td>)}
<td className={numCell}>{money(model.net.total, currency)}</td>
</tr>
</tbody>
</table>
</div>
</ReportSheet>
)}
</div>
);
}
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 (
<>
<tr className="bg-muted/40 font-semibold">
<td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
</tr>
{groups.map((g, gi) => (
<Fragment key={g.name ?? `__none-${gi}`}>
{g.name && (
<tr className="font-semibold text-[13px]">
<td className="px-3 pl-6 py-1.5" colSpan={periods.length + 2}>{g.name}</td>
</tr>
)}
{g.accts.map((a) => (
<tr key={a.id} className="border-b hover:bg-muted/20">
<td className={`px-3 py-1.5 ${g.name ? "pl-10" : "pl-6"}`}>
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
</td>
{periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
<td className={numCell + " font-medium"}>{isNum(a.total)}</td>
</tr>
))}
{g.name && (
<tr className="border-b font-medium text-muted-foreground">
<td className="px-3 pl-6 py-1.5">Total for {g.name}</td>
{periods.map((p) => <td key={p.key} className={numCell}>{money(g.by.get(p.key) ?? 0, currency)}</td>)}
<td className={numCell}>{money(g.total, currency)}</td>
</tr>
)}
</Fragment>
))}
<tr className="border-y-2 border-primary/40 font-semibold">
<td className="px-3 py-1.5">{totalLabel}</td>
{periods.map((p) => <td key={p.key} className={numCell}>{money(total.by.get(p.key) ?? 0, currency)}</td>)}
<td className={numCell}>{money(total.total, currency)}</td>
</tr>
</>
);
}
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 (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
<th className="py-2 text-left font-semibold">Account</th>
<th className="py-2 text-right font-semibold">Amount</th>
{showCompare && <th className="py-2 text-right font-semibold">Comparative</th>}
{showCompare && <th className="py-2 text-right font-semibold">Change</th>}
{showCompare && <th className="py-2 text-right font-semibold">Change %</th>}
</tr>
</thead>
<tbody>
{report.rows.map((r, i) => {
if (r.kind === "spacer") { alt = false; return <tr key={i}><td colSpan={span} className="h-3" /></tr>; }
if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) return null;
if (r.kind === "section") {
alt = false;
return (
<tr key={i} className="bg-[hsl(174_47%_94%)]">
<td colSpan={span} className="py-2 px-2 font-semibold text-[hsl(174_70%_25%)] text-sm">{r.label}</td>
</tr>
);
}
if (r.kind === "group") {
alt = false;
return (
<tr key={i}>
<td colSpan={span} className="pt-2.5 pb-1 pl-4 font-semibold text-sm">{r.label}</td>
</tr>
);
}
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 (
<tr key={i}
onClick={drillable ? () => 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(" ")}>
<td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
{showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>}
<span className={drillable ? "text-primary underline-offset-2 hover:underline" : ""}>{r.label}</span>
</td>
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
{showCompare && <AmountCell n={r.compare} />}
{showCompare && <AmountCell n={delta} bold={bold} />}
{showCompare && <td className={`py-1.5 px-2 text-right tabular-nums text-muted-foreground ${bold ? "font-semibold" : ""}`}>{r.amount !== undefined ? pctStr(r.amount, r.compare) : ""}</td>}
</tr>
);
})}
</tbody>
{report.balanced !== undefined && (
<tfoot>
<tr>
<td colSpan={span} className="pt-3">
<div className={`rounded-md px-4 py-2 text-sm font-semibold text-white ${report.balanced ? "bg-primary" : "bg-red-600"}`}>
{report.balanced ? "Balance Sheet is balanced ✓" : `Balance Sheet is OUT OF BALANCE by ${money(report.outOfBalanceAmount ?? 0, currency)} (Assets Liabilities Equity)`}
</div>
</td>
</tr>
</tfoot>
)}
{report.cashHighlight && (
<tfoot>
<tr><td colSpan={span} className="pt-3">
<div className="flex items-center justify-between rounded-md border border-primary bg-[hsl(174_47%_94%)] px-4 py-2.5 text-sm font-semibold text-[hsl(174_70%_25%)]">
<span>{report.cashHighlight.label}</span>
<span className={report.cashHighlight.amount < 0 ? "text-red-600" : ""}>{fmtAmount(report.cashHighlight.amount)}</span>
</div>
</td></tr>
</tfoot>
)}
</table>
);
}
function AmountCell({ n, bold, doubleUnderline }: { n?: number; bold?: boolean; doubleUnderline?: boolean }) {
if (n === undefined) return <td className="py-1.5 px-2" />;
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 <td className={cls}>{fmtAmount(n)}</td>;
}
function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare, showZero }: {
report: StructuredReport | null;
companyName: string; rangeLabel: string; showCodes: boolean; showCompare: boolean; showZero: boolean;
}) {
if (!report) return <div className="text-center text-muted-foreground py-12">No data.</div>;
return (
<div className="mx-auto bg-white shadow-md" style={{ width: 816, minHeight: 1056, fontFamily: "Georgia, 'Times New Roman', serif" }}>
<div className="px-12 pt-10 pb-6">
<div className="flex items-start gap-4">
<div className="h-11 w-11 rounded bg-primary flex items-center justify-center text-white font-bold text-lg">
{(companyName[0] ?? "?").toUpperCase()}
</div>
<div className="text-xl font-bold text-gray-900 mt-1">{companyName}</div>
</div>
<h1 className="text-2xl font-bold text-center mt-4">{report.title}</h1>
<div className="text-center text-sm text-gray-500 mt-1">{rangeLabel}</div>
<div className="mt-3 h-[1.5px] bg-primary" />
</div>
<div className="px-12 pb-12">
<StructuredTable report={report} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency="" />
</div>
<div className="px-12 pb-6">
<div className="h-[1px] bg-primary mb-3" />
<div className="text-center text-xs text-gray-500">Page 1 of 1</div>
<div className="text-center text-xs text-gray-500">Generated by {APP_NAME} on {new Date().toLocaleDateString()}</div>
</div>
</div>
);
}
// ---------- Financial report builders (structured) ----------
// Reconciliation matrix (§9) surfaced as visible residuals — never plug a residual.
// Shared so both the on-screen report and the PDF/CSV export run identical checks.
function buildReconChecks(d: any): RecCheck[] {
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 knownAcctIds = new Set(accounts.map((a) => a.id));
for (const l of (d.glCumulative ?? []) as any[]) {
const meta = l.accounts; const id = l.account_id;
if (!meta || !id || knownAcctIds.has(id)) continue;
knownAcctIds.add(id);
accounts.push({ id, type: meta.type, name: meta.name, is_cash: /cash|undeposited/i.test(String(meta.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);
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;
return reconcile({
accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill,
arApApplicable: d.glManaged,
reportPLNetIncome: plNI, sceEndingEquity: sceEnding, bsTotalEquity: bsEquity,
});
}
function ReconciliationReport({ d, currency, companyName, rangeLabel }: { d: any; currency: string; companyName?: string; rangeLabel?: string }) {
if (!d) return <div className="text-sm text-muted-foreground">Loading</div>;
const checks = buildReconChecks(d);
const ok = (r: number) => Math.abs(r) < 0.005;
const allPass = checks.every((c) => c.pass);
// Self-contained PDF (doesn't depend on the page-level exporter).
const downloadPdf = () => {
const doc = new jsPDF({ unit: "pt", format: "letter" });
doc.setFontSize(16); doc.setFont("helvetica", "bold");
doc.text("Reconciliation Checks", 40, 50);
doc.setFontSize(10); doc.setFont("helvetica", "normal"); doc.setTextColor(90);
if (companyName) doc.text(companyName, 40, 68);
if (rangeLabel) doc.text(rangeLabel, 40, 82);
doc.setTextColor(0);
autoTable(doc, {
startY: 96,
head: [["Check", "Assertion", "Residual", "Status"]],
body: checks.map((c) => [c.id, c.label, money(c.residual, currency), c.pass ? "Pass" : "FAIL"]),
styles: { font: "helvetica", fontSize: 9, cellPadding: 5 },
headStyles: { fillColor: [30, 41, 59], textColor: 255, fontStyle: "bold" },
columnStyles: { 0: { cellWidth: 50 }, 2: { halign: "right", cellWidth: 90 }, 3: { halign: "right", cellWidth: 60 } },
didParseCell: ({ section, row, column, cell }) => {
if (section === "body" && !checks[row.index]?.pass && (column.index === 2 || column.index === 3)) {
cell.styles.textColor = [220, 38, 38]; cell.styles.fontStyle = "bold";
}
},
margin: { left: 40, right: 40 },
});
doc.save(`reconciliation-checks-${(companyName || "company").replace(/[^a-z0-9]+/gi, "_")}.pdf`);
};
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base flex items-center gap-2">
Reconciliation Checks
<span className={`text-xs rounded px-2 py-0.5 ${allPass ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"}`}>
{allPass ? "All passing" : "Residuals present"}
</span>
</CardTitle>
<Button variant="outline" size="sm" onClick={downloadPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Check</TableHead>
<TableHead>Assertion</TableHead>
<TableHead className="text-right">Residual</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{checks.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-mono">{c.id}</TableCell>
<TableCell>{c.label}</TableCell>
<TableCell className={`text-right tabular-nums ${ok(c.residual) ? "" : "text-red-600 font-semibold"}`}>{money(c.residual, currency)}</TableCell>
<TableCell className="text-right">{ok(c.residual) ? "✓" : "✗"}</TableCell>
</TableRow>
))}
{/* R6 / R9 are not numeric residuals in this model — shown for matrix completeness. */}
<TableRow className="text-muted-foreground">
<TableCell className="font-mono">R6</TableCell>
<TableCell>GL closing balance = Trial Balance / Balance Sheet balance</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right" title="Trial Balance and Balance Sheet are both derived directly from the GL, so they agree by construction.">n/a</TableCell>
</TableRow>
<TableRow className="text-muted-foreground">
<TableCell className="font-mono">R9</TableCell>
<TableCell>Direct-method CFO = Indirect-method CFO</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right" title="Only the indirect-method cash flow is produced, so there is no second method to reconcile against.">n/a</TableCell>
</TableRow>
</TableBody>
</Table>
<p className="text-xs text-muted-foreground p-4">
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."}
</p>
</CardContent>
</Card>
);
}
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 creditdebit; expense accounts to debitcredit.
*/
function groupGLByAccount(lines: any[]) {
const income = new Map<string, GLAccountTotal>();
const expense = new Map<string, GLAccountTotal>();
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 <parent>" subtotal. Returns section totals.
*/
function pushPnLSection(
rows: StructuredRow[], label: string,
accMap: Map<string, GLAccountTotal>, prevMap: Map<string, GLAccountTotal>,
parentName: Map<string, string>, useCompare: boolean
): { total: number; prevTotal: number } {
rows.push({ kind: "section", label });
const ungrouped: [string, GLAccountTotal][] = [];
const groups = new Map<string, [string, GLAccountTotal][]>();
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<string, PnlClassification> = {
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<string, number>();
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;
// Surface accounts missing from the active COA list (i.e. archived) that still
// carry a balance, so their balance lands on the statement instead of being
// dropped (which would unbalance the Balance Sheet). Archived income/expense
// accounts already flow into Net Income via bsBalances; asset/liability/equity
// ones must appear as line items here. Zero-balance accounts are not added.
const knownIds = new Set(accounts.map((a) => a.id));
const extraAccounts: any[] = [];
const seenExtra = new Set<string>();
for (const l of (d.glCumulative ?? []) as any[]) {
const id = l.account_id; const meta = l.accounts;
if (!meta || !id || knownIds.has(id) || seenExtra.has(id)) continue;
seenExtra.add(id);
if (Math.abs(cur.glByAcct.get(id) ?? 0) > 0.005) extraAccounts.push({ id, name: meta.name, code: meta.code, type: meta.type });
}
const allAccounts = extraAccounts.length ? [...accounts, ...extraAccounts] : accounts;
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) => allAccounts.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);
// Account-number + parent-category labels (Buildium style:
// "3011 SIRS Reserves - 3066 Painting/Waterproofing"). Codes are baked into the
// label so they always show, and rows are sorted so siblings group by parent.
const acctMeta = new Map<string, { code: any; name: string }>(allAccounts.map((a: any) => [a.id, { code: a.code ?? null, name: a.name }]));
const codeName = (code: any, name: any) => `${code != null && String(code).trim() !== "" ? String(code) + " " : ""}${name ?? ""}`.trim();
const bsLabel = (a: any) => {
const p = a.parent_account_id ? acctMeta.get(a.parent_account_id) : null;
return p ? `${codeName(p.code, p.name)} - ${codeName(a.code, a.name)}` : codeName(a.code, a.name);
};
const bsKey = (a: any) => {
const p = a.parent_account_id ? acctMeta.get(a.parent_account_id) : null;
return `${p ? String(p.code ?? "") : String(a.code ?? "")}|${String(a.code ?? "")}`;
};
const bsSort = (arr: any[]) => [...arr].sort((x, y) => bsKey(x).localeCompare(bsKey(y), undefined, { numeric: true }));
const rows: StructuredRow[] = [];
// Assets
rows.push({ kind: "section", label: "Assets" });
const assets = byType("asset");
for (const a of bsSort(assets)) rows.push({ kind: "sub", label: bsLabel(a), 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 bsSort(liabs)) rows.push({ kind: "sub", label: bsLabel(a), 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.
// Fold ONLY the standard "Retained Earnings" / "Current Year Earnings" accounts
// into the calculated lines. Distinctly-named equity accounts (e.g. "Retained
// Earnings Savings", a reserve-equity account) must NOT be swallowed — they
// render as their own equity line. Match the full name, not a substring.
const reAccts = equityAccs.filter((a) => /^\s*retained\s+earnings(\s*\(.*\))?\s*$/i.test(String(a.name || "")));
const cyeAccts = equityAccs.filter((a) => /^\s*current\s*year\s*(earnings|income)(\s*\(.*\))?\s*$/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 bsSort(otherEquity)) rows.push({ kind: "sub", label: bsLabel(a), 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<string, any>((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<string, number>();
const endRaw = new Map<string, number>();
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<string>([...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, billed-date recognition (a bill's expense counts on the bill
// date — Dr Expense / Cr A/P — and a direct vendor payment on the payment
// date — Dr Expense / Cr Bank), then we back out the expense of bills issued
// in the period that aren't paid yet, so the summary reflects amounts the
// association has actually paid. Direct payments are cash already, so they
// stay; only the unpaid portion of open bills is removed.
const byAcct: Record<string, number> = {};
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;
}
// Remove the unpaid portion of period bills, prorated, keyed by account name.
const acctNameById = new Map((d.accounts ?? []).map((a: any) => [a.id, a.name]));
for (const bi of (d.periodBillItems ?? []) as any[]) {
const b = bi.bills;
if (!b || b.status === "void") continue;
const total = Number(b.total) || 0;
if (total <= 0) continue;
const unpaid = total - (Number(b.paid_amount) || 0);
if (unpaid <= 0) continue;
const name = acctNameById.get(bi.account_id);
if (!name || byAcct[name] === undefined) continue;
byAcct[name] -= Number(bi.amount) * (unpaid / total);
}
const kept = Object.entries(byAcct).filter(([, amt]) => Math.abs(amt) >= 0.005);
const rows = kept.sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
const total = kept.reduce((s, [, v]) => s + v, 0);
return { title: "Expense Summary (Paid)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
}
case "vendor-balances": {
const byVendor: Record<string, number> = {};
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<string, any[]>();
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) {
// Actuals come straight from the General Ledger so Budget vs. Actuals ties to
// the Income Statement and works for GL-import / import-mode associations whose
// activity lives only in journal entries. Sourcing from the operational tables
// (bills + payment transactions + a budget-weighted invoice "plug") both
// double-counted (a bill counted as its line item AND its payment, plus any
// redundant imported bills) and diverged from the posted books.
const lines = await fetchAllGLLines(
companyId,
t,
"debit,credit,account_id,accounts!inner(id,type),journal_entries!inner(company_id,date)",
f,
);
return { lines };
}
// Actuals per account, netted from the GL: income = credit debit,
// expense = debit credit (the same convention as every other report here).
function computeBvaActuals(actualsData: any): Record<string, number> {
const m: Record<string, number> = {};
for (const l of (actualsData?.lines ?? []) as any[]) {
const acctId = l.account_id ?? l.accounts?.id;
if (!acctId) continue;
const type = l.accounts?.type;
if (type !== "income" && type !== "expense") continue;
const debit = Number(l.debit || 0);
const credit = Number(l.credit || 0);
m[acctId] = (m[acctId] ?? 0) + (type === "income" ? credit - debit : debit - credit);
}
return m;
}
// Budget vs. Actuals column shading — a light→dark grey gradient with left
// vertical borders so the Budget / Actual / Variance / Variance % columns are
// easy to tell apart. Applied to the header, group-total and detail cells.
// Page view stays plain; the grey gradient lives on the PDF export only.
const BVA_COL = {
budget: "text-right tabular-nums",
actual: "text-right tabular-nums",
variance: "text-right tabular-nums",
pct: "text-right tabular-nums",
cmp: "text-right tabular-nums",
};
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<string>("");
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<string, any[]> = { 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<string, any[]>), [grouped]);
const selectedBudget = useMemo(() => (budgets as any[]).find((b) => b.id === budgetId), [budgets, budgetId]);
// Budget for the selected actuals window: include each budget period's FULL
// amount, exactly as entered, for any period the window touches — no proration.
// period_index maps to months (monthly), quarters (quarterly), or the whole
// year (annual). E.g. Jan 1 Jun 16 = full Jan…Jun monthly budgets.
const budgetByAcct = useMemo(() => {
const m: Record<string, number> = {};
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 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);
if (s > toT || en < fromT) continue; // period not touched by the window
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
}
return m;
}, [entries, selectedBudget, actFrom, actTo]);
const actualByAcct = useMemo(() => computeBvaActuals(actualsData), [actualsData]);
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData), [cmpActualsData]);
// Comparison-window budget (full per-period amounts like budgetByAcct, over
// [cmpFrom, cmpTo]). No proration.
const cmpBudgetByAcct = useMemo(() => {
const m: Record<string, number> = {};
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 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);
if (s > toT || en < fromT) continue; // period not touched by the window
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
}
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<string, number> = {};
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 (
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
No active budgets yet. <Link to="/dashboard/accounting/budgets" className="text-primary underline">Create one</Link> to compare against actuals.
</CardContent></Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-wrap items-center gap-3 py-4">
<Label className="text-sm">Budget:</Label>
<Select value={budgetId} onValueChange={setBudgetId}>
<SelectTrigger className="h-9 w-[280px]"><SelectValue /></SelectTrigger>
<SelectContent>
{budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Label className="text-sm">Actuals from</Label>
<Input type="date" value={actFrom} onChange={(e) => setActFrom(e.target.value)} className="h-9 w-40" />
<Label className="text-sm">to</Label>
<Input type="date" value={actTo} onChange={(e) => setActTo(e.target.value)} className="h-9 w-40" />
</div>
<div className="flex items-center gap-2">
<Label className="text-sm flex items-center gap-1">
<input type="checkbox" checked={cmpOn} onChange={(e) => setCmpOn(e.target.checked)} /> Compare to
</Label>
{cmpOn && (<>
<Input type="date" value={cmpFrom} onChange={(e) => setCmpFrom(e.target.value)} className="h-9 w-40" />
<Label className="text-sm">to</Label>
<Input type="date" value={cmpTo} onChange={(e) => setCmpTo(e.target.value)} className="h-9 w-40" />
</>)}
</div>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</div>
<div className="w-full text-xs text-muted-foreground">
Budget shows the full {String((selectedBudget as any)?.period_type ?? "annual")} amounts for every period the selected range touches exactly as entered, no proration.
</div>
</CardContent>
</Card>
<ReportSheet title="Budget vs. Actuals" subtitle="Accrual basis" companyName={companyName} period={actualsLabel} logoUrl={logoUrl}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead className={BVA_COL.budget}>Budget</TableHead>
<TableHead className={BVA_COL.actual}>Actual</TableHead>
<TableHead className={BVA_COL.variance}>Variance</TableHead>
<TableHead className={BVA_COL.pct}>Variance %</TableHead>
{cmpOn && <TableHead className={BVA_COL.cmp}>Compare</TableHead>}
{cmpOn && <TableHead className={BVA_COL.cmp}>Δ vs Compare</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{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 (
<Fragment key={t.value}>
<TableRow className="bg-muted/60 font-semibold">
<TableCell>{t.label}</TableCell>
<TableCell className={BVA_COL.budget}>{money(totalB, currency)}</TableCell>
<TableCell className={BVA_COL.actual}>{money(totalA, currency)}</TableCell>
<TableCell className={`${BVA_COL.variance} ${totalFavorable ? "text-emerald-700" : "text-red-700"}`}>{money(totalVar, currency)}</TableCell>
<TableCell className={`${BVA_COL.pct} ${totalFavorable ? "text-emerald-700" : "text-red-700"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(totalC, currency)}</TableCell>}
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(totalA - totalC, currency)}</TableCell>}
</TableRow>
{(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 (
<TableRow key={a.id} className="hover:bg-muted/20">
<TableCell style={{ paddingLeft: `${16 + depth * 20}px` }}>
<span className={depth === 0 ? "font-semibold" : "font-medium"}>{a.name}</span>
{a.code && <span className="text-xs text-muted-foreground font-mono ml-2">{a.code}</span>}
</TableCell>
<TableCell className={BVA_COL.budget}>{money(b, currency)}</TableCell>
<TableCell className={BVA_COL.actual}>{money(ac, currency)}</TableCell>
<TableCell className={`${BVA_COL.variance} ${fav ? "text-emerald-700" : "text-red-700"}`}>{money(v, currency)}</TableCell>
<TableCell className={`${BVA_COL.pct} ${fav ? "text-emerald-700" : "text-red-700"}`}>{b ? `${pct.toFixed(1)}%` : "—"}</TableCell>
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(c, currency)}</TableCell>}
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(ac - c, currency)}</TableCell>}
</TableRow>
);
})}
</Fragment>
);
})}
</TableBody>
</Table>
</ReportSheet>
</div>
);
}
// ── 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<string, APRow>();
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 (
<Table>
<TableHeader>
<TableRow>
<TableHead>Vendor</TableHead>
<TableHead className="text-right">Current</TableHead>
<TableHead className="text-right">130 days</TableHead>
<TableHead className="text-right">3160 days</TableHead>
<TableHead className="text-right">6190 days</TableHead>
<TableHead className="text-right">90+ days</TableHead>
<TableHead className="text-right font-semibold">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<TableRow key={r.id} className="hover:bg-muted/40">
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="text-right tabular-nums">{money(r.current, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-amber-600">{money(r.d30, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-orange-600">{money(r.d60, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-red-500">{money(r.d90, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-red-700 font-medium">{money(r.d90p, currency)}</TableCell>
<TableCell className="text-right tabular-nums font-semibold">{money(r.total, currency)}</TableCell>
</TableRow>
))}
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding payables.</TableCell></TableRow>}
{list.length > 0 && (
<TableRow className="font-semibold bg-muted/30">
<TableCell>Total</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.current, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d30, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d60, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d90, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d90p, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.total, currency)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
// ── Homeowner Balance Summary ─────────────────────────────────────────────────
function HomeownerSummaryTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
const byCustomer = useMemo(() => {
const m = new Map<string, { open: number; count: number; lastPaid: string | null }>();
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 (
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead>Property</TableHead>
<TableHead className="text-right">Open Invoices</TableHead>
<TableHead className="text-right">Outstanding Balance</TableHead>
<TableHead className="text-right">Last Payment</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r: any) => (
<TableRow key={r.id} className="hover:bg-muted/40">
<TableCell>
<Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.property_address ?? "—"}{r.lot_number ? ` · Lot ${r.lot_number}` : ""}</TableCell>
<TableCell className="text-right tabular-nums">{r.count || "—"}</TableCell>
<TableCell className={`text-right tabular-nums font-medium ${r.open > 0 ? "text-red-600" : ""}`}>{money(r.open, currency)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">{r.lastPaid ? fmtDate(r.lastPaid) : "—"}</TableCell>
</TableRow>
))}
{rows.length === 0 && <TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No homeowners.</TableCell></TableRow>}
<TableRow className="font-semibold bg-muted/30">
<TableCell colSpan={3}>Total ({rows.length} homeowners)</TableCell>
<TableCell className="text-right tabular-nums">{money(totalOpen, currency)}</TableCell>
<TableCell />
</TableRow>
</TableBody>
</Table>
);
}
// ── 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<string, DelRow>();
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: "130 days", cls: "bg-amber-100 text-amber-800" };
if (days <= 60) return { label: "3160 days", cls: "bg-orange-100 text-orange-800" };
if (days <= 90) return { label: "6190 days", cls: "bg-red-100 text-red-800" };
return { label: "90+ days", cls: "bg-red-200 text-red-900 font-semibold" };
};
return (
<div className="space-y-3">
{totalOverdue > 0 && (
<div className="rounded-md bg-red-50 border border-red-200 px-4 py-2 text-sm flex items-center justify-between">
<span className="font-medium text-red-800">{list.length} homeowners with overdue balances</span>
<span className="font-bold text-red-900">{money(totalOverdue, currency)} total overdue</span>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead>Property</TableHead>
<TableHead>Contact</TableHead>
<TableHead className="text-right">Invoices</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Amount Overdue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => {
const { label, cls } = ageBucket(r.oldest);
return (
<TableRow key={r.id} className="hover:bg-muted/40">
<TableCell>
<Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.property ?? "—"}</TableCell>
<TableCell className="text-sm">
{r.email && <div><a href={`mailto:${r.email}`} className="text-primary hover:underline">{r.email}</a></div>}
{r.phone && <div className="text-muted-foreground">{r.phone}</div>}
</TableCell>
<TableCell className="text-right tabular-nums">{r.invoiceCount}</TableCell>
<TableCell><span className={`rounded-full px-2 py-0.5 text-xs ${cls}`}>{label}</span></TableCell>
<TableCell className="text-right tabular-nums font-semibold text-red-700">{money(r.overdue, currency)}</TableCell>
</TableRow>
);
})}
{list.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No overdue accounts. All homeowners are current.</TableCell></TableRow>}
{list.length > 0 && (
<TableRow className="font-semibold bg-muted/30">
<TableCell colSpan={5}>Total overdue</TableCell>
<TableCell className="text-right tabular-nums text-red-700">{money(totalOverdue, currency)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
// ── 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<string, AgingRow>();
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 (
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead className="text-right">Current</TableHead>
<TableHead className="text-right">130 days</TableHead>
<TableHead className="text-right">3160 days</TableHead>
<TableHead className="text-right">6190 days</TableHead>
<TableHead className="text-right">90+ days</TableHead>
<TableHead className="text-right font-semibold">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/40">
<TableCell>
<Link to={`/dashboard/accounting/customers/${r.id}`} className="text-primary hover:underline">{r.name}</Link>
</TableCell>
<TableCell className="text-right">{money(r.current, currency)}</TableCell>
<TableCell className="text-right">{money(r.d30, currency)}</TableCell>
<TableCell className="text-right">{money(r.d60, currency)}</TableCell>
<TableCell className="text-right">{money(r.d90, currency)}</TableCell>
<TableCell className="text-right">{money(r.d90p, currency)}</TableCell>
<TableCell className="text-right font-medium">{money(r.total, currency)}</TableCell>
</TableRow>
))}
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding receivables.</TableCell></TableRow>}
{list.length > 0 && (
<TableRow className="font-semibold bg-muted/30">
<TableCell>Total</TableCell>
<TableCell className="text-right">{money(totals.current, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d30, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d60, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d90, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d90p, currency)}</TableCell>
<TableCell className="text-right">{money(totals.total, currency)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}