mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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;
|
||||
Reference in New Issue
Block a user