mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
9aa1f94eb4
partymap mode now also resolves each Buildium transaction's dominant income/expense account (from its Journal lines) to a local category, staged alongside the party. Used to backfill accounting.transactions.category on the materialized bank register. Applied to Village Woods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
21 KiB
TypeScript
419 lines
21 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];
|
||
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);
|
||
// Local income/expense accounts by code → {id,name}, for the category.
|
||
const acctByCode = new Map<string, { id: string; name: string }>();
|
||
for (const a of (await supabase.schema("accounting").from("accounts").select("id,code,name,type").eq("company_id", companyId)).data ?? [])
|
||
if (a.code && (a.type === "income" || a.type === "expense")) acctByCode.set(String(a.code).trim(), { id: a.id, name: a.name });
|
||
|
||
const rows: any[] = [];
|
||
let vendorHits = 0, customerHits = 0, categoryHits = 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;
|
||
|
||
// Category: the dominant income/expense GL account across the journal
|
||
// lines (Buildium expands a check/bill/payment to its category lines).
|
||
const byCode = new Map<string, number>();
|
||
for (const ln of (tx?.Journal?.Lines ?? [])) {
|
||
const t = String(ln?.GLAccount?.Type ?? "");
|
||
if (t !== "Expense" && t !== "Income") continue;
|
||
const code = String(ln?.GLAccount?.AccountNumber ?? "").trim();
|
||
if (!code) continue;
|
||
byCode.set(code, (byCode.get(code) ?? 0) + Math.abs(Number(ln?.Amount) || 0));
|
||
}
|
||
let category_name: string | null = null;
|
||
let coa_account_id: string | null = null;
|
||
if (byCode.size) {
|
||
const topCode = [...byCode.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
||
const acct = acctByCode.get(topCode);
|
||
if (acct) { category_name = acct.name; coa_account_id = acct.id; }
|
||
}
|
||
|
||
if (vendor_id) vendorHits++;
|
||
if (customer_id) customerHits++;
|
||
if (coa_account_id) categoryHits++;
|
||
if (name || vendor_id || customer_id || coa_account_id)
|
||
rows.push({ company_id: companyId, external_id: id, party_name: name, vendor_id, customer_id, party_type: payeeType || null, category_name, coa_account_id });
|
||
}
|
||
// 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, categoryHits });
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
});
|