From 5aef967b74409d9103b5ce3ca958455fa01a1cf7 Mon Sep 17 00:00:00 2001 From: renee-png Date: Sat, 13 Jun 2026 10:10:25 -0400 Subject: [PATCH] Buildium GL sync: keep import-mode bank registers current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- supabase/functions/buildium-gl-sync/index.ts | 55 +++++++++++++++++-- ...0_transactions_journal_entry_line_link.sql | 12 ++++ 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20260613120000_transactions_journal_entry_line_link.sql diff --git a/supabase/functions/buildium-gl-sync/index.ts b/supabase/functions/buildium-gl-sync/index.ts index 7f525ef..afa683b 100644 --- a/supabase/functions/buildium-gl-sync/index.ts +++ b/supabase/functions/buildium-gl-sync/index.ts @@ -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(); - for (const a of localAccounts || []) localById.set(a.id, a); + const bankIds = new Set(); + const acctNameById = new Map(); + 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)}`); } diff --git a/supabase/migrations/20260613120000_transactions_journal_entry_line_link.sql b/supabase/migrations/20260613120000_transactions_journal_entry_line_link.sql new file mode 100644 index 0000000..89d477d --- /dev/null +++ b/supabase/migrations/20260613120000_transactions_journal_entry_line_link.sql @@ -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);