Files
acmcc/supabase/migrations/20260604140000_per_association_chart_of_accounts.sql
T
admin 6634907799 Per-association chart of accounts
Each association now owns an independent set of chart_of_accounts rows. Two
associations can both have a "5000" meaning different things, edited
independently — previously buildium rows were shared across associations via the
association_ids[] array, so editing one association's number edited it for all.

- Data migration: split each shared buildium row into one row per association in
  its association_ids (excluding nulls and ids of deleted associations). The
  original row stays as the per-association row for its own association_id;
  clones are added for the others — nothing is deleted, so no FK dangles.
  94 -> 370 buildium rows. References repointed by each record's association
  (bills, budgets, owner_ledger_entries, vendor_coa_mappings, units, vendors,
  journal_entries; budget_actuals_monthly is a view). parent_account_id remapped
  same-association; orphan-parent children become top-level. Pristine backup in
  public._coa_perassoc_backup. Ran in one transaction with in-line verification.
- Uniqueness: drop (account_number, accounting_system); add
  UNIQUE(association_id, account_number) WHERE accounting_system <> 'platform'
  (platform rows mirror accounting.accounts and carry blank/dup codes). This also
  finally backs the buildium importers' existing onConflict target.
- Keep association_ids as a single-element mirror of association_id during the
  transition so the admin COA page and direct array-contains callers keep working.
- App: fetchChartOfAccounts scopes buildium/zoho by association_id when an
  association is given (was system-wide). Importers and sync_account_to_public_coa
  were already per-association; no change needed.

Verified on live data: 370 singleton-array rows across 12 associations, zero
intra-association duplicates, zero cross-association references or parents, and a
live two-association "5000" independence test (create/rename/isolate) passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:38:50 -04:00

141 lines
8.8 KiB
SQL

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