Income Statement: Buildium-style category subgroups

Group the multi-period Income Statement by account category (Operating Income,
Administration, Utilities, Reserves Budget, …) with "Total for <category>"
subtotals, matching the Buildium layout, in the on-screen table, PDF, and CSV.

- New accounting.accounts.category column (nullable; null = ungrouped), seeded
  from the local chart_of_accounts parent hierarchy.
- Editable in Chart of Accounts: single-edit (with datalist autocomplete) and
  bulk-edit (blank = no change, __clear__ to unset).
- buildium-account-categories edge function pulls each account's parent-GL
  category from Buildium (matched by code, fallback name) and backfills
  accounting.accounts.category; idempotent and re-runnable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:34:58 -04:00
parent cb8a29696f
commit 4f0ac97e83
4 changed files with 272 additions and 57 deletions
@@ -0,0 +1,122 @@
// buildium-account-categories
// Pulls each GL account's parent-category from Buildium's chart hierarchy and
// writes it onto accounting.accounts.category, which drives the Income Statement
// subgroups (Operating Income, Administration, Utilities, …). Matched by account
// code, falling back to name. Buildium's GL chart is shared across associations,
// so a single fetch maps code → category for every company.
//
// Body (all optional): { dryRun?: boolean, overwrite?: boolean }
// dryRun — compute and report, write nothing (default false)
// overwrite — replace existing categories too (default true); false only fills blanks
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";
function wait(ms: number) { return new Promise((r) => setTimeout(r, ms)); }
function norm(s: unknown) { return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); }
function stripCode(name: unknown) { return String(name ?? "").replace(/^\s*\d+[\s.\-]*/, "").trim(); }
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record<string, string>) {
const url = new URL(`${BUILDIUM_BASE}${path}`);
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
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) { await wait(600 * Math.pow(2, attempt)); continue; }
throw new Error(`Buildium ${path} failed [${res.status}]: ${text}`);
}
throw new Error(`Buildium ${path} failed after retries`);
}
async function buildiumFetchAll(path: string, clientId: string, clientSecret: string) {
const all: any[] = [];
let offset = 0; const limit = 50;
while (true) {
const page = await buildiumFetch(path, clientId, clientSecret, { offset: String(offset), limit: String(limit) });
if (!Array.isArray(page) || page.length === 0) break;
all.push(...page);
if (page.length < limit) break;
offset += limit;
}
return all;
}
function glCode(node: any): string {
return String(node?.AccountNumber ?? node?.Number ?? node?.GlNumber ?? node?.GLNumber ?? node?.Code ?? "").trim();
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, 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 dryRun = body?.dryRun === true;
const overwrite = body?.overwrite !== false; // default true
// 1) Pull the GL chart (nested) and map each leaf → its immediate parent's name.
const roots = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
const byCode = new Map<string, string>();
const byName = new Map<string, string>();
const walk = (parentName: string | null, node: any) => {
const name = node?.Name ? String(node.Name) : "";
if (parentName) {
const code = glCode(node);
if (code) byCode.set(code, parentName);
if (name) byName.set(norm(name), parentName);
}
const subs = node?.SubAccounts;
if (Array.isArray(subs)) for (const s of subs) walk(name || parentName, s);
};
for (const r of roots) walk(null, r);
// 2) Apply to accounting.accounts (income/expense only).
const { data: accts, error } = await supabase.schema("accounting")
.from("accounts").select("id,code,name,type,category").in("type", ["income", "expense"]);
if (error) throw error;
let matched = 0;
const updates: { id: string; category: string }[] = [];
for (const a of accts ?? []) {
const cat = (a.code && byCode.get(String(a.code).trim())) || byName.get(norm(stripCode(a.name)));
if (!cat) continue;
matched++;
if (!overwrite && a.category) continue;
if (a.category === cat) continue;
updates.push({ id: a.id, category: cat });
}
let updated = 0;
if (!dryRun) {
for (const u of updates) {
const { error: ue } = await supabase.schema("accounting").from("accounts").update({ category: u.category }).eq("id", u.id);
if (ue) throw ue;
updated++;
}
}
return json({
success: true, dryRun, overwrite,
glAccountsFetched: roots.length,
mappedCodes: byCode.size,
accountsScanned: (accts ?? []).length,
matched, toUpdate: updates.length, updated,
categories: [...new Set(updates.map((u) => u.category))].sort(),
});
} catch (e: any) {
return json({ error: String(e?.message ?? e) }, 500);
}
});
@@ -0,0 +1,22 @@
-- Income-statement subgroups (Buildium-style): each income/expense account can
-- carry a free-text category ("Operating Income", "Administration", "Utilities",
-- "Reserves Budget", …). The Income Statement groups accounts under it with a
-- "Total for <category>" subtotal. Sourced from Buildium's parent-GL hierarchy
-- and editable in the Chart of Accounts. Null = ungrouped.
alter table accounting.accounts
add column if not exists category text;
-- Seed from the local chart_of_accounts parent hierarchy where it exists
-- (a partial first pass — mostly a few income groups; the Buildium pull fills the rest).
update accounting.accounts a
set category = parent.account_name
from accounting.companies c
join public.chart_of_accounts coa
on coa.association_id = c.association_id
join public.chart_of_accounts parent
on parent.id = coa.parent_account_id
where a.company_id = c.id
and coa.account_number = a.code
and a.type in ('income','expense')
and a.category is null
and parent.account_name is not null;