Files
acmcc/supabase/migrations/20260616160000_import_register_dup_guard.sql
T
admin 7f5d21c398 Accounting: guard import-mode register rows against GL double-counting
post_transaction_gl now skips posting a register row whose exact bank
movement (company + bank account + date + amount + direction) is already
in buildium_gl, so categorizing an ad-hoc-materialized register row can no
longer duplicate the Buildium GL pull. Also freezes (exclude_from_gl) any
remaining unguarded buildium-overlapping register rows across all
import-mode companies. Genuinely-missing manual items (no GL match) still
post normally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:03:40 -04:00

105 lines
5.7 KiB
PL/PgSQL

-- 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))
);