mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Buildium GL account map: strict account links, separate pull/push charges
- buildium_gl_account_links + buildium_unmapped_gl_accounts tables (strict, hold-and-flag) - buildium-sync: pull charges syncType, account-first push resolution, no fuzzy matching, push dryRun - buildium-gl-sync: links-only resolution, watermark held while unmapped accounts exist - GL Account Map settings tab + Pull Charges card + unmapped results panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,11 @@
|
||||
//
|
||||
// Pull-only by design: nothing is written back to Buildium, and transactions
|
||||
// edited or deleted in Buildium after they were pulled are NOT reconciled.
|
||||
//
|
||||
// Account resolution is STRICT: Buildium GL accounts must be explicitly linked
|
||||
// to local accounts via public.buildium_gl_account_links (Buildium settings →
|
||||
// GL Account Map). Unmapped accounts hold their transactions (watermark is not
|
||||
// advanced) and are flagged in public.buildium_unmapped_gl_accounts.
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
@@ -64,9 +69,6 @@ async function buildiumFetchAll(path: string, clientId: string, clientSecret: st
|
||||
}
|
||||
|
||||
const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
// Local account names sometimes carry a leading code ("2010 Prepayments");
|
||||
// strip it so they match Buildium's bare names.
|
||||
const normName = (v: unknown) => norm(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, ""));
|
||||
|
||||
function mapGLAccountType(type: string | null | undefined): string {
|
||||
const t = String(type || "").toLowerCase();
|
||||
@@ -176,7 +178,7 @@ Deno.serve(async (req) => {
|
||||
const bAssocIdByName = new Map<string, string>();
|
||||
for (const ba of buildiumAssocs) bAssocIdByName.set(norm(ba.Name), String(ba.Id));
|
||||
|
||||
// ---- Buildium chart of accounts (account-id resolution + auto-create) ----
|
||||
// ---- Buildium chart of accounts (account metadata for line signing) ----
|
||||
// /v1/glaccounts returns only top-level accounts as list items; children
|
||||
// are nested in each item's SubAccounts array. Flatten recursively.
|
||||
const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
||||
@@ -286,64 +288,33 @@ Deno.serve(async (req) => {
|
||||
if ((rows || []).length < 1000) break;
|
||||
}
|
||||
|
||||
// ---- Local account resolution maps ----
|
||||
// ---- Local account resolution: explicit links only ----
|
||||
// STRICT mapping: Buildium GL accounts resolve exclusively through
|
||||
// public.buildium_gl_account_links (seeded from the historical
|
||||
// accounts.external_id backfills). No code/name matching and no
|
||||
// auto-create — transactions touching an unmapped account are held,
|
||||
// flagged for the GL Account Map UI, and re-pulled once mapped.
|
||||
const { data: localAccounts, error: aErr } = await supabase
|
||||
.from("accounts")
|
||||
.select("id, code, name, type, external_source, external_id")
|
||||
.select("id, type")
|
||||
.eq("company_id", company.id);
|
||||
if (aErr) throw aErr;
|
||||
const byExternal = new Map<string, any>();
|
||||
const byCode = new Map<string, any>();
|
||||
const byName = new Map<string, any>();
|
||||
for (const a of localAccounts || []) {
|
||||
if (a.external_id) byExternal.set(String(a.external_id), a);
|
||||
if (a.code) byCode.set(norm(a.code), a);
|
||||
byName.set(normName(a.name), a);
|
||||
}
|
||||
const localById = new Map<string, any>();
|
||||
for (const a of localAccounts || []) localById.set(a.id, a);
|
||||
|
||||
async function resolveAccount(bGlId: string): Promise<{ id: string } | null> {
|
||||
const direct = byExternal.get(bGlId);
|
||||
if (direct) return direct;
|
||||
const meta = bGlById.get(bGlId);
|
||||
if (!meta) return null;
|
||||
const codeMatch = meta.AccountNumber ? byCode.get(norm(meta.AccountNumber)) : null;
|
||||
const nameMatch = byName.get(normName(meta.Name));
|
||||
const match = codeMatch || nameMatch || null;
|
||||
if (match) {
|
||||
// Backfill the Buildium id so future syncs resolve deterministically.
|
||||
if (!match.external_id && !dryRun) {
|
||||
await supabase.from("accounts").update({ external_source: "buildium", external_id: bGlId }).eq("id", match.id);
|
||||
}
|
||||
match.external_id = match.external_id || bGlId;
|
||||
byExternal.set(bGlId, match);
|
||||
return match;
|
||||
}
|
||||
if (dryRun) {
|
||||
// Would be auto-created in a real run; stub it so the dry run
|
||||
// reports the transaction as insertable rather than unmapped.
|
||||
const stub = { id: `dryrun-${bGlId}`, external_id: bGlId };
|
||||
byExternal.set(bGlId, stub);
|
||||
companyResult.accounts_created = (companyResult.accounts_created || 0) + 1;
|
||||
return stub;
|
||||
}
|
||||
// New account in Buildium — mirror it locally, like the import would.
|
||||
const { data: created, error: createErr } = await supabase
|
||||
.from("accounts")
|
||||
.insert({
|
||||
company_id: company.id,
|
||||
code: meta.AccountNumber ? String(meta.AccountNumber) : null,
|
||||
name: meta.Name || `Buildium account ${bGlId}`,
|
||||
type: mapGLAccountType(meta.Type || meta.AccountType),
|
||||
description: meta.Description || null,
|
||||
external_source: "buildium",
|
||||
external_id: bGlId,
|
||||
})
|
||||
.select("id, code, name, external_id")
|
||||
.single();
|
||||
if (createErr) throw createErr;
|
||||
byExternal.set(bGlId, created);
|
||||
companyResult.accounts_created = (companyResult.accounts_created || 0) + 1;
|
||||
return created;
|
||||
const { data: linkRows, error: lkErr } = await pub
|
||||
.from("buildium_gl_account_links")
|
||||
.select("buildium_gl_id, account_id")
|
||||
.eq("association_id", company.association_id);
|
||||
if (lkErr) throw lkErr;
|
||||
const linkByGlId = new Map<string, string>();
|
||||
for (const r of linkRows || []) linkByGlId.set(String(r.buildium_gl_id), r.account_id);
|
||||
|
||||
const unmappedGl = new Map<string, any>(); // buildium_gl_id -> Buildium meta
|
||||
function resolveAccount(bGlId: string): { id: string; type?: string } | null {
|
||||
const accountId = linkByGlId.get(bGlId);
|
||||
if (!accountId) return null;
|
||||
return localById.get(accountId) || { id: accountId };
|
||||
}
|
||||
|
||||
// ---- Insert new transactions as journal entries ----
|
||||
@@ -357,12 +328,15 @@ Deno.serve(async (req) => {
|
||||
const lineRows: { account_id: string; debit: number; credit: number; description: string | null }[] = [];
|
||||
let resolved = true;
|
||||
for (const l of tx.lines) {
|
||||
const acct = await resolveAccount(l.bGlId);
|
||||
const acct = resolveAccount(l.bGlId);
|
||||
if (!acct) {
|
||||
const meta = bGlById.get(l.bGlId);
|
||||
companyResult.errors.push(
|
||||
`tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"})`,
|
||||
);
|
||||
unmappedGl.set(String(l.bGlId), meta || null);
|
||||
if (companyResult.errors.length < 50) {
|
||||
companyResult.errors.push(
|
||||
`tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"}) — map it in Buildium settings → GL Account Map`,
|
||||
);
|
||||
}
|
||||
resolved = false;
|
||||
break;
|
||||
}
|
||||
@@ -432,8 +406,29 @@ Deno.serve(async (req) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Flag unmapped Buildium accounts for the GL Account Map UI ----
|
||||
if (unmappedGl.size > 0) {
|
||||
const flagRows = [...unmappedGl.entries()].map(([glId, meta]) => ({
|
||||
association_id: company.association_id,
|
||||
buildium_gl_id: glId,
|
||||
buildium_name: meta?.Name ?? null,
|
||||
buildium_number: meta?.AccountNumber != null ? String(meta.AccountNumber) : null,
|
||||
buildium_type: meta?.Type || meta?.AccountType || null,
|
||||
context: "gl_sync",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
}));
|
||||
const { error: flagErr } = await pub
|
||||
.from("buildium_unmapped_gl_accounts")
|
||||
.upsert(flagRows, { onConflict: "association_id,buildium_gl_id" });
|
||||
if (flagErr) companyResult.errors.push(`flagging unmapped accounts failed: ${flagErr.message}`);
|
||||
companyResult.unmapped = flagRows.map((r) => ({ buildium_gl_id: r.buildium_gl_id, name: r.buildium_name, number: r.buildium_number }));
|
||||
}
|
||||
|
||||
// ---- Advance the watermark ----
|
||||
if (!dryRun) {
|
||||
// STRICT mode: unmapped accounts hold their transactions, so keep the
|
||||
// old watermark until they're mapped — the next run re-pulls the same
|
||||
// window and the external_id dedupe skips whatever already landed.
|
||||
if (!dryRun && unmappedGl.size === 0) {
|
||||
const nextCfg = {
|
||||
...cfg,
|
||||
buildium_gl: {
|
||||
@@ -447,6 +442,8 @@ Deno.serve(async (req) => {
|
||||
},
|
||||
};
|
||||
await supabase.from("companies").update({ acmacc_sync_config: nextCfg }).eq("id", company.id);
|
||||
} else if (unmappedGl.size > 0) {
|
||||
companyResult.watermark_held = true;
|
||||
}
|
||||
companyResult.window = { since, until };
|
||||
} catch (e: any) {
|
||||
|
||||
Reference in New Issue
Block a user