// buildium-payee-backfill // One-off-style backfill: the GL CSV/pull imports only captured Buildium's // per-entry Description (the transaction *type* — "Charge", "Payment", "Bill", // "Check 1153"), so imported journal_entries carry no payor/payee. This pulls // Buildium's GL *transactions* for a company's window, extracts the party name // (vendor for bills/checks, owner/unit/tenant for charges/payments), and writes // it onto accounting.journal_entries.description, matched by external_id (the // Buildium transaction id). // // Body: // companyId (required) accounting.companies.id // dateFrom (required) ISO date, inclusive // dateTo (optional) ISO date, inclusive (default today) // mode "sample" | "dry" | "apply" (default "dry") // sample — return raw Buildium GL-transaction JSON, write nothing // dry — compute new descriptions + counts, write nothing // apply — update journal_entries.description // sampleSize (sample mode) number of raw transactions to return (default 5) 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"; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); const json = (b: unknown, s = 200) => new Response(JSON.stringify(b), { status: s, headers: { ...corsHeaders, "Content-Type": "application/json" } }); 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); offset += page.length; } return all; } // Build unitId → owner name(s) for one association. /v1/associations/owners // returns owners with an OwnershipAccounts array; each entry ties the owner to // a UnitId within a specific AssociationId. A unit can have co-owners, so we // collect unique names and join up to two. async function buildOwnerByUnit(clientId: string, clientSecret: string, bAssocId: string) { const params = new URLSearchParams(); params.append("associationids", String(bAssocId)); const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret, params); const namesByUnit = new Map(); for (const o of owners) { const name = [o?.FirstName, o?.LastName].filter(Boolean).join(" ").trim(); if (!name) continue; for (const oa of o?.OwnershipAccounts ?? []) { if (String(oa?.AssociationId ?? "") !== String(bAssocId)) continue; const uid = String(oa?.UnitId ?? ""); if (!uid) continue; const arr = namesByUnit.get(uid) ?? []; if (!arr.includes(name)) arr.push(name); namesByUnit.set(uid, arr); } } const ownerByUnit = new Map(); for (const [uid, arr] of namesByUnit) ownerByUnit.set(uid, arr.slice(0, 2).join(" & ")); return ownerByUnit; } // Extract the party name from a Buildium GL transaction. // • Bill / Check / EFT / many Payments expose PaymentDetail.Payee.Name // (vendor or owner) directly. // • Owner-side Charge / ApplyDeposit carry only UnitId → resolve to owner name. // • Fall back to the raw UnitNumber so something identifying still shows. function extractPayee(tx: any, ownerByUnit: Map): string | null { const direct = tx?.PaymentDetail?.Payee?.Name; if (typeof direct === "string" && direct.trim()) return direct.trim(); const uid = tx?.UnitId != null ? String(tx.UnitId) : ""; if (uid && ownerByUnit.has(uid)) return ownerByUnit.get(uid)!; const unitNo = tx?.UnitNumber; if (typeof unitNo === "string" && unitNo.trim()) return `Unit ${unitNo.trim()}`; return null; } 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 json({ error: "Unauthorized" }, 401); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const serviceKey = 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 companyId = String(body?.companyId ?? ""); const dateFrom = String(body?.dateFrom ?? ""); const dateTo = String(body?.dateTo ?? new Date().toISOString().slice(0, 10)); const mode = String(body?.mode ?? "dry"); const sampleSize = Number(body?.sampleSize ?? 5); if (!companyId || !dateFrom) return json({ error: "companyId and dateFrom are required" }, 400); const supabase = createClient(supabaseUrl, serviceKey); // Resolve the Buildium association by the local association's name. const { data: company, error: cErr } = await supabase.schema("accounting") .from("companies").select("id, name, association_id").eq("id", companyId).maybeSingle(); if (cErr) throw cErr; if (!company) return json({ error: "company not found" }, 404); const { data: assoc } = await supabase.from("associations").select("id, name").eq("id", company.association_id).maybeSingle(); const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret); const bAssocId = buildiumAssocs.find((a: any) => norm(a.Name) === norm(assoc?.name))?.Id; if (!bAssocId) return json({ error: `No Buildium association matches "${assoc?.name}"` }, 400); // /v1/generalledger/transactions requires glaccountids. Gather every GL // account id for the chart (children nest under SubAccounts) plus inactive // bank GL accounts, then chunk them into the transactions call (the URL // can't hold the whole chart at once). Dedupe transactions by Id. const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret); const glIds = new Set(); const addGl = (g: any) => { if (!g?.Id) return; glIds.add(String(g.Id)); if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) addGl(s); }; for (const g of glAccounts) addGl(g); for (const b of await buildiumFetchAll("/v1/bankaccounts", clientId, clientSecret)) { const gid = String(b.GLAccount?.Id ?? b.Id ?? ""); if (gid) glIds.add(gid); } const allGlIds = [...glIds]; // Pull GL transactions for the window. /v1/generalledger/transactions is the // Journal view — one row per transaction with its Id, type and party data. const txById = new Map(); const CHUNK = 50; for (let i = 0; i < allGlIds.length; i += CHUNK) { const params = new URLSearchParams(); params.set("startdate", dateFrom); params.set("enddate", dateTo); params.set("entitytype", "Association"); params.set("entityid", String(bAssocId)); for (const id of allGlIds.slice(i, i + CHUNK)) params.append("glaccountids", id); const page = await buildiumFetchAll("/v1/generalledger/transactions", clientId, clientSecret, params); for (const tx of page) { const id = String(tx?.Id ?? ""); if (id) txById.set(id, tx); } } const txns = [...txById.values()]; if (mode === "sample") { const ownersParams = new URLSearchParams(); ownersParams.append("associationids", String(bAssocId)); const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret, ownersParams).catch((e) => [{ ownersError: String(e) }]); return json({ mode, company: company.name, bAssocId, window: [dateFrom, dateTo], pulled: txns.length, ownersCount: Array.isArray(owners) ? owners.length : 0, ownersSample: (owners || []).slice(0, 3), sample: txns.slice(0, sampleSize), }); } const ownerByUnit = await buildOwnerByUnit(clientId, clientSecret, String(bAssocId)); // Map Buildium tx id → extracted party name. const payeeById = new Map(); const byType: Record = {}; for (const tx of txns) { const id = String(tx?.Id ?? ""); if (!id) continue; const type = String(tx?.TransactionType ?? "?"); byType[type] ??= { total: 0, named: 0 }; byType[type].total++; const name = extractPayee(tx, ownerByUnit); if (name) { payeeById.set(id, name); byType[type].named++; } } // Load this company's imported entries and compute the new description. const entries: { id: string; external_id: string; description: string }[] = []; for (let offset = 0; ; offset += 1000) { const { data: rows, error } = await supabase.schema("accounting") .from("journal_entries") .select("id, external_id, description") .eq("company_id", companyId) .eq("external_source", "buildium_gl") .order("id", { ascending: true }) .range(offset, offset + 999); if (error) throw error; for (const r of rows || []) entries.push(r as any); if ((rows || []).length < 1000) break; } const updates: { id: string; before: string; after: string }[] = []; let unmatched = 0; for (const e of entries) { const name = e.external_id ? payeeById.get(String(e.external_id)) : undefined; if (!name) { unmatched++; continue; } const base = String(e.description ?? "").trim(); // Don't double-append if a previous run already added the name. if (base.includes(name)) continue; const after = base ? `${base} · ${name}` : name; if (after !== base) updates.push({ id: e.id, before: base, after }); } let updated = 0; if (mode === "apply") { for (const u of updates) { const { error } = await supabase.schema("accounting") .from("journal_entries").update({ description: u.after }).eq("id", u.id); if (error) throw error; updated++; } } return json({ mode, company: company.name, window: [dateFrom, dateTo], pulledTxns: txns.length, unitsWithOwner: ownerByUnit.size, txnsWithName: payeeById.size, byType, entries: entries.length, unmatched, toUpdate: updates.length, updated, examples: updates.slice(0, 15), }); } catch (e: any) { return json({ error: String(e?.message ?? e) }, 500); } });