Accounting: fix empty JE/GL pages + nightly Buildium GL pull sync

- Add missing indexes on journal_entry_lines (journal_entry_id, account_id)
  and journal_entries (company_id, date): Bridgewater's ledger query took
  7.5s, blew the 8s statement timeout, and rendered the JE/GL pages empty
- Paginate JE/GL page fetches past the 1000-row PostgREST cap (shared
  fetchJournalEntries helper) and surface query errors instead of
  swallowing them into an empty list
- New buildium-gl-sync edge function (scheduled nightly via pg_cron):
  incrementally pulls new Buildium GL activity into accounting journal
  entries via GET /v1/generalledger, reconstructing double-entry JEs by
  grouping per-account entries by transaction id; watermark + 14-day
  overlap window, dedupe on (company_id, external_source, external_id),
  account mapping by external_id/code/name with auto-create for new
  Buildium accounts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:12:38 -04:00
parent 4f0ac97e83
commit f5f6285bbd
5 changed files with 531 additions and 20 deletions
@@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "./lib/useCompanyId";
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -44,18 +45,12 @@ export default function AccountingGeneralLedgerPage() {
const [activityOnly, setActivityOnly] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { data: entries = [], isFetching } = useQuery({
const { data: entries = [], isFetching, error: entriesError } = useQuery({
queryKey: ["gl-journal-entries", cid],
enabled: !!cid,
queryFn: async () =>
(
await accounting
.from("journal_entries")
.select("id, date, description, reference, journal_entry_lines(debit, credit, account_id, accounts(code, name, type))")
.eq("company_id", cid)
.order("date", { ascending: true })
.order("created_at", { ascending: true })
).data ?? [],
queryFn: () =>
// Running balances below assume date-ascending order.
fetchAllJournalEntries(cid, "id, date, description, reference, journal_entry_lines(debit, credit, account_id, accounts(code, name, type))", { ascending: true }),
});
const { data: accounts = [] } = useQuery({
@@ -169,6 +164,13 @@ export default function AccountingGeneralLedgerPage() {
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
if (entriesError) {
return (
<p className="text-sm text-destructive text-center py-12">
Failed to load the general ledger: {(entriesError as any)?.message || String(entriesError)}
</p>
);
}
return (
<div className="space-y-6">
@@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "./lib/useCompanyId";
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -39,18 +40,10 @@ export default function AccountingJournalEntriesPage() {
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const { data: entries = [] } = useQuery({
const { data: entries = [], error: entriesError } = useQuery({
queryKey: ["journal-entries", cid],
enabled: !!cid,
queryFn: async () =>
(
await accounting
.from("journal_entries")
.select("*, journal_entry_lines(*, accounts(name, code))")
.eq("company_id", cid)
.order("date", { ascending: false })
.order("created_at", { ascending: false })
).data ?? [],
queryFn: () => fetchAllJournalEntries(cid, "*, journal_entry_lines(*, accounts(name, code))"),
});
const { data: accounts = [] } = useQuery({
@@ -186,6 +179,13 @@ export default function AccountingJournalEntriesPage() {
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
if (entriesError) {
return (
<p className="flex items-center justify-center gap-2 text-sm text-destructive py-12">
<AlertCircle className="h-4 w-4" /> Failed to load journal entries: {(entriesError as any)?.message || String(entriesError)}
</p>
);
}
return (
<div className="space-y-6">
@@ -0,0 +1,32 @@
import { accounting } from "@/lib/accountingClient";
// PostgREST caps each response at 1000 rows, so companies with more journal
// entries than that (e.g. Buildium-imported books) were silently truncated —
// and a query error (statement timeout) used to be swallowed into an empty
// list. Page through all entries on a stable key and throw on error so
// react-query surfaces failures instead of rendering an empty ledger.
const PAGE = 1000;
export async function fetchAllJournalEntries(
cid: string,
select: string,
opts: { ascending?: boolean } = {},
): Promise<any[]> {
const ascending = opts.ascending ?? false;
const out: any[] = [];
for (let offset = 0; ; offset += PAGE) {
const { data, error } = await accounting
.from("journal_entries")
.select(select)
.eq("company_id", cid)
.order("date", { ascending })
.order("created_at", { ascending })
.order("id", { ascending: true })
.range(offset, offset + PAGE - 1);
if (error) throw error;
const rows = (data ?? []) as any[];
out.push(...rows);
if (rows.length < PAGE) break;
}
return out;
}