Accounting: unify vendor roster + COA across bill-approvals and accounting bills

Single vendor source (public.vendors) and single COA source (accounting.accounts)
across both bill flows:

- Forward sync now carries public.bills.expense_account_id into the mirrored
  accounting.bill_items.account_id (when it resolves to accounting.accounts).
- Reverse trigger flows a GL change on a mirrored accounting bill line back to
  public.bills.expense_account_id (loop-guarded).
- New public.ensure_accounting_vendor RPC resolves a chosen public vendor to its
  accounting.vendors row; one-time backfill of mirrored line account_id.
- BillApprovalsPage GL pickers now use ChartOfAccountsDropdown (accounting.accounts).
- AccountingBillsPage vendor picker now lists public.vendors scoped to the
  company's association and maps to accounting.vendors on save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 18:57:32 -04:00
parent 84541a6813
commit 84c8483169
3 changed files with 198 additions and 37 deletions
@@ -0,0 +1,153 @@
-- Unify vendor + Chart of Accounts across the bill-approvals (public) and the
-- Accounting platform bill flows.
-- * Vendors: single roster = public.vendors. New RPC lets the Accounting UI
-- pick a public vendor and resolve it to the matching accounting.vendors row.
-- * COA: the GL account flows both ways between public.bills.expense_account_id
-- and accounting.bill_items.account_id (platform accounting.accounts ids are
-- mirrored 1:1 into public.chart_of_accounts, so the same id is valid on both
-- sides).
-- * One-time backfill of account_id on already-mirrored bills.
-- ---------------------------------------------------------------------------
-- 1) Forward sync now carries the public bill's GL account into the mirrored
-- accounting.bill_items.account_id (when it resolves to accounting.accounts).
-- ---------------------------------------------------------------------------
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;
_acct_account_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;
-- Adopt the public GL account when it maps to an accounting.accounts row for
-- this company (platform COA shares ids with public.chart_of_accounts).
_acct_account_id := null;
if b.expense_account_id is not null then
select id into _acct_account_id from accounting.accounts
where id = b.expense_account_id and company_id = _company_id;
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, account_id)
values (_acct_bill_id, coalesce(nullif(b.description,''), 'Bill ' || coalesce(b.invoice_number,'')), 1, _tot, _tot, _acct_account_id);
end;
$$;
-- ---------------------------------------------------------------------------
-- 2) Reverse: a GL change on a mirrored accounting bill line flows the account
-- back to public.bills.expense_account_id. Guarded against loops (only when
-- the value actually differs and the id is a valid public COA row).
-- ---------------------------------------------------------------------------
create or replace function accounting.tg_bill_item_coa_back_sync()
returns trigger
language plpgsql security definer set search_path to 'public','accounting'
as $$
declare ab accounting.bills%rowtype; _public_id uuid;
begin
begin
if new.account_id is null then return new; end if;
select * into ab from accounting.bills where id = new.bill_id;
if not found then return new; end if;
if ab.external_source <> 'acmacc_bill' or ab.external_id is null then return new; end if;
_public_id := ab.external_id::uuid;
update public.bills
set expense_account_id = new.account_id, updated_at = now()
where id = _public_id
and expense_account_id is distinct from new.account_id
and exists (select 1 from public.chart_of_accounts c where c.id = new.account_id);
exception when others then
raise warning 'accounting: bill item COA back-sync failed for %: %', new.id, sqlerrm;
end;
return new;
end;
$$;
drop trigger if exists trg_acct_bill_item_coa_back on accounting.bill_items;
create trigger trg_acct_bill_item_coa_back
after insert or update of account_id on accounting.bill_items
for each row execute function accounting.tg_bill_item_coa_back_sync();
-- ---------------------------------------------------------------------------
-- 3) RPC: resolve a chosen public vendor to its accounting.vendors row for the
-- association's company (find-or-create). Used by the Accounting bill UI so
-- the vendor roster stays single-sourced from public.vendors.
-- ---------------------------------------------------------------------------
create or replace function public.ensure_accounting_vendor(_association_id uuid, _public_vendor_id uuid)
returns uuid
language plpgsql security definer set search_path to 'public','accounting'
as $$
declare _company_id uuid; _vid uuid;
begin
if _public_vendor_id is null then return null; end if;
select id into _company_id from accounting.companies where association_id=_association_id;
if _company_id is null then return null; end if;
_vid := accounting.ensure_vendor_for_public(_company_id, _public_vendor_id);
return _vid;
end;
$$;
grant execute on function public.ensure_accounting_vendor(uuid, uuid) to authenticated, anon, service_role;
-- ---------------------------------------------------------------------------
-- 4) One-time backfill: populate account_id on already-mirrored bill lines from
-- the linked public bill's expense account (only when it maps to this
-- company's accounting.accounts).
-- ---------------------------------------------------------------------------
update accounting.bill_items bi
set account_id = b.expense_account_id
from accounting.bills ab
join public.bills b on b.id = ab.external_id::uuid
where bi.bill_id = ab.id
and ab.external_source = 'acmacc_bill' and ab.external_id is not null
and bi.account_id is null
and b.expense_account_id is not null
and exists (
select 1 from accounting.accounts a
where a.id = b.expense_account_id and a.company_id = ab.company_id
);