diff --git a/supabase/migrations/20260601120000_accounting_sync_homeowners_from_roster.sql b/supabase/migrations/20260601120000_accounting_sync_homeowners_from_roster.sql new file mode 100644 index 0000000..d1347cc --- /dev/null +++ b/supabase/migrations/20260601120000_accounting_sync_homeowners_from_roster.sql @@ -0,0 +1,270 @@ +-- Sync accounting Homeowners (accounting.customers) from the app roster +-- (public.units + public.owners), one customer per UNIT with all of that +-- unit's owners combined. Units/Owners are the source of truth for contact +-- fields; balances and ledger links (invoices, payments, transactions, +-- work orders, estimates) are always preserved. +-- +-- Scope: only associations that already have an accounting.companies row +-- (i.e. have switched on platform accounting). Associations without a company +-- are ignored. + +-- --------------------------------------------------------------------------- +-- Helper: normalized "street" key (first address line, lowercased, despaced). +-- Matches the differing roster vs. accounting address formats, e.g. +-- units: "1015 Cady Circle, Titusville, FL, 32780" +-- customers: "1015 Cady Circle\nTitusville, FL 32780" +-- both -> "1015 cady circle" +-- --------------------------------------------------------------------------- +create or replace function accounting._roster_street_key(_addr text) +returns text +language sql +immutable +as $$ + select nullif( + lower(regexp_replace( + trim(split_part(replace(coalesce(_addr, ''), E'\n', ','), ',', 1)), + '\s+', ' ', 'g' + )), + '' + ); +$$; + +-- --------------------------------------------------------------------------- +-- Core: ensure exactly one accounting.customers row represents a unit. +-- Adopts the best-matching existing customer (by external link, account #, or +-- street), folds any same-unit duplicates into it (reassigning ledger rows and +-- summing balances), then refreshes its contact fields from the roster. +-- Idempotent. +-- --------------------------------------------------------------------------- +create or replace function accounting.sync_customer_for_unit(_unit_id uuid) +returns uuid +language plpgsql +security definer +set search_path to 'public', 'accounting' +as $$ +declare + _u public.units%rowtype; + _company_id uuid; + _name text; + _email text; + _phone text; + _mailing text; + _movein date; + _prop text; + _acct text; + _street text; + _ids uuid[]; + _survivor uuid; + _dups uuid[]; +begin + select * into _u from public.units where id = _unit_id; + if not found then + return null; + end if; + + select id into _company_id + from accounting.companies + where association_id = _u.association_id; + if _company_id is null then + return null; -- association not on platform accounting; nothing to sync + end if; + + -- Combine all (non-tenant) owners on the unit. Primary owner first. + select + nullif(string_agg(nm, ' & ' order by pr, dord, cat), ''), + (array_agg(email order by pr, dord, cat) filter (where email is not null))[1], + (array_agg(phone order by pr, dord, cat) filter (where phone is not null))[1], + (array_agg(mailing_address order by pr, dord, cat) filter (where mailing_address is not null))[1], + (array_agg(move_in_date order by pr, dord, cat) filter (where move_in_date is not null))[1], + (array_agg(property_address order by pr, dord, cat) filter (where property_address is not null))[1], + (array_agg(account_number order by pr, dord, cat) filter (where account_number is not null))[1] + into _name, _email, _phone, _mailing, _movein, _prop, _acct + from ( + select + coalesce(nullif(trim(business_name), ''), + nullif(trim(concat_ws(' ', first_name, last_name)), '')) as nm, + email, phone, mailing_address, move_in_date, property_address, account_number, + case when is_primary then 0 else 1 end as pr, + coalesce(display_order, 999999) as dord, + created_at as cat + from public.owners + where unit_id = _unit_id + and coalesce(is_tenant, false) = false + ) o; + + -- Unit is the source of truth for property address / account number. + _prop := coalesce(nullif(trim(_u.address), ''), _prop); + _acct := coalesce(nullif(trim(_u.account_number), ''), _acct); + if _name is null or _name = '' then + _name := coalesce( + nullif('Unit ' || nullif(trim(_u.unit_number), ''), 'Unit '), + nullif(trim(_u.address), ''), + 'Unit' + ); + end if; + + _street := accounting._roster_street_key(_prop); + + -- Candidate existing customers for THIS unit: already-linked, same account #, + -- or same street. Prefer the already-linked row, then the earliest created. + select array_agg(id order by pri, cat) into _ids + from ( + select + id, + created_at as cat, + case when external_source = 'acmacc_unit' and external_id = _unit_id::text + then 0 else 1 end as pri + from accounting.customers + where company_id = _company_id + and ( + (external_source = 'acmacc_unit' and external_id = _unit_id::text) + or (unit_id = _unit_id::text) + or (_acct is not null and lower(trim(account_number)) = lower(trim(_acct))) + or (_street is not null and accounting._roster_street_key(property_address) = _street) + ) + ) s; + + if _ids is not null and array_length(_ids, 1) >= 1 then + _survivor := _ids[1]; + + -- Fold any duplicates into the survivor (preserve all financial history). + if array_length(_ids, 1) > 1 then + _dups := _ids[2:array_length(_ids, 1)]; + update accounting.invoices set customer_id = _survivor where customer_id = any(_dups); + update accounting.payments_received set customer_id = _survivor where customer_id = any(_dups); + update accounting.transactions set customer_id = _survivor where customer_id = any(_dups); + update accounting.work_orders set customer_id = _survivor where customer_id = any(_dups); + update accounting.estimates set customer_id = _survivor where customer_id = any(_dups); + + update accounting.customers + set balance = (select coalesce(sum(balance), 0) from accounting.customers where id = any(_ids)) + where id = _survivor; + + delete from accounting.customers where id = any(_dups); + end if; + + update accounting.customers set + name = _name, + email = _email, + phone = _phone, + property_address = _prop, + mailing_address = _mailing, + billing_address = coalesce(_mailing, _prop), + lot_number = nullif(trim(_u.lot), ''), + unit_number = nullif(trim(_u.unit_number), ''), + account_number = _acct, + move_in_date = _movein, + unit_id = _unit_id::text, + external_source = 'acmacc_unit', + external_id = _unit_id::text, + updated_at = now() + where id = _survivor; + else + insert into accounting.customers + (company_id, name, email, phone, property_address, mailing_address, billing_address, + lot_number, unit_number, account_number, move_in_date, unit_id, + external_source, external_id, balance) + values + (_company_id, _name, _email, _phone, _prop, _mailing, coalesce(_mailing, _prop), + nullif(trim(_u.lot), ''), nullif(trim(_u.unit_number), ''), _acct, _movein, _unit_id::text, + 'acmacc_unit', _unit_id::text, 0) + returning id into _survivor; + end if; + + return _survivor; +end; +$$; + +-- --------------------------------------------------------------------------- +-- When a unit goes away: delete its synced customer if it carries no ledger, +-- otherwise just detach the link so financial history is never lost. +-- --------------------------------------------------------------------------- +create or replace function accounting.unlink_customer_for_unit(_unit_id uuid) +returns void +language plpgsql +security definer +set search_path to 'public', 'accounting' +as $$ +declare + _cid uuid; + _refs int; +begin + for _cid in + select id from accounting.customers + where external_source = 'acmacc_unit' and external_id = _unit_id::text + loop + select count(*) into _refs from ( + select 1 from accounting.invoices where customer_id = _cid + union all select 1 from accounting.payments_received where customer_id = _cid + union all select 1 from accounting.transactions where customer_id = _cid + union all select 1 from accounting.work_orders where customer_id = _cid + union all select 1 from accounting.estimates where customer_id = _cid + ) t; + + if _refs = 0 then + delete from accounting.customers where id = _cid; + else + update accounting.customers + set external_source = null, external_id = null, updated_at = now() + where id = _cid; + end if; + end loop; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Trigger glue. Errors are swallowed (logged as warnings) so a sync hiccup can +-- never block a roster edit. +-- --------------------------------------------------------------------------- +create or replace function accounting.tg_sync_customer_from_unit() +returns trigger +language plpgsql +security definer +set search_path to 'public', 'accounting' +as $$ +begin + begin + if tg_op = 'DELETE' then + perform accounting.unlink_customer_for_unit(old.id); + return old; + end if; + perform accounting.sync_customer_for_unit(new.id); + return new; + exception when others then + raise warning 'accounting: unit sync failed for %: %', coalesce(new.id, old.id), sqlerrm; + return coalesce(new, old); + end; +end; +$$; + +create or replace function accounting.tg_sync_customer_from_owner() +returns trigger +language plpgsql +security definer +set search_path to 'public', 'accounting' +as $$ +begin + begin + if tg_op in ('INSERT', 'UPDATE') and new.unit_id is not null then + perform accounting.sync_customer_for_unit(new.unit_id); + end if; + if tg_op in ('UPDATE', 'DELETE') and old.unit_id is not null + and (tg_op = 'DELETE' or old.unit_id is distinct from new.unit_id) then + perform accounting.sync_customer_for_unit(old.unit_id); + end if; + exception when others then + raise warning 'accounting: owner sync failed: %', sqlerrm; + end; + return coalesce(new, old); +end; +$$; + +drop trigger if exists trg_acct_sync_customer_unit on public.units; +create trigger trg_acct_sync_customer_unit + after insert or update or delete on public.units + for each row execute function accounting.tg_sync_customer_from_unit(); + +drop trigger if exists trg_acct_sync_customer_owner on public.owners; +create trigger trg_acct_sync_customer_owner + after insert or update or delete on public.owners + for each row execute function accounting.tg_sync_customer_from_owner();