diff --git a/src/pages/accounting/AccountingGeneralLedgerPage.tsx b/src/pages/accounting/AccountingGeneralLedgerPage.tsx index f55e338..ef7b7ad 100644 --- a/src/pages/accounting/AccountingGeneralLedgerPage.tsx +++ b/src/pages/accounting/AccountingGeneralLedgerPage.tsx @@ -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

Select an association.

; if (companyLoading) return
; if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; + if (entriesError) { + return ( +

+ Failed to load the general ledger: {(entriesError as any)?.message || String(entriesError)} +

+ ); + } return (
diff --git a/src/pages/accounting/AccountingJournalEntriesPage.tsx b/src/pages/accounting/AccountingJournalEntriesPage.tsx index 94e0bb2..183c0cd 100644 --- a/src/pages/accounting/AccountingJournalEntriesPage.tsx +++ b/src/pages/accounting/AccountingJournalEntriesPage.tsx @@ -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(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

Select an association.

; if (companyLoading) return
; if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; + if (entriesError) { + return ( +

+ Failed to load journal entries: {(entriesError as any)?.message || String(entriesError)} +

+ ); + } return (
diff --git a/src/pages/accounting/lib/fetchJournalEntries.ts b/src/pages/accounting/lib/fetchJournalEntries.ts new file mode 100644 index 0000000..a5a0d0f --- /dev/null +++ b/src/pages/accounting/lib/fetchJournalEntries.ts @@ -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 { + 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; +} diff --git a/supabase/functions/buildium-gl-sync/index.ts b/supabase/functions/buildium-gl-sync/index.ts new file mode 100644 index 0000000..914e0e4 --- /dev/null +++ b/supabase/functions/buildium-gl-sync/index.ts @@ -0,0 +1,465 @@ +// Buildium GL Sync (pull-only) — incrementally pulls new Buildium general +// ledger transactions into accounting.journal_entries / journal_entry_lines. +// +// Scope: companies whose books were imported from Buildium (i.e. that already +// have journal entries with external_source = 'buildium_gl'), or companies +// explicitly listed in the request body. Dedupe rides on the existing unique +// index journal_entries_external_uq (company_id, external_source, external_id) +// where external_id is the Buildium transaction id — the same keying the GL +// CSV imports used, so re-pulling an overlapping window never duplicates. +// +// Pull-only by design: nothing is written back to Buildium, and transactions +// edited or deleted in Buildium after they were pulled are NOT reconciled. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", +}; + +const BUILDIUM_BASE = "https://api.buildium.com"; +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// How far behind the watermark each run re-reads. Buildium allows backdating, +// so a pure "since last run" window would miss entries posted into the past. +const OVERLAP_DAYS = 14; + +async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: URLSearchParams) { + const url = new URL(`${BUILDIUM_BASE}${path}`); + if (params) url.search = params.toString(); + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch(url.toString(), { + headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" }, + }); + if (res.ok) return res.json(); + const text = await res.text(); + if ((res.status === 429 || res.status >= 500) && attempt < 3) { + const ra = Number(res.headers.get("Retry-After") ?? ""); + await wait(Number.isFinite(ra) && ra > 0 ? ra * 1000 : 600 * Math.pow(2, attempt)); + continue; + } + throw new Error(`Buildium ${path} [${res.status}]: ${text}`); + } + throw new Error(`Buildium ${path} failed after retries`); +} + +async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, base?: URLSearchParams) { + const all: any[] = []; + let offset = 0; + const limit = 1000; + while (true) { + const params = new URLSearchParams(base); + params.set("offset", String(offset)); + params.set("limit", String(limit)); + const page = await buildiumFetch(path, clientId, clientSecret, params); + if (!Array.isArray(page) || page.length === 0) break; + all.push(...page); + // Buildium silently clamps `limit` on some endpoints, so a short page + // doesn't prove we're done — advance by what we actually got and stop + // only on an empty page. + offset += page.length; + } + return all; +} + +const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); +// Local account names sometimes carry a leading code ("2010 Prepayments"); +// strip it so they match Buildium's bare names. +const normName = (v: unknown) => norm(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, "")); + +function mapGLAccountType(type: string | null | undefined): string { + const t = String(type || "").toLowerCase(); + if (t.includes("asset")) return "asset"; + if (t.includes("liabilit")) return "liability"; + if (t.includes("equity")) return "equity"; + if (t.includes("income") || t.includes("revenue")) return "income"; + if (t.includes("expense")) return "expense"; + return "expense"; +} + +const isoDate = (d: Date) => d.toISOString().slice(0, 10); +const addDays = (iso: string, days: number) => { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return isoDate(d); +}; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + try { + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const token = authHeader.replace("Bearer ", ""); + + // Allow either the service role (pg_cron) or a staff user JWT. + let claims: any = null; + try { + const payload = token.split(".")[1]; + const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "="); + claims = JSON.parse(atob(padded)); + } catch { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + const isServiceRole = claims?.role === "service_role"; + if (!isServiceRole) { + const auth = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } }); + const { data: roles } = await auth.from("user_roles").select("role").eq("user_id", claims?.sub ?? ""); + const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager"); + if (!isStaff) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + } + + const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? ""; + const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? ""; + if (!clientId || !clientSecret) { + return new Response(JSON.stringify({ error: "Buildium API credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + const supabase = createClient(supabaseUrl, serviceKey, { db: { schema: "accounting" } }); + const pub = createClient(supabaseUrl, serviceKey); + + const body = await req.json().catch(() => ({})); + + // Debug: dump a raw transaction (bypasses the sync). + if (body.debugTransactionId) { + const tx = await buildiumFetch(`/v1/generalledger/transactions/${body.debugTransactionId}`, clientId, clientSecret); + return new Response(JSON.stringify(tx), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + + // Debug: inspect specific GL accounts directly (bypasses the sync). + if (Array.isArray(body.debugGlAccountIds) && body.debugGlAccountIds.length > 0) { + const listed = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret); + const out: Record = { + listed_count: listed.length, + with_parent: listed.filter((g: any) => g.ParentGLAccountId).length, + with_subaccounts: listed.filter((g: any) => Array.isArray(g.SubAccounts) && g.SubAccounts.length > 0).length, + sample_keys: listed[0] ? Object.keys(listed[0]) : [], + sample_subaccount: listed.find((g: any) => Array.isArray(g.SubAccounts) && g.SubAccounts.length > 0)?.SubAccounts?.[0] ?? null, + }; + for (const id of body.debugGlAccountIds.slice(0, 10)) { + try { + const acct = await buildiumFetch(`/v1/glaccounts/${id}`, clientId, clientSecret); + out[String(id)] = { AccountNumber: acct?.AccountNumber ?? null, Name: acct?.Name ?? null, Type: acct?.Type ?? null, SubType: acct?.SubType ?? null, IsActive: acct?.IsActive ?? null, ParentGLAccountId: acct?.ParentGLAccountId ?? null, inList: listed.some((g: any) => String(g.Id) === String(id)) }; + } catch (e: any) { + out[String(id)] = { error: e?.message || String(e) }; + } + } + return new Response(JSON.stringify(out), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } + const companyIdsFilter: string[] = Array.isArray(body.companyIds) ? body.companyIds.filter((s: any) => typeof s === "string" && s) : []; + const dateFromOverride = typeof body.dateFrom === "string" ? body.dateFrom : null; + const dateToOverride = typeof body.dateTo === "string" ? body.dateTo : null; + const dryRun = body.dryRun === true; + + // ---- Companies in scope: Buildium-managed = has buildium_gl entries ---- + const { data: companies, error: cErr } = await supabase + .from("companies") + .select("id, name, association_id, acmacc_sync_config") + .not("association_id", "is", null); + if (cErr) throw cErr; + + const scoped = (companies || []).filter((c: any) => companyIdsFilter.length === 0 || companyIdsFilter.includes(c.id)); + + // ---- Buildium association mapping by normalized name (same as import) ---- + const { data: assocRows } = await pub.from("associations").select("id, name"); + const assocNameById = new Map(); + for (const a of assocRows || []) assocNameById.set(a.id, a.name); + const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret); + const bAssocIdByName = new Map(); + for (const ba of buildiumAssocs) bAssocIdByName.set(norm(ba.Name), String(ba.Id)); + + // ---- Buildium chart of accounts (account-id resolution + auto-create) ---- + // /v1/glaccounts returns only top-level accounts as list items; children + // are nested in each item's SubAccounts array. Flatten recursively. + const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret); + const bGlById = new Map(); + const addGl = (g: any) => { + if (!g?.Id) return; + bGlById.set(String(g.Id), g); + if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) addGl(s); + }; + for (const g of glAccounts) addGl(g); + const allGlIds = [...bGlById.keys()]; + + const today = isoDate(new Date()); + const results: Record = {}; + + for (const company of scoped) { + const companyResult: any = { pulled: 0, inserted: 0, skipped_existing: 0, errors: [] as string[] }; + results[company.name] = companyResult; + try { + // Watermark: explicit override > stored config > newest imported entry. + const cfg = (company.acmacc_sync_config ?? {}) as Record; + let watermark: string | null = dateFromOverride || cfg?.buildium_gl?.last_synced_date || null; + if (!watermark) { + const { data: maxRow } = await supabase + .from("journal_entries") + .select("date") + .eq("company_id", company.id) + .eq("external_source", "buildium_gl") + .order("date", { ascending: false }) + .limit(1) + .maybeSingle(); + watermark = maxRow?.date ?? null; + } + if (!watermark) { + // No baseline Buildium import and no explicit dateFrom — not a + // Buildium-managed company; leave it alone. + companyResult.skipped = "no buildium_gl baseline (pass dateFrom to backfill)"; + continue; + } + + const bAssocId = bAssocIdByName.get(norm(assocNameById.get(company.association_id) ?? "")); + if (!bAssocId) { + companyResult.errors.push("No Buildium association matches the local association name"); + continue; + } + + const since = dateFromOverride ?? addDays(watermark, -OVERLAP_DAYS); + const until = dateToOverride ?? today; + + // ---- Pull general ledger entries for the window ---- + // /v1/generalledger returns, per GL account, the actual ledger entries + // (the same data as Buildium's GL report). Each entry carries the id + // of the transaction that produced it and a signed amount (debit + // positive / credit negative), so grouping entries across accounts by + // transaction id reconstructs complete double-entry journal entries. + // (The /generalledger/transactions Journal omits implicit AR/cash + // sides, so it can't be used for this.) + // + // glaccountids is required, but sending the whole chart at once blows + // the URL length limit — chunk it. + type GlLine = { bGlId: string; amount: number }; + const txById = new Map(); + const CHUNK = 50; + for (let i = 0; i < allGlIds.length; i += CHUNK) { + const params = new URLSearchParams(); + params.set("accountingbasis", "Accrual"); + params.set("startdate", since); + params.set("enddate", until); + params.set("entitytype", "Association"); + params.set("entityid", bAssocId); + for (const id of allGlIds.slice(i, i + CHUNK)) params.append("glaccountids", id); + const ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params); + for (const ledger of ledgers) { + const bGlId = String(ledger.GLAccountId ?? ledger.GLAccount?.Id ?? ""); + if (!bGlId) continue; + for (const e of ledger.Entries ?? []) { + const txId = String(e.Id ?? ""); + if (!txId) continue; + let tx = txById.get(txId); + if (!tx) { + tx = { + date: String(e.Date || "").split("T")[0], + description: String(e.Description || e.TransactionType || "Buildium entry").slice(0, 500), + transactionType: String(e.TransactionType || ""), + lines: [], + }; + txById.set(txId, tx); + } + tx.lines.push({ bGlId, amount: Number(e.Amount) || 0 }); + } + } + } + companyResult.pulled = txById.size; + + // ---- Already-imported transaction ids for this company ---- + const existingIds = new Set(); + for (let offset = 0; ; offset += 1000) { + const { data: rows, error } = await supabase + .from("journal_entries") + .select("external_id") + .eq("company_id", company.id) + .eq("external_source", "buildium_gl") + .order("id", { ascending: true }) + .range(offset, offset + 999); + if (error) throw error; + for (const r of rows || []) if (r.external_id) existingIds.add(String(r.external_id)); + if ((rows || []).length < 1000) break; + } + + // ---- Local account resolution maps ---- + const { data: localAccounts, error: aErr } = await supabase + .from("accounts") + .select("id, code, name, type, external_source, external_id") + .eq("company_id", company.id); + if (aErr) throw aErr; + const byExternal = new Map(); + const byCode = new Map(); + const byName = new Map(); + for (const a of localAccounts || []) { + if (a.external_id) byExternal.set(String(a.external_id), a); + if (a.code) byCode.set(norm(a.code), a); + byName.set(normName(a.name), a); + } + + async function resolveAccount(bGlId: string): Promise<{ id: string } | null> { + const direct = byExternal.get(bGlId); + if (direct) return direct; + const meta = bGlById.get(bGlId); + if (!meta) return null; + const codeMatch = meta.AccountNumber ? byCode.get(norm(meta.AccountNumber)) : null; + const nameMatch = byName.get(normName(meta.Name)); + const match = codeMatch || nameMatch || null; + if (match) { + // Backfill the Buildium id so future syncs resolve deterministically. + if (!match.external_id && !dryRun) { + await supabase.from("accounts").update({ external_source: "buildium", external_id: bGlId }).eq("id", match.id); + } + match.external_id = match.external_id || bGlId; + byExternal.set(bGlId, match); + return match; + } + if (dryRun) { + // Would be auto-created in a real run; stub it so the dry run + // reports the transaction as insertable rather than unmapped. + const stub = { id: `dryrun-${bGlId}`, external_id: bGlId }; + byExternal.set(bGlId, stub); + companyResult.accounts_created = (companyResult.accounts_created || 0) + 1; + return stub; + } + // New account in Buildium — mirror it locally, like the import would. + const { data: created, error: createErr } = await supabase + .from("accounts") + .insert({ + company_id: company.id, + code: meta.AccountNumber ? String(meta.AccountNumber) : null, + name: meta.Name || `Buildium account ${bGlId}`, + type: mapGLAccountType(meta.Type || meta.AccountType), + description: meta.Description || null, + external_source: "buildium", + external_id: bGlId, + }) + .select("id, code, name, external_id") + .single(); + if (createErr) throw createErr; + byExternal.set(bGlId, created); + companyResult.accounts_created = (companyResult.accounts_created || 0) + 1; + return created; + } + + // ---- Insert new transactions as journal entries ---- + const newTxns = [...txById.entries()] + .filter(([txId]) => !existingIds.has(txId)) + .sort((a, b) => a[1].date.localeCompare(b[1].date)); + companyResult.skipped_existing = txById.size - newTxns.length; + + for (const [txId, tx] of newTxns) { + try { + const lineRows: { account_id: string; debit: number; credit: number; description: string | null }[] = []; + let resolved = true; + for (const l of tx.lines) { + const acct = await resolveAccount(l.bGlId); + if (!acct) { + const meta = bGlById.get(l.bGlId); + companyResult.errors.push( + `tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"})`, + ); + resolved = false; + break; + } + // Amount is signed relative to the account's NATURAL balance: + // positive means the account's balance increased. An increase + // is a debit for asset/expense accounts and a credit for + // liability/equity/income accounts. Buildium's own account type + // is authoritative for how it signed the amount. + if (l.amount === 0) continue; + const meta = bGlById.get(l.bGlId); + const bType = mapGLAccountType(meta?.Type || (acct as any).type); + const creditNatural = bType === "liability" || bType === "equity" || bType === "income"; + const increase = l.amount >= 0; + const isDebit = creditNatural ? !increase : increase; + lineRows.push({ + account_id: acct.id, + debit: isDebit ? Math.abs(l.amount) : 0, + credit: isDebit ? 0 : Math.abs(l.amount), + description: null, + }); + } + if (!resolved) continue; + + const debits = lineRows.reduce((s, l) => s + l.debit, 0); + const credits = lineRows.reduce((s, l) => s + l.credit, 0); + if (Math.abs(debits - credits) > 0.005) { + companyResult.errors.push(`tx ${txId}: unbalanced (${debits.toFixed(2)} vs ${credits.toFixed(2)})`); + continue; + } + + if (dryRun) { + companyResult.inserted += 1; + continue; + } + + const { data: je, error: jeErr } = await supabase + .from("journal_entries") + .insert({ + company_id: company.id, + date: tx.date || today, + description: tx.description, + reference: null, + external_source: "buildium_gl", + external_id: txId, + }) + .select("id") + .single(); + if (jeErr) { + // Unique violation = concurrently imported; anything else is real. + if ((jeErr as any).code === "23505") { + companyResult.skipped_existing += 1; + continue; + } + throw jeErr; + } + const { error: lErr } = await supabase + .from("journal_entry_lines") + .insert(lineRows.map((l) => ({ ...l, journal_entry_id: je.id }))); + if (lErr) { + // Don't leave a headerless entry behind. + await supabase.from("journal_entries").delete().eq("id", je.id); + throw lErr; + } + companyResult.inserted += 1; + } catch (e: any) { + companyResult.errors.push(`tx ${txId}: ${e?.message || String(e)}`); + } + } + + // ---- Advance the watermark ---- + if (!dryRun) { + const nextCfg = { + ...cfg, + buildium_gl: { + last_synced_date: until, + last_run_at: new Date().toISOString(), + last_result: { + pulled: companyResult.pulled, + inserted: companyResult.inserted, + errors: companyResult.errors.length, + }, + }, + }; + await supabase.from("companies").update({ acmacc_sync_config: nextCfg }).eq("id", company.id); + } + companyResult.window = { since, until }; + } catch (e: any) { + companyResult.errors.push(e?.message || String(e)); + } + } + + return new Response(JSON.stringify({ success: true, dryRun, results }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e: any) { + console.error("buildium-gl-sync error", e); + return new Response(JSON.stringify({ error: e?.message || String(e) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } +}); diff --git a/supabase/migrations/20260611200000_accounting_je_indexes.sql b/supabase/migrations/20260611200000_accounting_je_indexes.sql new file mode 100644 index 0000000..990eb04 --- /dev/null +++ b/supabase/migrations/20260611200000_accounting_je_indexes.sql @@ -0,0 +1,12 @@ +-- Performance: journal_entry_lines had no FK indexes, so every nested +-- journal_entries -> journal_entry_lines fetch seq-scanned all lines per entry. +-- For Bridgewater (4,189 JEs / 10,075 lines) the JE + GL pages exceeded the 8s +-- statement timeout and rendered empty. +create index if not exists journal_entry_lines_journal_entry_id_idx + on accounting.journal_entry_lines (journal_entry_id); + +create index if not exists journal_entry_lines_account_id_idx + on accounting.journal_entry_lines (account_id); + +create index if not exists journal_entries_company_date_idx + on accounting.journal_entries (company_id, date);