mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
c124397a97
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>
271 lines
10 KiB
PL/PgSQL
271 lines
10 KiB
PL/PgSQL
-- 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();
|