mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
-- 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 $$;
|
||||
Reference in New Issue
Block a user