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