// 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. // // Account resolution is STRICT: Buildium GL accounts must be explicitly linked // to local accounts via public.buildium_gl_account_links (Buildium settings → // GL Account Map). Unmapped accounts hold their transactions (watermark is not // advanced) and are flagged in public.buildium_unmapped_gl_accounts. 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(); 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, gl_auto_post") .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 metadata for line signing) ---- // /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); // /v1/glaccounts omits some inactive accounts (e.g. prior-management bank // accounts) whose ledgers still hold one side of historical transactions — // without them, checks/deposits come back unbalanced. /v1/bankaccounts // returns inactive banks too, so merge their GL ids in. const bankAccts = await buildiumFetchAll("/v1/bankaccounts", clientId, clientSecret); for (const b of bankAccts) { const gid = String(b.GLAccount?.Id ?? b.Id ?? ""); if (gid && !bGlById.has(gid)) { bGlById.set(gid, { Id: gid, Name: b.GLAccount?.Name ?? b.Name, AccountNumber: b.GLAccount?.AccountNumber ?? null, Type: b.GLAccount?.Type ?? "Asset", IsActive: b.IsActive, }); } } 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; // Direct A/P import (buildium-sync "bills") posts Bill / Bill Payment / // Check journal entries itself for this company — skip those // transaction types here so they aren't double counted. if (cfg?.buildium_gl?.exclude_ap) { // NOTE: owner "Refund" transactions stay in the GL pull — they are // not returned by the /checks endpoint the direct import reads. const AP_TYPES = new Set(["bill", "bill payment", "billpayment", "check", "bill credit", "vendor credit", "applied vendor credit"]); let excludedAp = 0; for (const [txId, tx] of [...txById.entries()]) { if (AP_TYPES.has(String(tx.transactionType || "").toLowerCase())) { txById.delete(txId); excludedAp++; } } companyResult.excluded_ap = excludedAp; } // ---- 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: explicit links only ---- // STRICT mapping: Buildium GL accounts resolve exclusively through // public.buildium_gl_account_links (seeded from the historical // accounts.external_id backfills). No code/name matching and no // auto-create — transactions touching an unmapped account are held, // flagged for the GL Account Map UI, and re-pulled once mapped. const { data: localAccounts, error: aErr } = await supabase .from("accounts") .select("id, type, is_bank, name") .eq("company_id", company.id); if (aErr) throw aErr; const localById = new Map(); const bankIds = new Set(); const acctNameById = new Map(); for (const a of localAccounts || []) { localById.set(a.id, a); acctNameById.set(a.id, a.name); if (a.is_bank) bankIds.add(a.id); } // Import-mode companies (gl_auto_post=false) don't post Banking entries // to the GL; their bank registers are fed only from the GL pull. Mirror // each inserted bank line into accounting.transactions so the registers // and reconciliation stay current. gl_managed companies skip this — the // register is their source of truth and post_transaction_gl drives the GL. const materializeRegister = company.gl_auto_post === false; const { data: linkRows, error: lkErr } = await pub .from("buildium_gl_account_links") .select("buildium_gl_id, account_id") .eq("association_id", company.association_id); if (lkErr) throw lkErr; const linkByGlId = new Map(); for (const r of linkRows || []) linkByGlId.set(String(r.buildium_gl_id), r.account_id); const unmappedGl = new Map(); // buildium_gl_id -> Buildium meta function resolveAccount(bGlId: string): { id: string; type?: string } | null { const accountId = linkByGlId.get(bGlId); if (!accountId) return null; return localById.get(accountId) || { id: accountId }; } // ---- 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 = resolveAccount(l.bGlId); if (!acct) { const meta = bGlById.get(l.bGlId); unmappedGl.set(String(l.bGlId), meta || null); if (companyResult.errors.length < 50) { companyResult.errors.push( `tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"}) — map it in Buildium settings → GL Account Map`, ); } 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 { data: insertedLines, error: lErr } = await supabase .from("journal_entry_lines") .insert(lineRows.map((l) => ({ ...l, journal_entry_id: je.id }))) .select("id, account_id, debit, credit"); if (lErr) { // Don't leave a headerless entry behind. await supabase.from("journal_entries").delete().eq("id", je.id); throw lErr; } companyResult.inserted += 1; // ---- Materialize bank register rows (import-mode companies) ---- if (materializeRegister) { const lines = insertedLines || []; const offsetIds = [...new Set(lines.filter((l: any) => !bankIds.has(l.account_id)).map((l: any) => l.account_id))]; const offsetId = offsetIds.length === 1 ? offsetIds[0] : null; const offsetName = offsetId ? (acctNameById.get(offsetId) ?? null) : null; const regRows = lines .filter((l: any) => bankIds.has(l.account_id)) .map((l: any) => { const moneyIn = Number(l.debit) > 0; // bank debit = deposit (money in) return { company_id: company.id, account_id: l.account_id, date: tx.date || today, description: tx.description || "Buildium GL", amount: moneyIn ? Number(l.debit) : Number(l.credit), type: moneyIn ? "credit" : "debit", // register: credit = deposit, debit = withdrawal category: offsetName, coa_account_id: offsetId, cleared: false, journal_entry_line_id: l.id, }; }); if (regRows.length > 0) { const { error: regErr } = await supabase .from("transactions") .upsert(regRows, { onConflict: "journal_entry_line_id", ignoreDuplicates: true }); if (regErr) companyResult.errors.push(`register materialize (tx ${txId}): ${regErr.message}`); else companyResult.register_rows = (companyResult.register_rows ?? 0) + regRows.length; } } } catch (e: any) { companyResult.errors.push(`tx ${txId}: ${e?.message || String(e)}`); } } // ---- Flag unmapped Buildium accounts for the GL Account Map UI ---- if (unmappedGl.size > 0) { const flagRows = [...unmappedGl.entries()].map(([glId, meta]) => ({ association_id: company.association_id, buildium_gl_id: glId, buildium_name: meta?.Name ?? null, buildium_number: meta?.AccountNumber != null ? String(meta.AccountNumber) : null, buildium_type: meta?.Type || meta?.AccountType || null, context: "gl_sync", last_seen_at: new Date().toISOString(), })); const { error: flagErr } = await pub .from("buildium_unmapped_gl_accounts") .upsert(flagRows, { onConflict: "association_id,buildium_gl_id" }); if (flagErr) companyResult.errors.push(`flagging unmapped accounts failed: ${flagErr.message}`); companyResult.unmapped = flagRows.map((r) => ({ buildium_gl_id: r.buildium_gl_id, name: r.buildium_name, number: r.buildium_number })); } // ---- Advance the watermark ---- // STRICT mode: unmapped accounts hold their transactions, so keep the // old watermark until they're mapped — the next run re-pulls the same // window and the external_id dedupe skips whatever already landed. if (!dryRun && unmappedGl.size === 0) { const nextCfg = { ...cfg, buildium_gl: { ...(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); } else if (unmappedGl.size > 0) { companyResult.watermark_held = true; } // Import-mode: surface no-bill vendor payments (Buildium direct checks that // hit an expense account with no A/P leg) as record-only bills. post_bill_gl // no-ops for gl_auto_post=false companies, so this never touches the GL. if (!dryRun && company.gl_auto_post === false) { const { data: madeBills, error: billErr } = await supabase.schema("accounting").rpc("autocreate_nobill_vendor_bills", { _company_id: company.id }); if (billErr) companyResult.errors.push(`autocreate bills: ${billErr.message}`); else if (madeBills) companyResult.autobills_created = madeBills; } 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" } }); } });