mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
4f0ac97e83
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>
123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
// 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);
|
|
}
|
|
});
|