Accounting reports: AR Aging (Property), Pre-Paid Homeowners, Cash Disbursement

Buildium-style reports built on the owner ledger and GL:
- AR Aging (Property): FIFO-aged buckets (0-30/over 30/60/90) per unit with
  charge-type breakdown, collection status, summary + distribution bar
- Pre-Paid Homeowners: units with net credit balances as of a date
- Cash Disbursement: bank-credit GL entries grouped by bank account with
  check#/vendor/invoice enrichment from the banking register and GL line detail
All with branded PDF/CSV exports; shared owner-ledger helpers in lib/ownerLedger.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:26:30 -04:00
parent 4c7fe7840b
commit e510a76dfc
5 changed files with 1092 additions and 4 deletions
+20 -4
View File
@@ -31,6 +31,9 @@ import { Lock } from "lucide-react";
import { TrialBalanceReport } from "./components/TrialBalanceReport"; import { TrialBalanceReport } from "./components/TrialBalanceReport";
import { GeneralLedgerReport } from "./components/GeneralLedgerReport"; import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
import { ReserveFundReport } from "./components/ReserveFundReport"; 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 { ReportSheet } from "./components/ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader"; import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf"; import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
@@ -38,8 +41,8 @@ import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
type ReportId = type ReportId =
| "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals" | "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
| "trial-balance" | "general-ledger" | "trial-balance" | "general-ledger"
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency" | "invoice-summary" | "customer-balances" | "ar-aging" | "ar-aging-property" | "prepaid-homeowners" | "homeowner-summary" | "delinquency"
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation" | "expense-summary" | "vendor-balances" | "ap-aging" | "cash-disbursement" | "reconciliation"
| "reserve-fund"; | "reserve-fund";
const APP_NAME = "Cozy Books"; const APP_NAME = "Cozy Books";
@@ -57,6 +60,8 @@ const GROUPS = [
{ id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" }, { id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" },
]}, ]},
{ name: "Receivables", reports: [ { 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: "ar-aging" as ReportId, name: "AR Aging Details" },
{ id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" }, { id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" },
{ id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" }, { id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" },
@@ -64,6 +69,7 @@ const GROUPS = [
{ id: "delinquency" as ReportId, name: "Delinquency Report" }, { id: "delinquency" as ReportId, name: "Delinquency Report" },
]}, ]},
{ name: "Payables", reports: [ { name: "Payables", reports: [
{ id: "cash-disbursement" as ReportId, name: "Cash Disbursement" },
{ id: "ap-aging" as ReportId, name: "AP Aging Details" }, { id: "ap-aging" as ReportId, name: "AP Aging Details" },
{ id: "expense-summary" as ReportId, name: "Expense Summary" }, { id: "expense-summary" as ReportId, name: "Expense Summary" },
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" }, { id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
@@ -396,7 +402,8 @@ export default function AccountingReportsPage({ association }: { association?: {
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]); }, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
// Reports whose export is handled internally (own PDF/CSV buttons inside the component) // Reports whose export is handled internally (own PDF/CSV buttons inside the component)
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals" || active === "income-statement"; const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals" || active === "income-statement"
|| active === "ar-aging-property" || active === "prepaid-homeowners" || active === "cash-disbursement";
const anyExportable = !!(structured || flat || exportFlat); const anyExportable = !!(structured || flat || exportFlat);
const doExportPDF = async () => { const doExportPDF = async () => {
@@ -588,6 +595,15 @@ export default function AccountingReportsPage({ association }: { association?: {
{active === "reserve-fund" && ( {active === "reserve-fund" && (
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} /> <ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
)} )}
{active === "ar-aging-property" && (
<ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)}
{active === "prepaid-homeowners" && (
<PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)}
{active === "cash-disbursement" && (
<CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)}
{active === "reconciliation" && ( {active === "reconciliation" && (
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}> <ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<ReconciliationReport d={data} currency={cur} /> <ReconciliationReport d={data} currency={cur} />
@@ -604,7 +620,7 @@ export default function AccountingReportsPage({ association }: { association?: {
<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> <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" && ( {!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}> <ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
{!data ? ( {!data ? (
<div className="text-sm text-muted-foreground">Loading</div> <div className="text-sm text-muted-foreground">Loading</div>
@@ -0,0 +1,432 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { FileDown, Download } from "lucide-react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { ReportSheet } from "./ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
import {
fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners,
unitLabel, chargeTypeLabel, money,
type OwnerLedgerEntry, type UnitInfo, type OwnerInfo,
} from "../lib/ownerLedger";
const TEAL: [number, number, number] = [0, 137, 123];
const BUCKETS = ["0-30", "Over 30", "Over 60", "Over 90"] as const;
const BUCKET_COLORS: [number, number, number][] = [
[141, 178, 85], // green (0-30)
[234, 179, 8], // amber (over 30)
[234, 124, 8], // orange (over 60)
[220, 68, 68], // red (over 90)
];
type Buckets = [number, number, number, number];
type UnitAging = {
key: string;
label: string;
collStatus: string | null;
buckets: Buckets;
total: number;
byType: Map<string, { buckets: Buckets; total: number }>;
};
function emptyBuckets(): Buckets { return [0, 0, 0, 0]; }
function bucketIndex(asOf: string, chargeDate: string): number {
const days = Math.floor((new Date(asOf + "T00:00:00").getTime() - new Date(chargeDate + "T00:00:00").getTime()) / 86400000);
if (days <= 30) return 0;
if (days <= 60) return 1;
if (days <= 90) return 2;
return 3;
}
const prettyStatus = (s: string | null | undefined) =>
s ? s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : null;
const dash = (n: number) => (n ? money(n) : "-");
/**
* Buildium-style AR Aging: per-property open charge balances aged into
* 0-30 / Over 30 / Over 60 / Over 90 buckets, with charge-type breakdown,
* collection status, summary and distribution. Payments and credits apply to
* charges oldest-first (FIFO), so only genuinely open charge amounts age.
*/
export function ARAgingPropertyReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
const { data, isLoading } = useQuery({
queryKey: ["ar-aging-property", companyId, asOf],
enabled: !!companyId,
queryFn: async () => {
const associationId = await fetchAssociationId(companyId);
if (!associationId) return null;
const [entries, { units, owners }, collectionsRes] = await Promise.all([
fetchOwnerLedger(associationId, asOf),
fetchUnitsAndOwners(associationId),
supabase.from("collections").select("unit_id, owner_id, status, updated_at").eq("association_id", associationId).order("updated_at", { ascending: false }),
]);
return { entries, units, owners, collections: (collectionsRes.data ?? []) as any[] };
},
});
const report = useMemo(() => {
if (!data) return null;
const { entries, units, owners, collections } = data;
const unitById = new Map<string, UnitInfo>();
for (const u of units) unitById.set(u.id, u);
const ownerById = new Map<string, OwnerInfo>();
for (const o of owners) ownerById.set(o.id, o);
const ownerByUnit = new Map<string, OwnerInfo>();
for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
// Latest collection status per unit (rows came back newest-first)
const collByUnit = new Map<string, string>();
const collByOwner = new Map<string, string>();
for (const c of collections) {
if (c.unit_id && !collByUnit.has(c.unit_id)) collByUnit.set(c.unit_id, c.status);
if (c.owner_id && !collByOwner.has(c.owner_id)) collByOwner.set(c.owner_id, c.status);
}
// Group ledger entries per unit (fall back to owner when the entry has no unit)
const byUnit = new Map<string, OwnerLedgerEntry[]>();
for (const e of entries) {
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null;
if (!key) continue;
const list = byUnit.get(key) ?? [];
list.push(e);
byUnit.set(key, list);
}
const rows: UnitAging[] = [];
for (const [key, list] of byUnit) {
list.sort((a, b) => a.date.localeCompare(b.date));
// FIFO: total credits pay down the oldest charges first
let creditPool = list.reduce((s, e) => s + e.credit, 0);
const buckets = emptyBuckets();
const byType = new Map<string, { buckets: Buckets; total: number }>();
let total = 0;
for (const e of list) {
if (e.debit <= 0) continue;
let open = e.debit;
if (creditPool > 0) {
const applied = Math.min(creditPool, open);
creditPool -= applied;
open -= applied;
}
if (open <= 0.004) continue;
const bi = bucketIndex(asOf, e.date);
buckets[bi] += open;
total += open;
const label = chargeTypeLabel(e.transaction_type);
const t = byType.get(label) ?? { buckets: emptyBuckets(), total: 0 };
t.buckets[bi] += open;
t.total += open;
byType.set(label, t);
}
if (total <= 0.004) continue;
const unitId = key.startsWith("u:") ? key.slice(2) : null;
const ownerId = key.startsWith("o:") ? key.slice(2) : null;
const unit = unitId ? unitById.get(unitId) : undefined;
const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null;
const collStatus = prettyStatus((unitId && collByUnit.get(unitId)) || (owner && collByOwner.get(owner.id)) || null);
rows.push({
key,
label: unitLabel(unit, owner?.last_name ?? null),
collStatus,
buckets,
total,
byType,
});
}
rows.sort((a, b) => a.label.localeCompare(b.label));
// Charge-type summary across all properties
const summary = new Map<string, { count: number; balance: number }>();
for (const r of rows) {
for (const [label, t] of r.byType) {
const s = summary.get(label) ?? { count: 0, balance: 0 };
s.count += 1;
s.balance += t.total;
summary.set(label, s);
}
}
const summaryRows = [...summary.entries()].sort((a, b) => b[1].balance - a[1].balance);
const totals = rows.reduce<Buckets>((t, r) => [t[0] + r.buckets[0], t[1] + r.buckets[1], t[2] + r.buckets[2], t[3] + r.buckets[3]], emptyBuckets());
const counts = rows.reduce<[number, number, number, number]>(
(t, r) => [t[0] + (r.buckets[0] > 0.004 ? 1 : 0), t[1] + (r.buckets[1] > 0.004 ? 1 : 0), t[2] + (r.buckets[2] > 0.004 ? 1 : 0), t[3] + (r.buckets[3] > 0.004 ? 1 : 0)],
[0, 0, 0, 0],
);
const grandTotal = totals.reduce((s, n) => s + n, 0);
const distribution = totals.map((n) => (grandTotal > 0 ? (n / grandTotal) * 100 : 0));
return { rows, summaryRows, totals, counts, grandTotal, distribution };
}, [data, asOf]);
const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" });
const exportPDF = async () => {
if (!report) return;
const doc = new jsPDF({ unit: "pt", format: "letter" });
const ML = 40;
const pageW = doc.internal.pageSize.getWidth();
const logo = await loadBrandedLogo(logoUrl);
let y = drawBrandedHeader(doc, {
logo, title: "AR Aging", subtitle: `As of ${asOfLabel}`,
metaLines: [{ label: "Properties:", value: companyName || "" }],
});
// Summary (left) — charge types
autoTable(doc, {
startY: y,
head: [["Charge", "Balance"]],
body: [
...report.summaryRows.map(([label, s]) => [`${label} (${s.count})`, money(s.balance)]),
[{ content: "Total", styles: { fontStyle: "bold" } } as any, { content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } } as any],
],
styles: { fontSize: 8, cellPadding: 4 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 1: { halign: "right" } },
margin: { left: ML, right: pageW / 2 + 20 },
tableWidth: pageW / 2 - ML - 30,
});
const summaryEndY = (doc as any).lastAutoTable.finalY;
// Distribution (right) — stacked bucket bar with % legend
const barX = pageW / 2 + 20;
const barW = pageW - ML - barX;
let dy = y + 4;
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(100);
doc.text("DISTRIBUTION", barX, dy);
dy += 8;
if (report.grandTotal > 0) {
let x = barX;
report.distribution.forEach((pct, i) => {
const w = (pct / 100) * barW;
if (w <= 0) return;
const c = BUCKET_COLORS[i];
doc.setFillColor(c[0], c[1], c[2]);
doc.rect(x, dy, w, 14, "F");
x += w;
});
dy += 24;
doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); doc.setTextColor(60);
report.distribution.forEach((pct, i) => {
if (pct <= 0) return;
const c = BUCKET_COLORS[i];
doc.setFillColor(c[0], c[1], c[2]);
doc.rect(barX, dy - 6, 7, 7, "F");
doc.text(`${BUCKETS[i]}: ${pct.toFixed(2)} %`, barX + 11, dy);
dy += 12;
});
}
y = Math.max(summaryEndY, dy) + 16;
// Property detail
const body: any[] = [];
for (const r of report.rows) {
body.push([
{ content: r.label + (r.collStatus ? `\nColl Status: ${r.collStatus}` : ""), styles: { fontStyle: "bold" } },
{ content: dash(r.buckets[0]), styles: { fontStyle: "bold", halign: "right" } },
{ content: dash(r.buckets[1]), styles: { fontStyle: "bold", halign: "right" } },
{ content: dash(r.buckets[2]), styles: { fontStyle: "bold", halign: "right" } },
{ content: dash(r.buckets[3]), styles: { fontStyle: "bold", halign: "right" } },
{ content: money(r.total), styles: { fontStyle: "bold", halign: "right" } },
]);
for (const [label, t] of r.byType) {
body.push([
{ content: ` ${label}`, styles: {} },
{ content: dash(t.buckets[0]), styles: { halign: "right" } },
{ content: dash(t.buckets[1]), styles: { halign: "right" } },
{ content: dash(t.buckets[2]), styles: { halign: "right" } },
{ content: dash(t.buckets[3]), styles: { halign: "right" } },
{ content: money(t.total), styles: { halign: "right" } },
]);
}
}
body.push([
{ content: "Total:", styles: { fontStyle: "bold" } },
...report.totals.map((n) => ({ content: money(n), styles: { fontStyle: "bold", halign: "right" } })),
{ content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } },
]);
body.push([
{ content: "Property Count:", styles: { fontStyle: "bold" } },
...report.counts.map((n) => ({ content: String(n), styles: { fontStyle: "bold", halign: "right" } })),
{ content: "", styles: {} },
]);
autoTable(doc, {
startY: y,
head: [["Property", "0-30", "Over 30", "Over 60", "Over 90", "Balance"]],
body,
styles: { fontSize: 8, cellPadding: 3 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 220 }, 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" }, 5: { halign: "right" } },
margin: { left: ML, right: ML },
});
drawBrandedFooter(doc);
doc.save(`ar-aging-${asOf}.pdf`);
};
const exportCSV = () => {
if (!report) return;
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
const lines = [["Property", "Coll Status", "Charge Type", "0-30", "Over 30", "Over 60", "Over 90", "Balance"].join(",")];
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
for (const r of report.rows) {
lines.push([q(r.label), q(r.collStatus ?? ""), "", f(r.buckets[0]), f(r.buckets[1]), f(r.buckets[2]), f(r.buckets[3]), f(r.total)].join(","));
for (const [label, t] of r.byType) {
lines.push([q(r.label), "", q(label), f(t.buckets[0]), f(t.buckets[1]), f(t.buckets[2]), f(t.buckets[3]), f(t.total)].join(","));
}
}
lines.push(["TOTAL", "", "", ...report.totals.map(f), f(report.grandTotal)].join(","));
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `ar-aging-${asOf}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<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">As of</Label>
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
</div>
{report && report.rows.length > 0 && (
<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>
) : !report || report.rows.length === 0 ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
No open balances as of {asOfLabel}. 🎉
</CardContent></Card>
) : (
<ReportSheet title="AR Aging" subtitle={`As of ${asOfLabel}`} companyName={companyName} logoUrl={logoUrl}>
{/* Summary + Distribution */}
<div className="grid gap-6 sm:grid-cols-2 mb-6">
<div>
<p className="text-[11px] uppercase tracking-wide font-semibold text-muted-foreground mb-2 text-center">Summary</p>
<table className="w-full text-sm">
<thead>
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-1.5 text-left font-semibold">Charge</th>
<th className="px-3 py-1.5 text-right font-semibold">Balance</th>
</tr>
</thead>
<tbody>
{report.summaryRows.map(([label, s]) => (
<tr key={label} className="border-b">
<td className="px-3 py-1.5">{label} ({s.count})</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(s.balance)}</td>
</tr>
))}
<tr className="font-bold border-b-2">
<td className="px-3 py-1.5 text-right">Total</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(report.grandTotal)}</td>
</tr>
</tbody>
</table>
</div>
<div>
<p className="text-[11px] uppercase tracking-wide font-semibold text-muted-foreground mb-2 text-center">Distribution</p>
<div className="flex h-5 w-full overflow-hidden rounded">
{report.distribution.map((pct, i) => pct > 0 && (
<div
key={i}
style={{ width: `${pct}%`, backgroundColor: `rgb(${BUCKET_COLORS[i].join(",")})` }}
title={`${BUCKETS[i]}: ${pct.toFixed(2)}%`}
/>
))}
</div>
<div className="mt-3 space-y-1">
{report.distribution.map((pct, i) => pct > 0 && (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: `rgb(${BUCKET_COLORS[i].join(",")})` }} />
{BUCKETS[i]}: {pct.toFixed(2)} %
</div>
))}
</div>
</div>
</div>
{/* Property detail */}
<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">Property</th>
<th className="px-3 py-2 text-right font-semibold">0-30</th>
<th className="px-3 py-2 text-right font-semibold">Over 30</th>
<th className="px-3 py-2 text-right font-semibold">Over 60</th>
<th className="px-3 py-2 text-right font-semibold">Over 90</th>
<th className="px-3 py-2 text-right font-semibold">Balance</th>
</tr>
</thead>
<tbody>
{report.rows.map((r) => (
<>
<tr key={r.key} className="border-b bg-muted/30">
<td className="px-3 py-1.5 font-semibold">
{r.label}
{r.collStatus && <div className="text-[11px] font-medium text-amber-700">Coll Status: {r.collStatus}</div>}
</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[0])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[1])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[2])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[3])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{money(r.total)}</td>
</tr>
{[...r.byType.entries()].map(([label, t]) => (
<tr key={`${r.key}-${label}`} className="border-b">
<td className="px-3 py-1 pl-8 text-muted-foreground">{label}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[0])}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[1])}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[2])}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[3])}</td>
<td className="px-3 py-1 text-right tabular-nums">{money(t.total)}</td>
</tr>
))}
</>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-b font-bold">
<td className="px-3 py-2">Total:</td>
{report.totals.map((n, i) => <td key={i} className="px-3 py-2 text-right tabular-nums">{money(n)}</td>)}
<td className="px-3 py-2 text-right tabular-nums">{money(report.grandTotal)}</td>
</tr>
<tr className="border-b-2 font-semibold text-muted-foreground">
<td className="px-3 py-2">Property Count:</td>
{report.counts.map((n, i) => <td key={i} className="px-3 py-2 text-right tabular-nums">{n}</td>)}
<td />
</tr>
</tfoot>
</table>
</div>
</ReportSheet>
)}
</div>
);
}
@@ -0,0 +1,334 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accounting } from "@/lib/accountingClient";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { FileDown, Download } from "lucide-react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { ReportSheet } from "./ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
import { num } from "../lib/ownerLedger";
import { fmtDate } from "../lib/format";
const TEAL: [number, number, number] = [0, 137, 123];
type GLAccount = { id: string; code: string | null; name: string; is_bank: boolean };
type GLLineRow = {
code: string | null;
name: string;
description: string | null;
amount: number;
};
type Disbursement = {
jeId: string;
date: string;
checkNo: string;
description: string;
invoiceDate: string | null;
amount: number;
lines: GLLineRow[];
};
type BankGroup = { bankLabel: string; entries: Disbursement[]; subtotal: number };
function monthStart() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; }
function today() { return new Date().toISOString().slice(0, 10); }
const acctLabel = (a: GLAccount | undefined) => (a ? `${a.code ? a.code + " - " : ""}${a.name}` : "Unknown account");
/**
* Buildium-style Cash Disbursement: every payment out of a bank account in the
* period, grouped by bank account, with the GL expense breakdown under each.
* Built from the GL (bank-account credit lines), so it works for both
* platform-managed and Buildium-imported companies. Platform entries are
* enriched with check #, vendor and bill info from the banking register.
*/
export function CashDisbursementReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
const [from, setFrom] = useState(monthStart());
const [to, setTo] = useState(today());
const { data, isLoading } = useQuery({
queryKey: ["cash-disbursement", companyId, from, to],
enabled: !!companyId,
queryFn: async () => {
const [accountsRes, linesRes] = await Promise.all([
accounting.from("accounts").select("id,code,name,is_bank").eq("company_id", companyId),
accounting
.from("journal_entry_lines")
.select("id,debit,credit,description,account_id,journal_entries!inner(id,company_id,date,description,reference,external_source,external_id)")
.eq("journal_entries.company_id", companyId)
.gte("journal_entries.date", from)
.lte("journal_entries.date", to)
.limit(50000),
]);
const accounts = (accountsRes.data ?? []) as GLAccount[];
const lines = (linesRes.data ?? []) as any[];
// Enrich register-posted entries with check # / vendor / bill from banking
const txnIds = [...new Set(lines
.filter((l) => l.journal_entries?.external_source === "acmacc_txn" && l.journal_entries?.external_id)
.map((l) => String(l.journal_entries.external_id)))];
let txns: any[] = [];
if (txnIds.length > 0) {
const { data: t } = await accounting
.from("transactions")
.select("id,reference,description,vendor_id,bill_id,vendors(name),bills(number,issue_date,vendors(name))")
.in("id", txnIds);
txns = t ?? [];
}
return { accounts, lines, txns };
},
});
const report = useMemo(() => {
if (!data) return null;
const acctById = new Map<string, GLAccount>();
for (const a of data.accounts) acctById.set(a.id, a);
const txnById = new Map<string, any>();
for (const t of data.txns) txnById.set(String(t.id), t);
// Group lines per journal entry
const byJe = new Map<string, { je: any; lines: any[] }>();
for (const l of data.lines) {
const je = l.journal_entries;
if (!je?.id) continue;
const g = byJe.get(je.id) ?? { je, lines: [] };
g.lines.push(l);
byJe.set(je.id, g);
}
const groups = new Map<string, BankGroup>();
let grandTotal = 0;
for (const { je, lines } of byJe.values()) {
if (je.external_source === "acmacc_xfer") continue; // bank-to-bank transfers aren't disbursements
const bankCredits = lines.filter((l) => Number(l.credit || 0) > 0 && acctById.get(l.account_id)?.is_bank);
if (bankCredits.length === 0) continue;
const nonBankDebits = lines.filter((l) => Number(l.debit || 0) > 0 && !acctById.get(l.account_id)?.is_bank);
if (nonBankDebits.length === 0) continue; // pure transfer between banks
const amount = bankCredits.reduce((s, l) => s + Number(l.credit || 0), 0);
// Attribute the disbursement to the (largest) credited bank account
const mainBank = bankCredits.reduce((a, b) => (Number(b.credit) > Number(a.credit) ? b : a));
const bankLabel = acctLabel(acctById.get(mainBank.account_id));
const txn = je.external_source === "acmacc_txn" && je.external_id ? txnById.get(String(je.external_id)) : null;
const bill = txn?.bills ?? null;
const vendorName = bill?.vendors?.name || txn?.vendors?.name || null;
let checkNo = (txn?.reference || je.reference || "").toString().trim();
const descSource = `${txn?.description ?? ""} ${je.description ?? ""}`.toLowerCase();
if (!checkNo && /\bach\b|autopay|auto-pay|eft/.test(descSource)) checkNo = "ACH";
const description = vendorName
? `${vendorName}${bill?.number ? ` Inv # ${bill.number}` : ""}`
: (txn?.description || je.description || "—");
const entry: Disbursement = {
jeId: je.id,
date: String(je.date).slice(0, 10),
checkNo: checkNo || "—",
description,
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
amount,
lines: nonBankDebits.map((l) => ({
code: acctById.get(l.account_id)?.code ?? null,
name: acctById.get(l.account_id)?.name ?? "Unknown account",
description: l.description ?? null,
amount: Number(l.debit || 0),
})),
};
const g = groups.get(bankLabel) ?? { bankLabel, entries: [], subtotal: 0 };
g.entries.push(entry);
g.subtotal += amount;
groups.set(bankLabel, g);
grandTotal += amount;
}
const out = [...groups.values()].sort((a, b) => a.bankLabel.localeCompare(b.bankLabel));
for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date));
return { groups: out, grandTotal };
}, [data]);
const rangeLabel = `${fmtDate(from)} ${fmtDate(to)}`;
const exportPDF = async () => {
if (!report) return;
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
const ML = 40;
const logo = await loadBrandedLogo(logoUrl);
const startY = drawBrandedHeader(doc, {
logo, title: "Cash Disbursement", subtitle: rangeLabel,
metaLines: [{ label: "Properties:", value: companyName || "" }],
});
const body: any[] = [];
for (const g of report.groups) {
body.push([{
content: g.bankLabel,
colSpan: 5,
styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10, fillColor: [255, 255, 255] },
}]);
for (const e of g.entries) {
body.push([
{ content: fmtDate(e.date), styles: { fontStyle: "bold" } },
{ content: e.checkNo, styles: { fontStyle: "bold" } },
{ content: e.description, styles: { fontStyle: "bold" } },
{ content: e.invoiceDate ? fmtDate(e.invoiceDate) : "", styles: { fontStyle: "bold", halign: "right" } },
{ content: num(e.amount), styles: { fontStyle: "bold", halign: "right" } },
]);
for (const l of e.lines) {
body.push([
"",
"",
{ content: ` ${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`, styles: { textColor: [90, 90, 90] } },
"",
{ content: num(l.amount), styles: { halign: "right", textColor: [90, 90, 90] } },
]);
}
}
body.push([
{ content: `Total ${g.bankLabel}`, colSpan: 4, styles: { fontStyle: "bold", halign: "right" } },
{ content: num(g.subtotal), styles: { fontStyle: "bold", halign: "right" } },
]);
}
body.push([
{ content: "Total Disbursements", colSpan: 4, styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
{ content: num(report.grandTotal), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
]);
autoTable(doc, {
startY,
head: [["Paid Date", "CheckNo", "Description", "Invoice Date", "Amount"]],
body,
styles: { fontSize: 8, cellPadding: 3 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 70 }, 1: { cellWidth: 80 }, 3: { cellWidth: 80, halign: "right" }, 4: { cellWidth: 80, halign: "right" } },
margin: { left: ML, right: ML },
});
drawBrandedFooter(doc);
doc.save(`cash-disbursement-${from}-to-${to}.pdf`);
};
const exportCSV = () => {
if (!report) return;
const q = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
const f = (n: number) => n.toFixed(2);
const lines = [["Bank Account", "Paid Date", "CheckNo", "Description", "GL Account", "Invoice Date", "Amount"].join(",")];
for (const g of report.groups) {
for (const e of g.entries) {
lines.push([q(g.bankLabel), e.date, q(e.checkNo), q(e.description), "", e.invoiceDate ?? "", f(e.amount)].join(","));
for (const l of e.lines) {
lines.push([q(g.bankLabel), "", "", "", q(`${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`), "", f(l.amount)].join(","));
}
}
lines.push([q(g.bankLabel), "", "", q(`Total ${g.bankLabel}`), "", "", f(g.subtotal)].join(","));
}
lines.push(["", "", "", "Total Disbursements", "", "", f(report.grandTotal)].join(","));
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `cash-disbursement-${from}-to-${to}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
const hasData = !!report && report.groups.length > 0;
return (
<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">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value || from)} className="w-44 mt-1" />
</div>
<div>
<Label className="text-xs text-muted-foreground">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value || to)} className="w-44 mt-1" />
</div>
{hasData && (
<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>
) : !hasData ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No disbursements in this period.</CardContent></Card>
) : (
<ReportSheet title="Cash Disbursement" subtitle={rangeLabel} 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 w-24">Paid Date</th>
<th className="px-3 py-2 text-left font-semibold w-28">CheckNo</th>
<th className="px-3 py-2 text-left font-semibold">Description</th>
<th className="px-3 py-2 text-right font-semibold w-28">Invoice Date</th>
<th className="px-3 py-2 text-right font-semibold w-28">Amount</th>
</tr>
</thead>
<tbody>
{report!.groups.map((g) => (
<>
<tr key={g.bankLabel}>
<td colSpan={5} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
{g.bankLabel}
</td>
</tr>
{g.entries.map((e) => (
<>
<tr key={e.jeId} className="border-t">
<td className="px-3 py-1.5 font-medium whitespace-nowrap">{fmtDate(e.date)}</td>
<td className="px-3 py-1.5 font-medium">{e.checkNo}</td>
<td className="px-3 py-1.5 font-medium">{e.description}</td>
<td className="px-3 py-1.5 text-right whitespace-nowrap">{e.invoiceDate ? fmtDate(e.invoiceDate) : ""}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-medium">{num(e.amount)}</td>
</tr>
{e.lines.map((l, li) => (
<tr key={`${e.jeId}-${li}`}>
<td />
<td />
<td className="px-3 py-0.5 pl-8 text-muted-foreground text-[13px]">
{l.code ? `${l.code} - ` : ""}{l.name}{l.description ? ` - ${l.description}` : ""}
</td>
<td />
<td className="px-3 py-0.5 text-right tabular-nums text-muted-foreground text-[13px]">{num(l.amount)}</td>
</tr>
))}
</>
))}
<tr className="border-t font-semibold">
<td colSpan={4} className="px-3 py-1.5 text-right">Total {g.bankLabel}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{num(g.subtotal)}</td>
</tr>
</>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-b-2 font-bold">
<td colSpan={4} className="px-3 py-2 text-right">Total Disbursements</td>
<td className="px-3 py-2 text-right tabular-nums">{num(report!.grandTotal)}</td>
</tr>
</tfoot>
</table>
</div>
</ReportSheet>
)}
</div>
);
}
@@ -0,0 +1,180 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { FileDown, Download } from "lucide-react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { ReportSheet } from "./ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
import {
fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners, num,
type UnitInfo, type OwnerInfo,
} from "../lib/ownerLedger";
const TEAL: [number, number, number] = [0, 137, 123];
type Row = { account: string; property: string; ownerName: string; credit: number };
/**
* Buildium-style "Pre Paid Homeowners": every unit whose owner ledger nets to
* a credit (payments exceed charges) as of the chosen date, with the credit amount.
*/
export function PrepaidHomeownersReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
const { data, isLoading } = useQuery({
queryKey: ["prepaid-homeowners", companyId, asOf],
enabled: !!companyId,
queryFn: async () => {
const associationId = await fetchAssociationId(companyId);
if (!associationId) return null;
const [entries, { units, owners }] = await Promise.all([
fetchOwnerLedger(associationId, asOf),
fetchUnitsAndOwners(associationId),
]);
return { entries, units, owners };
},
});
const { rows, total } = useMemo(() => {
if (!data) return { rows: [] as Row[], total: 0 };
const unitById = new Map<string, UnitInfo>();
for (const u of data.units) unitById.set(u.id, u);
const ownerById = new Map<string, OwnerInfo>();
for (const o of data.owners) ownerById.set(o.id, o);
const ownerByUnit = new Map<string, OwnerInfo>();
for (const o of data.owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
const bal = new Map<string, number>();
for (const e of data.entries) {
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null;
if (!key) continue;
bal.set(key, (bal.get(key) ?? 0) + e.debit - e.credit);
}
const out: Row[] = [];
for (const [key, b] of bal) {
if (b >= -0.004) continue; // only credit balances
const unitId = key.startsWith("u:") ? key.slice(2) : null;
const ownerId = key.startsWith("o:") ? key.slice(2) : null;
const unit = unitId ? unitById.get(unitId) : undefined;
const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null;
const property = [unit?.address, unit?.unit_number && !unit?.address?.includes(unit.unit_number) ? unit.unit_number : null]
.filter(Boolean).join(" ") || (unit?.unit_number ? `Unit ${unit.unit_number}` : "—");
out.push({
account: unit?.account_number || unit?.unit_number || "—",
property,
ownerName: owner ? `${owner.first_name ?? ""} ${owner.last_name ?? ""}`.trim() || "—" : "—",
credit: -b,
});
}
out.sort((a, b) => b.credit - a.credit);
return { rows: out, total: out.reduce((s, r) => s + r.credit, 0) };
}, [data]);
const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" });
const exportPDF = async () => {
const doc = new jsPDF({ unit: "pt", format: "letter" });
const ML = 40;
const logo = await loadBrandedLogo(logoUrl);
const startY = drawBrandedHeader(doc, {
logo, title: "Pre Paid Homeowners", subtitle: `For ${asOfLabel}`,
metaLines: [{ label: "Properties:", value: companyName || "" }],
});
autoTable(doc, {
startY,
head: [["Account", "Property", "Owner Name", "Credit Amount"]],
body: [
...rows.map((r) => [r.account, r.property, r.ownerName, num(r.credit)]),
[
{ content: "", styles: {} } as any,
{ content: "", styles: {} } as any,
{ content: "Total", styles: { fontStyle: "bold", halign: "right" } } as any,
{ content: num(total), styles: { fontStyle: "bold", halign: "right" } } as any,
],
],
styles: { fontSize: 8.5, cellPadding: 4 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 3: { halign: "right" } },
margin: { left: ML, right: ML },
});
drawBrandedFooter(doc);
doc.save(`prepaid-homeowners-${asOf}.pdf`);
};
const exportCSV = () => {
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
const f = (n: number) => n.toFixed(2);
const lines = [["Account", "Property", "Owner Name", "Credit Amount"].join(",")];
for (const r of rows) lines.push([q(r.account), q(r.property), q(r.ownerName), f(r.credit)].join(","));
lines.push(["", "", "Total", f(total)].join(","));
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `prepaid-homeowners-${asOf}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<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">As of</Label>
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
</div>
{rows.length > 0 && (
<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>
) : rows.length === 0 ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No prepaid balances as of {asOfLabel}.</CardContent></Card>
) : (
<ReportSheet title="Pre Paid Homeowners" subtitle={`For ${asOfLabel}`} 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>
<th className="px-3 py-2 text-left font-semibold">Property</th>
<th className="px-3 py-2 text-left font-semibold">Owner Name</th>
<th className="px-3 py-2 text-right font-semibold">Credit Amount</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={`border-b ${i % 2 ? "" : "bg-muted/20"}`}>
<td className="px-3 py-1.5 font-mono text-xs">{r.account}</td>
<td className="px-3 py-1.5">{r.property}</td>
<td className="px-3 py-1.5">{r.ownerName}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{num(r.credit)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-b-2 font-bold">
<td className="px-3 py-2" colSpan={3}>
<span className="float-right">Total</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">{num(total)}</td>
</tr>
</tfoot>
</table>
</div>
</ReportSheet>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { supabase } from "@/integrations/supabase/client";
import { accounting } from "@/lib/accountingClient";
/**
* Shared data access for the owner-ledger-based accounting reports
* (AR Aging by property, Pre-Paid Homeowners). The accounting dashboard is
* keyed by company; the owner ledger lives in the public schema keyed by
* association — companies.association_id bridges the two.
*/
export type OwnerLedgerEntry = {
unit_id: string | null;
owner_id: string | null;
date: string;
transaction_type: string | null;
debit: number;
credit: number;
};
export type UnitInfo = {
id: string;
unit_number: string | null;
address: string | null;
account_number: string | null;
};
export type OwnerInfo = {
id: string;
first_name: string | null;
last_name: string | null;
unit_id: string | null;
};
export async function fetchAssociationId(companyId: string): Promise<string | null> {
const { data } = await accounting
.from("companies")
.select("association_id")
.eq("id", companyId)
.maybeSingle();
return (data as any)?.association_id ?? null;
}
/** All owner-ledger entries for an association up to (and including) asOf, paged past the 1000-row cap. */
export async function fetchOwnerLedger(associationId: string, asOf: string): Promise<OwnerLedgerEntry[]> {
const PAGE = 1000;
const out: OwnerLedgerEntry[] = [];
for (let offset = 0; ; offset += PAGE) {
const { data, error } = await supabase
.from("owner_ledger_entries")
.select("unit_id, owner_id, date, transaction_type, debit, credit")
.eq("association_id", associationId)
.lte("date", asOf)
.order("id", { ascending: true })
.range(offset, offset + PAGE - 1);
if (error) throw error;
const rows = (data ?? []) as any[];
for (const r of rows) {
out.push({
unit_id: r.unit_id ?? null,
owner_id: r.owner_id ?? null,
date: String(r.date ?? "").slice(0, 10),
transaction_type: r.transaction_type ?? null,
debit: Number(r.debit || 0),
credit: Number(r.credit || 0),
});
}
if (rows.length < PAGE) break;
}
return out;
}
export async function fetchUnitsAndOwners(associationId: string): Promise<{ units: UnitInfo[]; owners: OwnerInfo[] }> {
const [unitsRes, ownersRes] = await Promise.all([
supabase.from("units").select("id, unit_number, address, account_number").eq("association_id", associationId),
supabase.from("owners").select("id, first_name, last_name, unit_id").eq("association_id", associationId),
]);
return {
units: (unitsRes.data ?? []) as UnitInfo[],
owners: (ownersRes.data ?? []) as OwnerInfo[],
};
}
/** Buildium-style label for a unit row: "account# - address unit# - Owner last name". */
export function unitLabel(unit: UnitInfo | undefined, ownerLastName: string | null): string {
const parts: string[] = [];
if (unit?.account_number) parts.push(unit.account_number);
const addr = [unit?.address, unit?.unit_number && !unit?.address?.includes(unit.unit_number) ? unit.unit_number : null]
.filter(Boolean)
.join(" ");
if (addr) parts.push(addr);
else if (unit?.unit_number) parts.push(`Unit ${unit.unit_number}`);
if (ownerLastName) parts.push(ownerLastName);
return parts.join(" - ") || "Unknown property";
}
const CHARGE_TYPE_LABELS: Record<string, string> = {
assessment: "Assessments",
special_assessment: "Special Assessments",
interest: "Interest",
late_fee: "Late Fees",
admin_fee: "Admin Fees",
legal_fee: "Legal Fees",
violation: "Violation Fines",
bank_fee: "Bank Fees",
prepayment: "Prepayments",
charge: "Charges",
};
export function chargeTypeLabel(t: string | null | undefined): string {
const key = String(t || "charge").toLowerCase();
if (CHARGE_TYPE_LABELS[key]) return CHARGE_TYPE_LABELS[key];
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Always two decimals, thousands-separated, negatives in parentheses. */
export function num(n: number): string {
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return v < 0 ? `(${abs})` : abs;
}
export function money(n: number): string {
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return v < 0 ? `($${abs})` : `$${abs}`;
}