Files
acmcc/supabase/functions/buildium-payee-backfill/index.ts
T
admin 0faee9994d Accounting: partymap mode + register payee backfill
Adds "partymap" mode to buildium-payee-backfill: resolves each Buildium GL
transaction to a local vendor (by name) / customer (by unit_number) and stages
it in accounting.buildium_party_map. The materialized bank register
(accounting.transactions) is then backfilled in SQL by bridging each register
row to its journal-entry bank line (bijective row-number pairing within
date/amount/side groups, so same-amount/same-day payments each get a distinct
owner) and pulling the resolved vendor/customer + name from staging.

Applied to all five Buildium-imported associations (Village Woods, Bent Oak,
Village Grove, Bridgewater, Casuarina).

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

399 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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];
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<string, { number: string; name: string; type: string }>();
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<string, number>();
const txnIds = new Set<string>();
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<string, { number: string; name: string }>();
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<string, any>();
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++; }
}
// 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<string, string>();
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<string, string>();
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);
}
});