mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Bidirectional bill sync between app and Accounting
Migration applied to prod: approved/paid public.bills mirror into accounting.bills (Payables) with find-or-create vendor + line item (external_source='acmacc_bill'/'acmacc_vendor'). When an accounting bill is marked paid (paid_amount>=total), the linked public.bills is set status=paid (+paid_date, amount_paid) and its bill_approvals marked paid. Loop-guarded with is-distinct-from. Backfilled 370 bills/45 vendors; totals reconcile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
|||||||
|
-- Bidirectional bill sync between the main app and the Accounting platform.
|
||||||
|
-- Forward: an approved/paid public.bills row (created by the bill-approvals
|
||||||
|
-- flow) is mirrored into accounting.bills (Payables), creating the
|
||||||
|
-- accounting vendor + a single line item.
|
||||||
|
-- Back: when an accounting.bills row is marked paid (paid_amount >= total),
|
||||||
|
-- the linked public.bills is set status='paid' (+ paid_date,
|
||||||
|
-- amount_paid) and its bill_approvals are marked paid.
|
||||||
|
-- Scoped to associations with an accounting.companies row. Keyed by
|
||||||
|
-- external_source='acmacc_bill' / 'acmacc_vendor', external_id = public row id.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Find-or-create the accounting vendor mirroring a public vendor.
|
||||||
|
-- Adopts an existing same-name accounting vendor before creating a new one.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
create or replace function accounting.ensure_vendor_for_public(_company_id uuid, _public_vendor_id uuid)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
declare _vid uuid; _name text; _email text; _phone text; _addr text;
|
||||||
|
begin
|
||||||
|
if _public_vendor_id is null then return null; end if;
|
||||||
|
|
||||||
|
select id into _vid from accounting.vendors
|
||||||
|
where company_id=_company_id and external_source='acmacc_vendor' and external_id=_public_vendor_id::text;
|
||||||
|
if _vid is not null then return _vid; end if;
|
||||||
|
|
||||||
|
select name, email, phone, address into _name, _email, _phone, _addr
|
||||||
|
from public.vendors where id=_public_vendor_id;
|
||||||
|
if _name is null then _name := 'Vendor'; end if;
|
||||||
|
|
||||||
|
-- adopt an existing same-name vendor (avoid duplicates), else create
|
||||||
|
select id into _vid from accounting.vendors
|
||||||
|
where company_id=_company_id and lower(trim(name))=lower(trim(_name)) limit 1;
|
||||||
|
if _vid is not null then
|
||||||
|
update accounting.vendors
|
||||||
|
set external_source='acmacc_vendor', external_id=_public_vendor_id::text,
|
||||||
|
email=coalesce(email,_email), phone=coalesce(phone,_phone), address=coalesce(address,_addr),
|
||||||
|
updated_at=now()
|
||||||
|
where id=_vid;
|
||||||
|
return _vid;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into accounting.vendors (company_id, name, email, phone, address, external_source, external_id)
|
||||||
|
values (_company_id, _name, _email, _phone, _addr, 'acmacc_vendor', _public_vendor_id::text)
|
||||||
|
returning id into _vid;
|
||||||
|
return _vid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Should a public bill of this status appear in accounting Payables?
|
||||||
|
create or replace function accounting.bill_should_mirror(_status text)
|
||||||
|
returns boolean language sql immutable as $$
|
||||||
|
select coalesce(lower(_status), '') not in
|
||||||
|
('draft','pending','pending_approval','rejected','void','voided','cancelled','denied');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Forward: mirror one public.bills row into accounting.bills (idempotent).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
create or replace function accounting.sync_public_bill(_bill_id uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
b public.bills%rowtype;
|
||||||
|
_company_id uuid;
|
||||||
|
_vendor_id uuid;
|
||||||
|
_status accounting.bill_status;
|
||||||
|
_paid numeric; _tot numeric;
|
||||||
|
_acct_bill_id uuid;
|
||||||
|
begin
|
||||||
|
select * into b from public.bills where id=_bill_id;
|
||||||
|
if not found then return; end if;
|
||||||
|
|
||||||
|
select id into _company_id from accounting.companies where association_id=b.association_id;
|
||||||
|
if _company_id is null then return; end if;
|
||||||
|
|
||||||
|
if not accounting.bill_should_mirror(b.status) then
|
||||||
|
delete from accounting.bills where company_id=_company_id and external_source='acmacc_bill' and external_id=b.id::text;
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
_vendor_id := accounting.ensure_vendor_for_public(_company_id, b.vendor_id);
|
||||||
|
_paid := coalesce(b.amount_paid, 0);
|
||||||
|
_tot := coalesce(b.amount, 0);
|
||||||
|
_status := (case when _paid >= _tot and _tot > 0 then 'paid'
|
||||||
|
when _paid > 0 then 'partially_paid'
|
||||||
|
else 'open' end)::accounting.bill_status;
|
||||||
|
|
||||||
|
insert into accounting.bills
|
||||||
|
(company_id, vendor_id, number, issue_date, due_date, status, subtotal, tax, total,
|
||||||
|
notes, paid_amount, attachment_url, external_source, external_id)
|
||||||
|
values
|
||||||
|
(_company_id, _vendor_id,
|
||||||
|
coalesce(nullif(b.invoice_number,''), 'BILL-' || left(replace(b.id::text,'-',''),8)),
|
||||||
|
b.bill_date, b.due_date, _status, _tot, 0, _tot,
|
||||||
|
b.description, _paid, b.attachment_url, 'acmacc_bill', b.id::text)
|
||||||
|
on conflict (company_id, external_source, external_id) where external_id is not null
|
||||||
|
do update set vendor_id=excluded.vendor_id, number=excluded.number, issue_date=excluded.issue_date,
|
||||||
|
due_date=excluded.due_date, status=excluded.status, subtotal=excluded.subtotal,
|
||||||
|
total=excluded.total, notes=excluded.notes, paid_amount=excluded.paid_amount,
|
||||||
|
attachment_url=excluded.attachment_url, updated_at=now()
|
||||||
|
returning id into _acct_bill_id;
|
||||||
|
|
||||||
|
if _acct_bill_id is null then
|
||||||
|
select id into _acct_bill_id from accounting.bills
|
||||||
|
where company_id=_company_id and external_source='acmacc_bill' and external_id=b.id::text;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Single line item mirroring the bill amount (refresh on each sync).
|
||||||
|
delete from accounting.bill_items where bill_id=_acct_bill_id;
|
||||||
|
insert into accounting.bill_items (bill_id, description, quantity, rate, amount)
|
||||||
|
values (_acct_bill_id, coalesce(nullif(b.description,''), 'Bill ' || coalesce(b.invoice_number,'')), 1, _tot, _tot);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Back: when an accounting bill is fully paid, reflect it on the public bill.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
create or replace function accounting.sync_accounting_bill_paid(_acct_bill_id uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
declare ab accounting.bills%rowtype; _public_id uuid;
|
||||||
|
begin
|
||||||
|
select * into ab from accounting.bills where id=_acct_bill_id;
|
||||||
|
if not found then return; end if;
|
||||||
|
if ab.external_source <> 'acmacc_bill' or ab.external_id is null then return; end if;
|
||||||
|
if not (coalesce(ab.paid_amount,0) >= coalesce(ab.total,0) and coalesce(ab.total,0) > 0) then return; end if;
|
||||||
|
|
||||||
|
_public_id := ab.external_id::uuid;
|
||||||
|
update public.bills
|
||||||
|
set status='paid', amount_paid=ab.total,
|
||||||
|
paid_date=coalesce(paid_date, current_date), updated_at=now()
|
||||||
|
where id=_public_id and status is distinct from 'paid';
|
||||||
|
|
||||||
|
update public.bill_approvals
|
||||||
|
set status='paid', updated_at=now()
|
||||||
|
where bill_id=_public_id and status is distinct from 'paid';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Triggers (error-swallowed).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
create or replace function accounting.tg_public_bill_sync()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
declare _company_id uuid;
|
||||||
|
begin
|
||||||
|
begin
|
||||||
|
if tg_op='DELETE' then
|
||||||
|
select id into _company_id from accounting.companies where association_id=old.association_id;
|
||||||
|
if _company_id is not null then
|
||||||
|
delete from accounting.bills where company_id=_company_id and external_source='acmacc_bill' and external_id=old.id::text;
|
||||||
|
end if;
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
perform accounting.sync_public_bill(new.id);
|
||||||
|
return new;
|
||||||
|
exception when others then
|
||||||
|
raise warning 'accounting: public bill sync failed for %: %', coalesce(new.id, old.id), sqlerrm;
|
||||||
|
return coalesce(new, old);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function accounting.tg_accounting_bill_paid_sync()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
begin
|
||||||
|
-- only act when the paid position actually changed, to avoid loops
|
||||||
|
if old.paid_amount is distinct from new.paid_amount or old.total is distinct from new.total then
|
||||||
|
perform accounting.sync_accounting_bill_paid(new.id);
|
||||||
|
end if;
|
||||||
|
exception when others then
|
||||||
|
raise warning 'accounting: accounting bill paid sync failed for %: %', new.id, sqlerrm;
|
||||||
|
end;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_acct_sync_public_bill on public.bills;
|
||||||
|
create trigger trg_acct_sync_public_bill
|
||||||
|
after insert or update or delete on public.bills
|
||||||
|
for each row execute function accounting.tg_public_bill_sync();
|
||||||
|
|
||||||
|
drop trigger if exists trg_acct_bill_paid_back on accounting.bills;
|
||||||
|
create trigger trg_acct_bill_paid_back
|
||||||
|
after update on accounting.bills
|
||||||
|
for each row execute function accounting.tg_accounting_bill_paid_sync();
|
||||||
Reference in New Issue
Block a user