Sync accounting Homeowners from Units & Owners per association

Adds DB triggers + backfill so accounting.customers is driven by the
public units/owners roster: one customer per unit, all owners combined,
Units/Owners as source of truth for contact fields. Balances and ledger
links (invoices, payments_received, transactions, work_orders, estimates)
are always preserved. Scoped to associations with an accounting company.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:54:57 -04:00
parent b67587a2b7
commit c124397a97
@@ -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();