// buildium-account-categories // Pulls each GL account's parent-category from Buildium's chart hierarchy and // writes it onto accounting.accounts.category, which drives the Income Statement // subgroups (Operating Income, Administration, Utilities, …). Matched by account // code, falling back to name. Buildium's GL chart is shared across associations, // so a single fetch maps code → category for every company. // // Body (all optional): { dryRun?: boolean, overwrite?: boolean } // dryRun — compute and report, write nothing (default false) // overwrite — replace existing categories too (default true); false only fills blanks 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", }; const BUILDIUM_BASE = "https://api.buildium.com"; function wait(ms: number) { return new Promise((r) => setTimeout(r, ms)); } function norm(s: unknown) { return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); } function stripCode(name: unknown) { return String(name ?? "").replace(/^\s*\d+[\s.\-]*/, "").trim(); } function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record) { const url = new URL(`${BUILDIUM_BASE}${path}`); if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); 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) { await wait(600 * Math.pow(2, attempt)); continue; } throw new Error(`Buildium ${path} failed [${res.status}]: ${text}`); } throw new Error(`Buildium ${path} failed after retries`); } async function buildiumFetchAll(path: string, clientId: string, clientSecret: string) { const all: any[] = []; let offset = 0; const limit = 50; while (true) { const page = await buildiumFetch(path, clientId, clientSecret, { offset: String(offset), limit: String(limit) }); if (!Array.isArray(page) || page.length === 0) break; all.push(...page); if (page.length < limit) break; offset += limit; } return all; } function glCode(node: any): string { return String(node?.AccountNumber ?? node?.Number ?? node?.GlNumber ?? node?.GLNumber ?? node?.Code ?? "").trim(); } Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); try { const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!); const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? ""; const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? ""; if (!clientId || !clientSecret) return json({ error: "Buildium API credentials not configured" }, 500); const body = await req.json().catch(() => ({} as any)); const dryRun = body?.dryRun === true; const overwrite = body?.overwrite !== false; // default true // 1) Pull the GL chart (nested) and map each leaf → its immediate parent's name. const roots = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret); const byCode = new Map(); const byName = new Map(); const walk = (parentName: string | null, node: any) => { const name = node?.Name ? String(node.Name) : ""; if (parentName) { const code = glCode(node); if (code) byCode.set(code, parentName); if (name) byName.set(norm(name), parentName); } const subs = node?.SubAccounts; if (Array.isArray(subs)) for (const s of subs) walk(name || parentName, s); }; for (const r of roots) walk(null, r); // 2) Apply to accounting.accounts (income/expense only). const { data: accts, error } = await supabase.schema("accounting") .from("accounts").select("id,code,name,type,category").in("type", ["income", "expense"]); if (error) throw error; let matched = 0; const updates: { id: string; category: string }[] = []; for (const a of accts ?? []) { const cat = (a.code && byCode.get(String(a.code).trim())) || byName.get(norm(stripCode(a.name))); if (!cat) continue; matched++; if (!overwrite && a.category) continue; if (a.category === cat) continue; updates.push({ id: a.id, category: cat }); } let updated = 0; if (!dryRun) { for (const u of updates) { const { error: ue } = await supabase.schema("accounting").from("accounts").update({ category: u.category }).eq("id", u.id); if (ue) throw ue; updated++; } } return json({ success: true, dryRun, overwrite, glAccountsFetched: roots.length, mappedCodes: byCode.size, accountsScanned: (accts ?? []).length, matched, toUpdate: updates.length, updated, categories: [...new Set(updates.map((u) => u.category))].sort(), }); } catch (e: any) { return json({ error: String(e?.message ?? e) }, 500); } });