Accounting platform: remove Zoho, unify reports, board access, vendor sharing

- Remove the Zoho Books integration (edge functions, sync libs, settings,
  reports/overview, banking links, fees tab, import dialog); preserve fee
  rules as a standalone FeesTab and the COA accounting_system classification.
- Financial Overview/Reports (staff + board) render the Accounting dashboard
  and reports; board reports mirror the rich Accounting Reports.
- New Reserve Fund Schedule report + an is_reserve flag on accounts.
- Unify all report exports to a branded format (logo + centered header +
  footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs
  Actuals and Bank Reconciliation PDFs now match the reference layout.
- Render financial reports inline (no preview pop-up).
- Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA
  navigation; editable bills in the Accounting Bills page.
- Negative opening balances flow through to the GL and reports (allow negative
  input; keep non-zero on save; signed CSV import).
- Upload a per-account trial balance via CSV on Opening Balances.
- Board members: read-only RLS access to their association's accounting ledger;
  editable board-members panel on the association page; share vendor contacts
  with the board (toggle + directory section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:29:31 -04:00
parent db20226d62
commit e302fb91f0
63 changed files with 2406 additions and 9514 deletions
+4 -131
View File
@@ -83,10 +83,6 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
const [association, setAssociation] = useState<
{ id: string; name: string; logo_url: string | null; zoho_organization_id: string | null } | null
>(null);
const [zohoCur, setZohoCur] = useState<Aggregated | null>(null);
const [zohoCmp, setZohoCmp] = useState<Aggregated | null>(null);
const [zohoLoading, setZohoLoading] = useState(false);
const [zohoError, setZohoError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [printing, setPrinting] = useState(false);
const [timeframe, setTimeframe] = useState<Timeframe>("ytd");
@@ -265,118 +261,8 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
[ranges, actuals],
);
// If the association is linked to a Zoho organization, fetch the P&L for the
// current (and comparison) ranges and use those totals as the actuals source.
const isZohoLinked = !!association?.zoho_organization_id;
useEffect(() => {
if (!isZohoLinked || !associationId) {
setZohoCur(null);
setZohoCmp(null);
return;
}
let cancelled = false;
// Debounce so rapid filter changes don't fire overlapping (rate-limited) requests
const debounce = setTimeout(() => { run(); }, 350);
const toIso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const flatten = (pl: any): { name: string; amount: number; type: "income" | "expense" }[] => {
const out: { name: string; amount: number; type: "income" | "expense" }[] = [];
const sections = pl?.profit_and_loss || pl?.profitandloss || pl?.sections || pl?.reports || pl?.rows || [];
const walk = (nodes: any[], inheritedType: "income" | "expense" | null) => {
for (const n of nodes || []) {
const label = String(n?.section_name || n?.name || n?.account_name || n?.account_type || n?.total_label || "").toLowerCase();
let t = inheritedType;
if (/income|revenue|operating_income|other_income/.test(label)) t = "income";
else if (/expense|cost|cogs|cost_of_goods/.test(label)) t = "expense";
const accountName = n?.account_name || n?.name || n?.account || null;
const hasChildren = zohoChildKeys.some((key) => asArray(n?.[key]).length > 0);
const isTotal = !!n?.total_label || /^total\b/.test(String(accountName || "").trim().toLowerCase());
const raw = parseMoney(n?.total ?? n?.amount ?? n?.amount_in_base_currency ?? n?.balance ?? n?.value);
if (accountName && !hasChildren && !isTotal && raw !== 0) {
out.push({ name: String(accountName), amount: Math.abs(raw), type: t || "expense" });
}
zohoChildKeys.forEach((key) => {
const children = asArray(n?.[key]);
if (children.length) walk(children, t);
});
}
};
walk(asArray(sections), null);
return out;
};
const buildAgg = (items: { name: string; amount: number; type: "income" | "expense" }[]): Aggregated => {
const byGl: Record<string, Bucket> = {};
const byName: Record<string, Bucket> = {};
const make = (): Bucket => ({ income: 0, expense: 0, glIds: new Set(), names: new Set() });
items.forEach((it) => {
const key = normalizeName(it.name);
const nb = (byName[key] ||= make());
if (!nb.display) nb.display = it.name;
nb.names.add(key);
const glId = coaIdByName[key];
if (glId) nb.glIds.add(glId);
if (it.type === "income") nb.income += it.amount; else nb.expense += it.amount;
if (glId) {
const gb = (byGl[glId] ||= make());
gb.glIds.add(glId);
gb.names.add(key);
if (it.type === "income") gb.income += it.amount; else gb.expense += it.amount;
}
});
return { byGl, byName };
};
const fetchOne = async (from: Date, to: Date) => {
// Retry once on transient errors (rate limit / cold start)
let lastErr: any = null;
for (let attempt = 0; attempt < 2; attempt++) {
const { data, error } = await supabase.functions.invoke("zoho-books", {
body: {
action: "get_profit_and_loss",
params: { association_id: associationId, from_date: toIso(from), to_date: toIso(to) },
},
});
if (!error) return buildAgg(flatten((data as any)?.data ?? data));
lastErr = error;
await new Promise((r) => setTimeout(r, 600));
}
throw new Error(lastErr?.message || "Zoho fetch failed");
};
const hasAnyData = (agg: Aggregated | null) => {
if (!agg) return false;
const sumBuckets = (rec: Record<string, Bucket>) =>
Object.values(rec).reduce((s, b) => s + Math.abs(b.income) + Math.abs(b.expense), 0);
return sumBuckets(agg.byName) + sumBuckets(agg.byGl) > 0;
};
async function run() {
setZohoLoading(true);
setZohoError(null);
try {
const cur = await fetchOne(ranges.curStart, ranges.curEnd);
if (cancelled) return;
// If Zoho returned nothing parseable, fall back to local actuals rather
// than rendering an empty (but truthy) aggregate that zeros every row.
setZohoCur(hasAnyData(cur) ? cur : null);
if (ranges.cmpStart && ranges.cmpEnd) {
const cmp = await fetchOne(ranges.cmpStart, ranges.cmpEnd);
if (cancelled) return;
setZohoCmp(hasAnyData(cmp) ? cmp : null);
} else {
setZohoCmp(null);
}
} catch (e) {
console.warn("[BudgetVsActualReport] Zoho P&L fetch failed, falling back to local:", e);
// Do NOT clear previous successful results — that's what caused the
// table to flicker / show wrong data when filters change quickly.
if (!cancelled) setZohoError((e as Error)?.message || "Zoho fetch failed");
} finally {
if (!cancelled) setZohoLoading(false);
}
}
return () => { cancelled = true; clearTimeout(debounce); };
}, [isZohoLinked, associationId, ranges, coaIdByName]);
const curActuals = zohoCur || localCurActuals;
const cmpActuals = zohoCmp || localCmpActuals;
const curActuals = localCurActuals;
const cmpActuals = localCmpActuals;
// Build report rows: one per leaf budget line, with matched actuals
const reportRows = useMemo(() => {
@@ -471,10 +357,7 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
return false;
};
// Walk byName so each unbudgeted line shows under its human-readable name.
// When Zoho is the actuals source, always surface unbudgeted lines so
// expenses pulled from Zoho aren't silently hidden when they don't match
// a local budget category.
const showUnbudgeted = !hideUnbudgeted || !!zohoCur;
const showUnbudgeted = !hideUnbudgeted;
if (showUnbudgeted) {
Object.entries(curActuals.byName).forEach(([key, v]) => {
const isMatchedById = Array.from(v.glIds).some((id) => matchedGlIds.has(id));
@@ -526,7 +409,7 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
if (ac !== bc) return ac.localeCompare(bc, undefined, { numeric: true });
return a.category.localeCompare(b.category);
});
}, [budgets, fiscalYear, curActuals, cmpActuals, budgetMonths, comparisonBudgetMonths, accountParents, accountNumbers, hideUnbudgeted, zohoCur]);
}, [budgets, fiscalYear, curActuals, cmpActuals, budgetMonths, comparisonBudgetMonths, accountParents, accountNumbers, hideUnbudgeted]);
const totals = useMemo(() => {
const income = reportRows.filter((r) => r.accountType === "income");
@@ -742,16 +625,6 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
<Badge variant="secondary">Period: {rangeLabel}</Badge>
{cmpRangeLabel && <Badge variant="outline">Compare: {cmpRangeLabel}</Badge>}
{budgetMonths < 12 && <Badge variant="outline">Budget pro-rated to {budgetMonths} of 12 months</Badge>}
{isZohoLinked && (
<Badge variant="outline" className="border-emerald-500/40 text-emerald-700">
{zohoLoading ? "Loading Zoho P&L…" : "Actuals: Zoho Books P&L"}
</Badge>
)}
{zohoError && (
<Badge variant="outline" className="border-amber-500/50 text-amber-700">
Zoho refresh failed showing previous data. {zohoError}
</Badge>
)}
</div>
{/* KPI cards */}