-- 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();