-- Per-association Chart of Accounts. -- -- Today public.chart_of_accounts shares one row across many associations via the -- association_ids[] array (buildium system: 94 rows shared by up to 13 associations). -- Editing a number for one association edits the row the others use. This migration -- gives every association its own row: it splits each shared buildium row into one -- row per association in its association_ids, repoints all references by each -- referencing record's association, and swaps the uniqueness to be per-association. -- -- Nothing is deleted: the original shared row simply becomes the per-association row -- for its own association_id; clones are added for the other associations. So no -- foreign key can dangle. association_ids is kept as a single-element mirror of -- association_id during the transition (existing `association_ids @> [assoc]` -- callers keep working); drop it in a later cleanup pass. -- -- Pre-audited on live data: association_id is always in association_ids; account -- numbers are globally unique within buildium (so no per-association collisions); -- all buildium parents are buildium rows; every reference's association is genuinely -- in its target row's array (zero mismatches). One row carries a stray NULL array -- element (excluded). Two child accounts (4000, 4004) are shared with associations -- that don't own their parent (4999) — those clones become top-level. -- Pristine snapshot for rollback / original-parent lookups. drop table if exists public._coa_perassoc_backup; create table public._coa_perassoc_backup as table public.chart_of_accounts; -- Drop the old per-system uniqueness up front: clones reuse the same account_number -- within buildium, which the old (account_number, accounting_system) index forbids. -- The new per-association index is created at the end, after the data is consistent. drop index if exists public.chart_of_accounts_number_per_system_unique; -- (old_id, association) -> new_id. The owning association keeps the original row id; -- every other (non-null) association in the array gets a fresh clone id. -- Only map to associations that still exist — some arrays carry ids of deleted -- associations (stale memberships); those simply don't get a row. create temp table coa_map as select c.id as old_id, u.assoc as association_id, case when u.assoc = c.association_id then c.id else gen_random_uuid() end as new_id, (u.assoc = c.association_id) as is_keep from public.chart_of_accounts c cross join lateral ( select distinct e as assoc from unnest(c.association_ids) e where e is not null and exists (select 1 from public.associations a where a.id = e) ) u where c.accounting_system = 'buildium'; -- Clone rows for the non-owning associations. insert into public.chart_of_accounts (id, association_id, account_number, account_name, account_type, parent_account_id, is_active, description, association_ids, accounting_system, created_at, updated_at) select m.new_id, m.association_id, c.account_number, c.account_name, c.account_type, c.parent_account_id, c.is_active, c.description, array[m.association_id], c.accounting_system, c.created_at, now() from coa_map m join public.chart_of_accounts c on c.id = m.old_id where not m.is_keep; -- Collapse the kept rows' arrays to their single association. update public.chart_of_accounts c set association_ids = array[c.association_id], updated_at = now() where c.accounting_system = 'buildium' and association_ids <> array[c.association_id]; -- Remap parent_account_id to the same-association copy of the parent (original -- parents read from the pristine snapshot so in-place updates don't interfere). update public.chart_of_accounts r set parent_account_id = pm.new_id, updated_at = now() from coa_map rm join public._coa_perassoc_backup b on b.id = rm.old_id join coa_map pm on pm.old_id = b.parent_account_id and pm.association_id = rm.association_id where r.id = rm.new_id and b.parent_account_id is not null and r.parent_account_id is distinct from pm.new_id; -- Any parent that still isn't owned by the row's association (a child shared with an -- association that doesn't own its parent) can't nest there — make it top-level. update public.chart_of_accounts c set parent_account_id = null, updated_at = now() from public.chart_of_accounts p where c.parent_account_id = p.id and c.accounting_system = 'buildium' and p.association_id is distinct from c.association_id; -- Repoint every reference to the association-specific row, by the record's association. update public.bills x set expense_account_id = m.new_id from coa_map m where m.old_id = x.expense_account_id and m.association_id = x.association_id and m.new_id <> x.expense_account_id; update public.budgets x set gl_account_id = m.new_id from coa_map m where m.old_id = x.gl_account_id and m.association_id = x.association_id and m.new_id <> x.gl_account_id; update public.owner_ledger_entries x set gl_account_id = m.new_id from coa_map m where m.old_id = x.gl_account_id and m.association_id = x.association_id and m.new_id <> x.gl_account_id; -- budget_actuals_monthly is a VIEW derived from the base tables above; it recomputes -- and needs no repointing (it is still read in verification below). update public.vendor_coa_mappings x set chart_of_accounts_id = m.new_id from coa_map m where m.old_id = x.chart_of_accounts_id and m.association_id = x.association_id and m.new_id <> x.chart_of_accounts_id; update public.units x set assessment_account_id = m.new_id from coa_map m where m.old_id = x.assessment_account_id and m.association_id = x.association_id and m.new_id <> x.assessment_account_id; update public.vendors x set default_expense_account_id = m.new_id from coa_map m where m.old_id = x.default_expense_account_id and m.association_id = x.association_id and m.new_id <> x.default_expense_account_id; update public.journal_entries x set chart_of_account_id = m.new_id from coa_map m where m.old_id = x.chart_of_account_id and m.association_id = x.association_id and m.new_id <> x.chart_of_account_id; -- Add per-association uniqueness. Excludes 'platform' (whose rows are id-keyed -- mirrors of accounting.accounts and may carry blank/duplicate codes), matching the -- old index's exclusion. Also validates the importer's existing onConflict target. create unique index chart_of_accounts_assoc_number_unique on public.chart_of_accounts (association_id, account_number) where accounting_system <> 'platform'; -- Verify (raises -> rolls back the whole migration on any inconsistency). do $$ declare _n int; _bad int; begin select count(*) into _n from public.chart_of_accounts where accounting_system='buildium'; if _n <> 370 then raise exception 'buildium row count % <> expected 370', _n; end if; if exists (select 1 from public.chart_of_accounts where accounting_system='buildium' and array_length(association_ids,1) <> 1) then raise exception 'buildium rows with non-singleton association_ids remain'; end if; select count(*) into _bad from ( select x.association_id ra, c.association_id ca from public.bills x join public.chart_of_accounts c on c.id=x.expense_account_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.budgets x join public.chart_of_accounts c on c.id=x.gl_account_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.owner_ledger_entries x join public.chart_of_accounts c on c.id=x.gl_account_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.budget_actuals_monthly x join public.chart_of_accounts c on c.id=x.gl_account_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.vendor_coa_mappings x join public.chart_of_accounts c on c.id=x.chart_of_accounts_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.units x join public.chart_of_accounts c on c.id=x.assessment_account_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.vendors x join public.chart_of_accounts c on c.id=x.default_expense_account_id where c.accounting_system='buildium' union all select x.association_id, c.association_id from public.journal_entries x join public.chart_of_accounts c on c.id=x.chart_of_account_id where c.accounting_system='buildium' ) t where ra is distinct from ca; if _bad > 0 then raise exception '% references point to a different-association COA row', _bad; end if; select count(*) into _bad from public.chart_of_accounts c join public.chart_of_accounts p on p.id=c.parent_account_id where c.accounting_system='buildium' and p.association_id is distinct from c.association_id; if _bad > 0 then raise exception '% buildium rows have a cross-association parent', _bad; end if; end $$;