Buildium GL sync: keep import-mode bank registers current

Root cause of 'syncs stop at 5/31': the nightly buildium-gl-sync writes journal
entries (so reports stay current) but never created the matching bank-register
transactions for import-mode companies (gl_auto_post=false). Those registers were
materialized once at import, so the reconciliation/register views froze ~5/31
while the GL kept advancing.

- Add accounting.transactions.journal_entry_line_id (FK + unique index) to link
  register rows to their source GL line, making materialization idempotent
- buildium-gl-sync now materializes a register transaction for each bank line it
  inserts, for import-mode companies only (bank debit -> deposit/credit,
  bank credit -> withdrawal/debit; category/coa from the single offset account),
  upserting on journal_entry_line_id so re-runs never double-insert
- One-time backfill (run against prod) filled the existing gap: 231 register
  rows across Bridgewater/Casuarina/Village Grove/Bent Oak, skipping 3 near-miss
  rows that share a day with an existing register row (incl. a Casuarina transfer)
  for manual review

gl_managed companies are unaffected — their register drives the GL via
post_transaction_gl, not the other way around.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:10:25 -04:00
parent 42475a0e93
commit 5aef967b74
2 changed files with 62 additions and 5 deletions
+50 -5
View File
@@ -164,7 +164,7 @@ Deno.serve(async (req) => {
// ---- Companies in scope: Buildium-managed = has buildium_gl entries ----
const { data: companies, error: cErr } = await supabase
.from("companies")
.select("id, name, association_id, acmacc_sync_config")
.select("id, name, association_id, acmacc_sync_config, gl_auto_post")
.not("association_id", "is", null);
if (cErr) throw cErr;
@@ -331,11 +331,23 @@ Deno.serve(async (req) => {
// flagged for the GL Account Map UI, and re-pulled once mapped.
const { data: localAccounts, error: aErr } = await supabase
.from("accounts")
.select("id, type")
.select("id, type, is_bank, name")
.eq("company_id", company.id);
if (aErr) throw aErr;
const localById = new Map<string, any>();
for (const a of localAccounts || []) localById.set(a.id, a);
const bankIds = new Set<string>();
const acctNameById = new Map<string, string>();
for (const a of localAccounts || []) {
localById.set(a.id, a);
acctNameById.set(a.id, a.name);
if (a.is_bank) bankIds.add(a.id);
}
// Import-mode companies (gl_auto_post=false) don't post Banking entries
// to the GL; their bank registers are fed only from the GL pull. Mirror
// each inserted bank line into accounting.transactions so the registers
// and reconciliation stay current. gl_managed companies skip this — the
// register is their source of truth and post_transaction_gl drives the GL.
const materializeRegister = company.gl_auto_post === false;
const { data: linkRows, error: lkErr } = await pub
.from("buildium_gl_account_links")
@@ -427,15 +439,48 @@ Deno.serve(async (req) => {
}
throw jeErr;
}
const { error: lErr } = await supabase
const { data: insertedLines, error: lErr } = await supabase
.from("journal_entry_lines")
.insert(lineRows.map((l) => ({ ...l, journal_entry_id: je.id })));
.insert(lineRows.map((l) => ({ ...l, journal_entry_id: je.id })))
.select("id, account_id, debit, credit");
if (lErr) {
// Don't leave a headerless entry behind.
await supabase.from("journal_entries").delete().eq("id", je.id);
throw lErr;
}
companyResult.inserted += 1;
// ---- Materialize bank register rows (import-mode companies) ----
if (materializeRegister) {
const lines = insertedLines || [];
const offsetIds = [...new Set(lines.filter((l: any) => !bankIds.has(l.account_id)).map((l: any) => l.account_id))];
const offsetId = offsetIds.length === 1 ? offsetIds[0] : null;
const offsetName = offsetId ? (acctNameById.get(offsetId) ?? null) : null;
const regRows = lines
.filter((l: any) => bankIds.has(l.account_id))
.map((l: any) => {
const moneyIn = Number(l.debit) > 0; // bank debit = deposit (money in)
return {
company_id: company.id,
account_id: l.account_id,
date: tx.date || today,
description: tx.description || "Buildium GL",
amount: moneyIn ? Number(l.debit) : Number(l.credit),
type: moneyIn ? "credit" : "debit", // register: credit = deposit, debit = withdrawal
category: offsetName,
coa_account_id: offsetId,
cleared: false,
journal_entry_line_id: l.id,
};
});
if (regRows.length > 0) {
const { error: regErr } = await supabase
.from("transactions")
.upsert(regRows, { onConflict: "journal_entry_line_id", ignoreDuplicates: true });
if (regErr) companyResult.errors.push(`register materialize (tx ${txId}): ${regErr.message}`);
else companyResult.register_rows = (companyResult.register_rows ?? 0) + regRows.length;
}
}
} catch (e: any) {
companyResult.errors.push(`tx ${txId}: ${e?.message || String(e)}`);
}
@@ -0,0 +1,12 @@
-- Link bank-register transactions back to the GL line that produced them, so
-- the Buildium GL pull can materialize register rows idempotently for
-- import-mode companies (gl_auto_post = false). NULL = manually-entered or
-- legacy register row with no GL source. NULLs are distinct, so many unlinked
-- rows coexist; the unique index only prevents materializing the same GL line
-- into the register twice (and is usable as an ON CONFLICT target).
alter table accounting.transactions
add column if not exists journal_entry_line_id uuid
references accounting.journal_entry_lines(id) on delete set null;
create unique index if not exists transactions_jel_id_uidx
on accounting.transactions(journal_entry_line_id);