-- Import-mode register double-count guard. -- -- Background: import-mode companies (gl_auto_post=false) get their GL from the -- Buildium GL pull (external_source='buildium_gl'). buildium-gl-sync materializes -- a bank-register row per GL bank line WITH journal_entry_line_id set, so those -- are recognized as "already in GL" and never re-post. But register rows created -- by an ad-hoc materialization (e.g. Village Woods' finishing step) were left -- UNLINKED (journal_entry_line_id null, exclude_from_gl false). They sat harmless -- until the "categorize register transactions from Buildium GL lines" op -- (buildium-payee-backfill partymap) assigned them a vendor/customer/coa — which -- gave post_transaction_gl a counter account and made it post them, DUPLICATING -- the Buildium GL pull (A/P, A/R and bank all double-counted). -- -- This migration: -- #1 Freezes any remaining unguarded register row whose exact bank movement -- already exists in buildium_gl, across ALL import-mode companies. Rows that -- are genuinely missing from Buildium (intentionally-posted manual items) -- have no matching buildium_gl line and are left postable. -- #2 Adds a backstop inside post_transaction_gl: it never posts a register row -- whose exact bank movement (bank account + date + amount + direction) is -- already recorded in buildium_gl — regardless of what a later categorization -- run assigns. This enforces the rule at the GL choke point (the ad-hoc -- apply step that writes party/coa onto the register has no committed home). -- -- Match key = (company, bank account_id, date, amount, direction). For a register -- row, type 'credit' = money in = bank debit; otherwise = money out = bank credit. -- Genuinely-missing manual items don't match (verified against Bent Oak's -- insurance/electric/water and Bridgewater's misc-income donations), so they keep -- posting. A coincidental same-account/date/amount/direction collision with an -- unrelated buildium_gl movement is rare and resolves conservatively (no double -- count); such a row can still be surfaced and posted as an explicit manual JE. create or replace function accounting.post_transaction_gl(_txn_id uuid) returns void language plpgsql security definer set search_path to 'public', 'accounting' as $function$ declare t accounting.transactions%rowtype; _counter uuid; _je uuid; _amt numeric; begin select * into t from accounting.transactions where id=_txn_id; if not found then return; end if; perform accounting._gl_clear(t.company_id, 'acmacc_txn', t.id::text); if coalesce(t.voided,false) then return; end if; if t.journal_entry_line_id is not null then return; end if; -- already in GL via Buildium pull if coalesce(t.exclude_from_gl,false) then return; end if; -- frozen legacy/duplicate register row if t.transfer_id is not null or t.deposit_id is not null then return; end if; if t.account_id is null then return; end if; _amt := coalesce(t.amount,0); if _amt = 0 then return; end if; -- Backstop: if buildium_gl already records this exact bank movement, this -- register row is a duplicate of the Buildium GL pull. Never double-post it, -- no matter what party/category a categorization run later assigns. if exists ( select 1 from accounting.journal_entries je join accounting.journal_entry_lines l on l.journal_entry_id = je.id where je.company_id = t.company_id and je.external_source = 'buildium_gl' and je.date = t.date and l.account_id = t.account_id and ((t.type = 'credit' and l.debit = _amt) or (t.type <> 'credit' and l.credit = _amt)) ) then return; end if; _counter := case when t.customer_id is not null then accounting.coa_ar(t.company_id) when t.coa_account_id is not null then t.coa_account_id when t.vendor_id is not null then accounting.coa_ap(t.company_id) else null end; if _counter is null then return; end if; insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) values (t.company_id, t.date, coalesce(nullif(t.description,''), 'Bank transaction'), t.reference, 'acmacc_txn', t.id::text) returning id into _je; if t.type = 'credit' then insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, t.account_id, _amt, 0, t.description), (_je, _counter, 0, _amt, t.description); else insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _counter, _amt, 0, t.description), (_je, t.account_id, 0, _amt, t.description); end if; end$function$; -- One-time cleanup: freeze remaining unguarded buildium-overlapping register rows -- across all import-mode companies. The UPDATE fires trg_acct_txn_gl, which clears -- any duplicate acmacc_txn JE these rows had posted. Idempotent (already-frozen and -- already-linked rows are skipped; intentionally-posted manual items don't match). update accounting.transactions t set exclude_from_gl = true from accounting.companies c where t.company_id = c.id and coalesce(c.gl_auto_post, true) = false and t.journal_entry_line_id is null and coalesce(t.exclude_from_gl, false) = false and t.deposit_id is null and t.transfer_id is null and t.account_id is not null and coalesce(t.voided, false) = false and exists ( select 1 from accounting.journal_entries je join accounting.journal_entry_lines l on l.journal_entry_id = je.id where je.company_id = t.company_id and je.external_source = 'buildium_gl' and je.date = t.date and l.account_id = t.account_id and ((t.type = 'credit' and l.debit = t.amount) or (t.type <> 'credit' and l.credit = t.amount)) );