diff --git a/supabase/migrations/20260605010000_two_way_bill_creation_and_pending_mirror.sql b/supabase/migrations/20260605010000_two_way_bill_creation_and_pending_mirror.sql new file mode 100644 index 0000000..305e345 --- /dev/null +++ b/supabase/migrations/20260605010000_two_way_bill_creation_and_pending_mirror.sql @@ -0,0 +1,153 @@ +-- Make bill creation truly two-way between the app (public.bills / bill_approvals) +-- and the Accounting platform (accounting.bills), and mirror pending bills. +-- +-- 1. bill_should_mirror now includes 'pending' so approval bills appear in +-- Payables immediately (still excludes draft/rejected/void/cancelled/denied). +-- 2. A bill created NATIVELY in the Accounting module (external_source IS NULL) +-- now creates a matching public.bills row. The accounting row is pre-linked +-- to the new public id so the existing forward sync adopts it (no duplicate), +-- the vendor is mapped back to public.vendors, and the line's GL is carried. +-- 3. Backfill: mirror existing pending public bills + reverse-create eligible +-- native accounting bills. + +-- --------------------------------------------------------------------------- +-- 1) Mirror pending bills too. +-- --------------------------------------------------------------------------- +create or replace function accounting.bill_should_mirror(_status text) +returns boolean language sql immutable as $$ + select coalesce(lower(_status), '') not in + ('draft','rejected','void','voided','cancelled','denied'); +$$; + +-- --------------------------------------------------------------------------- +-- 2) Reverse creation: native accounting bill -> public.bills (idempotent-ish; +-- only acts on unlinked native rows). +-- --------------------------------------------------------------------------- +create or replace function accounting.create_public_from_accounting_bill(_acct_id uuid) +returns uuid +language plpgsql security definer set search_path to 'public','accounting' +as $$ +declare + ab accounting.bills%rowtype; + _assoc uuid; + _pub_vendor uuid; + _avs text; _avext text; _avname text; + _exp uuid; + _new_id uuid; + _pub_status text; +begin + select * into ab from accounting.bills where id=_acct_id; + if not found then return null; end if; + + -- only native, real, non-void bills + if ab.external_source is not null then return null; end if; + if coalesce(ab.auto_created,false) then return null; end if; + if ab.source_payment_id is not null then return null; end if; + if ab.status::text = 'void' then return null; end if; + + select association_id into _assoc from accounting.companies where id=ab.company_id; + if _assoc is null then return null; end if; + + -- Map the vendor back to a single public.vendors roster (find-or-create). + _pub_vendor := null; + if ab.vendor_id is not null then + select external_source, external_id, name into _avs, _avext, _avname + from accounting.vendors where id=ab.vendor_id; + if _avs='acmacc_vendor' and nullif(_avext,'') is not null then + _pub_vendor := _avext::uuid; + end if; + if _pub_vendor is null or not exists (select 1 from public.vendors where id=_pub_vendor) then + select id into _pub_vendor from public.vendors + where (association_id=_assoc or _assoc = any(association_ids)) + and lower(trim(name))=lower(trim(coalesce(_avname,''))) limit 1; + if _pub_vendor is null then + insert into public.vendors (name, association_id, is_active) + values (coalesce(nullif(_avname,''),'Vendor'), _assoc, true) + returning id into _pub_vendor; + end if; + update accounting.vendors + set external_source='acmacc_vendor', external_id=_pub_vendor::text, updated_at=now() + where id=ab.vendor_id and nullif(external_id,'') is null; + end if; + end if; + + -- Carry the first line's GL account if it is a valid public COA id. + select account_id into _exp from accounting.bill_items + where bill_id=ab.id and account_id is not null limit 1; + if _exp is not null and not exists (select 1 from public.chart_of_accounts where id=_exp) then + _exp := null; + end if; + + _pub_status := case ab.status::text when 'paid' then 'paid' else 'approved' end; + _new_id := gen_random_uuid(); + + -- Pre-link the accounting row so the forward sync (fired by the insert below) + -- updates THIS row on conflict instead of creating a duplicate mirror. + update accounting.bills + set external_source='acmacc_bill', external_id=_new_id::text, updated_at=now() + where id=ab.id; + + insert into public.bills + (id, association_id, vendor_id, invoice_number, bill_date, due_date, + amount, amount_paid, expense_account_id, description, status, attachment_url, approved_date) + values + (_new_id, _assoc, _pub_vendor, ab.number, coalesce(ab.issue_date, current_date), ab.due_date, + coalesce(ab.total,0), coalesce(ab.paid_amount,0), _exp, ab.notes, + _pub_status, ab.attachment_url, + case when _pub_status in ('approved','paid') then current_date else null end); + + return _new_id; +end; +$$; + +create or replace function accounting.tg_accounting_bill_reverse_create() +returns trigger +language plpgsql security definer set search_path to 'public','accounting' +as $$ +begin + begin + if new.external_source is null + and not coalesce(new.auto_created,false) + and new.source_payment_id is null + and new.status::text <> 'void' then + perform accounting.create_public_from_accounting_bill(new.id); + end if; + exception when others then + raise warning 'accounting: reverse bill create failed for %: %', new.id, sqlerrm; + end; + return new; +end; +$$; + +drop trigger if exists trg_acct_bill_reverse_create on accounting.bills; +create trigger trg_acct_bill_reverse_create + after insert on accounting.bills + for each row execute function accounting.tg_accounting_bill_reverse_create(); + +-- --------------------------------------------------------------------------- +-- 3a) Backfill: mirror existing pending public bills into Payables now. +-- --------------------------------------------------------------------------- +do $$ +declare r record; +begin + for r in select id from public.bills where lower(coalesce(status,'')) = 'pending' loop + perform accounting.sync_public_bill(r.id); + end loop; +end $$; + +-- --------------------------------------------------------------------------- +-- 3b) Backfill: reverse-create eligible native accounting bills. +-- --------------------------------------------------------------------------- +do $$ +declare r record; +begin + for r in + select id from accounting.bills + where external_source is null + and not coalesce(auto_created,false) + and source_payment_id is null + and status::text <> 'void' + loop + perform accounting.create_public_from_accounting_bill(r.id); + end loop; +end $$;