diff --git a/supabase/functions/buildium-payee-backfill/index.ts b/supabase/functions/buildium-payee-backfill/index.ts new file mode 100644 index 0000000..9c61074 --- /dev/null +++ b/supabase/functions/buildium-payee-backfill/index.ts @@ -0,0 +1,256 @@ +// 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); + } +});