Files
acmcc/supabase/functions/buildium-payee-backfill/index.ts
T
admin 3a7e08fb78 Accounting: buildium-payee-backfill edge function
Re-pulls Buildium GL transactions and backfills payor/payee names onto
imported journal_entries.description (matched by external_id). Vendor for
Bill/Check/EFT via PaymentDetail.Payee; owner for Charge/Payment/ApplyDeposit
resolved from UnitId via /v1/associations/owners. Idempotent; modes
sample/dry/apply. Used to backfill Village Woods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:12:00 -04:00

257 lines
12 KiB
TypeScript

// 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<string, string[]>();
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<string, string>();
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, string>): 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<string>();
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<string, any>();
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<string, string>();
const byType: Record<string, { total: number; named: number }> = {};
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);
}
});