// 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]; const CHUNK = 50; // report mode: pull /v1/generalledger (signed amounts, debit +/credit −) and // sum net movement per account for the window, to diff against our books. if (mode === "report") { const glMeta = new Map(); const collectMeta = (g: any) => { if (g?.Id) glMeta.set(String(g.Id), { number: String(g.AccountNumber ?? ""), name: String(g.Name ?? ""), type: String(g.Type ?? "") }); if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) collectMeta(s); }; for (const g of glAccounts) collectMeta(g); const netById = new Map(); const txnIds = new Set(); for (let i = 0; i < allGlIds.length; i += CHUNK) { const params = new URLSearchParams(); params.set("accountingbasis", "Accrual"); 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 ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params); for (const ledger of ledgers) { const gid = String(ledger.GLAccountId ?? ledger.GLAccount?.Id ?? ""); for (const e of ledger.Entries ?? []) { netById.set(gid, (netById.get(gid) ?? 0) + (Number(e.Amount) || 0)); if (e.Id) txnIds.add(String(e.Id)); } } } const rows = [...netById.entries()] .map(([gid, net]) => ({ ...(glMeta.get(gid) ?? { number: "", name: gid, type: "" }), net: Math.round(net * 100) / 100 })) .filter((r) => Math.abs(r.net) > 0.005) .sort((a, b) => a.number.localeCompare(b.number)); return json({ mode, company: company.name, window: [dateFrom, dateTo], distinctTxns: txnIds.size, accounts: rows.length, rows }); } // account mode: list every /v1/generalledger entry for a single account // (by AccountNumber) in the window — used to chase per-account discrepancies. if (mode === "account") { const wantNum = String(body?.accountNumber ?? ""); let gid = ""; const findGl = (g: any) => { if (String(g?.AccountNumber ?? "") === wantNum) gid = String(g.Id); if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) findGl(s); }; for (const g of glAccounts) findGl(g); if (!gid) return json({ error: `account number ${wantNum} not found in Buildium chart` }, 404); const params = new URLSearchParams(); params.set("accountingbasis", "Accrual"); params.set("startdate", dateFrom); params.set("enddate", dateTo); params.set("entitytype", "Association"); params.set("entityid", String(bAssocId)); params.append("glaccountids", gid); const ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params); const entries: any[] = []; let net = 0; for (const ledger of ledgers) for (const e of ledger.Entries ?? []) { net += Number(e.Amount) || 0; entries.push({ txnId: String(e.Id ?? ""), date: String(e.Date ?? "").split("T")[0], type: e.TransactionType, amount: Number(e.Amount) || 0, memo: e.Memo ?? e.Description ?? "" }); } entries.sort((a, b) => a.date.localeCompare(b.date)); return json({ mode, accountNumber: wantNum, gid, window: [dateFrom, dateTo], net: Math.round(net * 100) / 100, count: entries.length, entries }); } // txn mode: reconstruct one transaction's full double-entry by scanning the // ledger across all accounts for entries carrying its id. if (mode === "txn") { const wantId = String(body?.txnId ?? ""); const numByGid = new Map(); const collect = (g: any) => { if (g?.Id) numByGid.set(String(g.Id), { number: String(g.AccountNumber ?? ""), name: String(g.Name ?? "") }); if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) collect(s); }; for (const g of glAccounts) collect(g); const lines: any[] = []; for (let i = 0; i < allGlIds.length; i += CHUNK) { const params = new URLSearchParams(); params.set("accountingbasis", "Accrual"); 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 ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params); for (const ledger of ledgers) { const gid = String(ledger.GLAccountId ?? ledger.GLAccount?.Id ?? ""); for (const e of ledger.Entries ?? []) { if (String(e.Id ?? "") !== wantId) continue; const m = numByGid.get(gid) ?? { number: "", name: gid }; lines.push({ account: `${m.number} ${m.name}`.trim(), amount: Number(e.Amount) || 0, date: String(e.Date ?? "").split("T")[0], type: e.TransactionType, memo: e.Memo ?? e.Description ?? "" }); } } } const sum = Math.round(lines.reduce((s, l) => s + l.amount, 0) * 100) / 100; return json({ mode, txnId: wantId, balanceCheck: sum, lineCount: lines.length, lines }); } // 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(); 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++; } } // partymap mode: resolve each Buildium tx to a LOCAL vendor/customer id and // stage it (accounting.buildium_party_map) so the materialized bank register // (accounting.transactions) can be backfilled in SQL by bridging tx→JE. // vendor : Payee.Type=Vendor → match Payee.Name to accounting.vendors.name // customer : tx.UnitNumber → match to accounting.customers.unit_number if (mode === "partymap") { const vendorByName = new Map(); for (const v of (await supabase.schema("accounting").from("vendors").select("id,name").eq("company_id", companyId)).data ?? []) if (v.name) vendorByName.set(norm(v.name), v.id); const customerByUnitNo = new Map(); for (const c of (await supabase.schema("accounting").from("customers").select("id,unit_number").eq("company_id", companyId)).data ?? []) if (c.unit_number) customerByUnitNo.set(norm(c.unit_number), c.id); const rows: any[] = []; let vendorHits = 0, customerHits = 0; for (const tx of txns) { const id = String(tx?.Id ?? ""); if (!id) continue; const name = payeeById.get(id) ?? null; const payeeType = String(tx?.PaymentDetail?.Payee?.Type ?? ""); let vendor_id: string | null = null; let customer_id: string | null = null; if (payeeType === "Vendor" && name) vendor_id = vendorByName.get(norm(name)) ?? null; const unitNo = tx?.UnitNumber; if (!vendor_id && typeof unitNo === "string" && unitNo.trim()) customer_id = customerByUnitNo.get(norm(unitNo)) ?? null; // owner payment with no UnitNumber: try the payee name against customers if (!vendor_id && !customer_id && name && payeeType && payeeType !== "Vendor") { // fall through — name match handled in SQL if needed; leave null here } if (vendor_id) vendorHits++; if (customer_id) customerHits++; if (name || vendor_id || customer_id) rows.push({ company_id: companyId, external_id: id, party_name: name, vendor_id, customer_id, party_type: payeeType || null }); } // Upsert in batches. let staged = 0; for (let i = 0; i < rows.length; i += 500) { const batch = rows.slice(i, i + 500); const { error } = await supabase.schema("accounting").from("buildium_party_map").upsert(batch, { onConflict: "company_id,external_id" }); if (error) throw error; staged += batch.length; } return json({ mode, company: company.name, window: [dateFrom, dateTo], txns: txns.length, staged, vendorHits, customerHits }); } // 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); } });