From f7dc5d8177694032199bb5d23b13853cdcd27e20 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 3 Jun 2026 02:31:21 -0400 Subject: [PATCH] Buildium -> Accounting: auto-provision companies + opening-balance migration - buildium-sync now ensures every active association has an accounting company after syncing associations. Once it exists, the existing DB triggers flow units -> customers, owner ledger -> A/R + income, and bills -> A/P + expense into Accounting automatically (closing the gap where Buildium synced only the main dashboard, not Accounting). - New buildium-opening-balances function: fetches an association's Buildium GL trial balance as of a cutoff (default 2025-12-31, Accrual), maps GL accounts to accounting accounts (flagging bank accounts), rolls prior-year P&L into Retained Earnings, writes accounting.opening_balances, and posts the opening GL entry. Idempotent; service-role gated. Applied to 6 Buildium associations (opening balances + 2026 activity); all balance. New columns/data applied to the project directly. Co-Authored-By: Claude Opus 4.8 --- .../buildium-opening-balances/index.ts | 116 ++++++++++++++++++ supabase/functions/buildium-sync/index.ts | 25 +++- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 supabase/functions/buildium-opening-balances/index.ts diff --git a/supabase/functions/buildium-opening-balances/index.ts b/supabase/functions/buildium-opening-balances/index.ts new file mode 100644 index 0000000..24f6513 --- /dev/null +++ b/supabase/functions/buildium-opening-balances/index.ts @@ -0,0 +1,116 @@ +import { createClient } from 'npm:@supabase/supabase-js@2' + +// One-time (idempotent) opening-balance migration from Buildium. +// For a given association: fetches its general-ledger trial balance as of a +// cutoff date from Buildium, maps each GL account to an accounting account +// (creating/flagging bank accounts), rolls prior-year P&L into Retained +// Earnings, writes accounting.opening_balances, and posts the opening GL entry. +// Service-role gated. Activity (owner ledger + bills) on/after the cutoff is +// replayed separately so it does not double-count these opening balances. + +const BUILDIUM_BASE = 'https://api.buildium.com' +const cors = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, content-type' } +const J = (o: unknown, s = 200) => new Response(JSON.stringify(o, null, 2), { status: s, headers: { ...cors, 'Content-Type': 'application/json' } }) + +function claimsOf(token: string): Record | null { + const p = token.split('.'); if (p.length < 2) return null + try { return JSON.parse(atob(p[1].replaceAll('-', '+').replaceAll('_', '/').padEnd(Math.ceil(p[1].length / 4) * 4, '='))) } catch { return null } +} +async function bfetchAll(path: string, cid: string, cs: string, params: Record) { + const out: any[] = []; let offset = 0 + for (;;) { + const url = new URL(`${BUILDIUM_BASE}${path}`) + for (const [k,v] of Object.entries({ ...params, limit: '1000', offset: String(offset) })) url.searchParams.set(k, v) + const res = await fetch(url.toString(), { headers: { 'x-buildium-client-id': cid, 'x-buildium-client-secret': cs, Accept: 'application/json' } }) + if (!res.ok) throw new Error(`Buildium ${path} ${res.status}: ${(await res.text()).slice(0,300)}`) + const page = await res.json() + if (!Array.isArray(page) || page.length === 0) break + out.push(...page); offset += page.length + if (page.length < 1000) break + } + return out +} +const TYPE_MAP: Record = { Asset: 'asset', Liability: 'liability', Equity: 'equity', Income: 'income', Expense: 'expense' } + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response(null, { headers: cors }) + const auth = req.headers.get('Authorization') || '' + if (claimsOf(auth.replace('Bearer ', '').trim())?.role !== 'service_role') return J({ error: 'Forbidden' }, 403) + + const cid = Deno.env.get('BUILDIUM_API_KEY') ?? '', cs = Deno.env.get('BUILDIUM_API_SECRET') ?? '' + const sb = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!) + const acct = sb.schema('accounting') + const body = await req.json().catch(() => ({})) + const associationId: string = body.associationId + const asOfDate: string = body.asOfDate || '2025-12-31' + const basis: string = body.accountingBasis || 'Accrual' + if (!associationId) return J({ error: 'associationId required' }, 400) + + const { data: comp } = await acct.from('companies').select('id, name').eq('association_id', associationId).maybeSingle() + if (!comp) return J({ error: 'No accounting company for this association' }, 400) + const { data: assocRow } = await sb.from('associations').select('name').eq('id', associationId).maybeSingle() + const assocName = assocRow?.name || comp.name + + const buildiumAssocs = await bfetchAll('/v1/associations', cid, cs, {}) + const match = buildiumAssocs.find((a: any) => String(a?.Name || '').trim().toLowerCase() === String(assocName).trim().toLowerCase()) + if (!match?.Id) return J({ error: `No Buildium association matched name '${assocName}'` }, 404) + + const balances = await bfetchAll('/v1/glaccounts/balances', cid, cs, { + entityid: String(match.Id), entitytype: 'Association', asofdate: asOfDate, accountingbasis: basis, + }) + + const netCredit = new Map() + let netIncome = 0 + let reAccountId: string | null = null + const round2 = (n: number) => Math.round(n * 100) / 100 + + for (const b of balances as any[]) { + const gl = b.GLAccount || {} + const t = TYPE_MAP[String(gl.Type || '')] + if (!t) continue + const bal = Number(b.TotalBalance || 0) + if (t === 'income') { netIncome += bal; continue } + if (t === 'expense') { netIncome -= bal; continue } + const code = String(gl.AccountNumber ?? '').trim() || null + const name = String(gl.Name || `Buildium GL ${gl.Id}`) + const { data: accId, error: coaErr } = await acct.rpc('coa_get_or_create', { + _company_id: comp.id, _match: [name], _code: code, _name: name, _type: t, + }) + if (coaErr || !accId) continue + if (gl.IsBankAccount === true) await acct.from('accounts').update({ is_bank: true }).eq('id', accId) + const signedCredit = (t === 'asset') ? -bal : bal + netCredit.set(accId, round2((netCredit.get(accId) || 0) + signedCredit)) + if (!reAccountId && t === 'equity' && /retained earnings/i.test(name)) reAccountId = accId + } + + if (Math.abs(netIncome) > 0.005) { + if (!reAccountId) { + const { data: id } = await acct.rpc('coa_get_or_create', { _company_id: comp.id, _match: ['Retained Earnings'], _code: '3010', _name: 'Retained Earnings', _type: 'equity' }) + reAccountId = id as string + } + if (reAccountId) netCredit.set(reAccountId, round2((netCredit.get(reAccountId) || 0) + netIncome)) + } + + let net = 0; for (const v of netCredit.values()) net += v; net = round2(net) + if (Math.abs(net) > 0.005 && reAccountId) netCredit.set(reAccountId, round2((netCredit.get(reAccountId) || 0) - net)) + + await acct.from('opening_balances').delete().eq('company_id', comp.id) + const rows = [...netCredit.entries()] + .filter(([, v]) => Math.abs(v) > 0.005) + .map(([account_id, v]) => ({ company_id: comp.id, account_id, debit: v < 0 ? -v : 0, credit: v > 0 ? v : 0 })) + if (rows.length) { + const { error: insErr } = await acct.from('opening_balances').insert(rows) + if (insErr) return J({ error: 'opening_balances insert failed', detail: insErr.message }, 500) + } + await acct.from('opening_balances_setup').upsert({ company_id: comp.id, as_of_date: asOfDate, confirmed: true }, { onConflict: 'company_id' }) + const { error: postErr } = await acct.rpc('post_opening_balance_gl', { _company_id: comp.id }) + if (postErr) return J({ error: 'post_opening_balance_gl failed', detail: postErr.message }, 500) + + const totDr = rows.reduce((s, r) => s + r.debit, 0), totCr = rows.reduce((s, r) => s + r.credit, 0) + return J({ + company_id: comp.id, buildium_association_id: match.Id, as_of_date: asOfDate, + gl_accounts_from_buildium: balances.length, prior_year_net_income_rolled_to_RE: round2(netIncome), + opening_rows: rows.length, total_debit: round2(totDr), total_credit: round2(totCr), + balanced: Math.abs(round2(totDr - totCr)) < 0.005, + }) +}) diff --git a/supabase/functions/buildium-sync/index.ts b/supabase/functions/buildium-sync/index.ts index 5101953..393fcd2 100644 --- a/supabase/functions/buildium-sync/index.ts +++ b/supabase/functions/buildium-sync/index.ts @@ -656,7 +656,30 @@ Deno.serve(async (req) => { const { error } = await supabase.from("associations").upsert(upserts, { onConflict: "name", ignoreDuplicates: true }); if (error) throw new Error(`Associations upsert failed: ${error.message}`); } - results.associations = { fetched: associations.length, upserted: upserts.length }; + + // Ensure every active association has an accounting company. Once it exists, + // the existing DB triggers automatically flow units -> customers, owner + // ledger -> A/R + income, and bills -> A/P + expense into Accounting. + // (Opening balances are a separate one-time migration: buildium-opening-balances.) + let accountingCompaniesCreated = 0; + try { + const { data: activeAssocs } = await supabase.from("associations").select("id, name").eq("status", "active"); + const acctSchema = (supabase as any).schema("accounting"); + const { data: existingCompanies } = await acctSchema.from("companies").select("association_id"); + const haveCompany = new Set((existingCompanies || []).map((c: any) => c.association_id)); + const toCreate = (activeAssocs || []).filter((a: any) => a.id && !haveCompany.has(a.id)); + if (toCreate.length > 0) { + const { error: compErr } = await acctSchema.from("companies").insert( + toCreate.map((a: any) => ({ association_id: a.id, name: a.name || "Association", created_by: userId })) + ); + if (compErr) console.warn(`Accounting company provisioning failed: ${compErr.message}`); + else accountingCompaniesCreated = toCreate.length; + } + } catch (e) { + console.warn("Accounting company provisioning error:", (e as Error)?.message); + } + + results.associations = { fetched: associations.length, upserted: upserts.length, accounting_companies_created: accountingCompaniesCreated }; } if (syncType === "all" || syncType === "units") {