diff --git a/src/lib/chartOfAccountsSource.ts b/src/lib/chartOfAccountsSource.ts index bfc31ef..42777d8 100644 --- a/src/lib/chartOfAccountsSource.ts +++ b/src/lib/chartOfAccountsSource.ts @@ -54,7 +54,10 @@ function fromPlatform(row: any, associationId: string): NormalizedAccount { * - `platform` → the Accounting module's `accounting.accounts` (single source of * truth once an association is on the platform). Returns [] if the association * has no `accounting.companies` row yet. - * - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system. + * - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system AND, when + * an association is given, by that association. Each association owns an independent + * set of accounts (one row per association — see the per-association COA migration), + * so two associations can both have a "5000" meaning different things. * * Returned rows are normalized to {@link NormalizedAccount} so callers never * branch on the source. @@ -81,11 +84,14 @@ export async function fetchChartOfAccounts( return (data ?? []).map((row) => fromPlatform(row, associationId)); } - const { data, error } = await supabase + let query = supabase .from("chart_of_accounts") .select("*") - .eq("accounting_system", system) - .order("account_number", { ascending: true }); + .eq("accounting_system", system); + // Scope to the current association so each one sees only its own accounts. When no + // association is supplied, fall back to the whole system set (back-compat). + if (associationId) query = query.eq("association_id", associationId); + const { data, error } = await query.order("account_number", { ascending: true }); if (error) throw error; return (data ?? []).map(fromPublic); } diff --git a/supabase/migrations/20260604140000_per_association_chart_of_accounts.sql b/supabase/migrations/20260604140000_per_association_chart_of_accounts.sql new file mode 100644 index 0000000..bd7bcc4 --- /dev/null +++ b/supabase/migrations/20260604140000_per_association_chart_of_accounts.sql @@ -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 $$;