-- A5 (expense classification) + A/P overpayment fix. -- -- 1. post_transaction_gl counter priority was customer→AR, vendor→AP, coa→coa. -- But direct vendor payments carry an expense category (coa_account_id); mapping -- them to A/P drove A/P negative AND hid the real expense account. New priority: -- customer→A/R (a receipt always settles A/R), then coa→that account (direct -- categorized expense/income), then vendor with no category→A/P (bill settlement). -- -- 2. Synced bills created a single bill_item with no account, so post_bill_gl lumped -- every bill into the default "Administrative" expense. Map the public bill's -- expense_account_id (public.chart_of_accounts) to the matching accounting expense -- account by name and set it on the bill_item. create or replace function accounting.post_transaction_gl(_txn_id uuid) returns void language plpgsql security definer set search_path to 'public','accounting' as $$ declare t accounting.transactions%rowtype; _counter uuid; _je uuid; _amt numeric; begin select * into t from accounting.transactions where id=_txn_id; if not found then return; end if; perform accounting._gl_clear(t.company_id, 'acmacc_txn', t.id::text); if not accounting.gl_managed(t.company_id) then return; end if; if t.transfer_id is not null or t.deposit_id is not null then return; end if; if t.account_id is null then return; end if; _amt := coalesce(t.amount,0); if _amt = 0 then return; end if; _counter := case when t.customer_id is not null then accounting.coa_ar(t.company_id) -- receipt settles A/R when t.coa_account_id is not null then t.coa_account_id -- direct categorized expense/income when t.vendor_id is not null then accounting.coa_ap(t.company_id) -- uncategorized vendor payment = bill settlement else null end; if _counter is null then return; end if; insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) values (t.company_id, t.date, coalesce(nullif(t.description,''), 'Bank transaction'), t.reference, 'acmacc_txn', t.id::text) returning id into _je; if t.type = 'credit' then -- money in insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, t.account_id, _amt, 0, t.description), (_je, _counter, 0, _amt, t.description); else -- money out insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _counter, _amt, 0, t.description), (_je, t.account_id, 0, _amt, t.description); end if; end$$; 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; _exp_acct uuid; _exp_name text; 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; -- Map the bill's expense account (public COA name -> accounting expense account). if b.expense_account_id is not null then select account_name into _exp_name from public.chart_of_accounts where id=b.expense_account_id; if _exp_name is not null then _exp_acct := accounting.coa_get_or_create(_company_id, array[_exp_name], null, _exp_name, 'expense'::accounting.account_type); end if; end if; 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, _exp_acct); -- Re-post the bill to the GL now that the line item's expense account is set. perform accounting.post_bill_gl(_acct_bill_id); end; $$; -- Backfill managed companies do $$ declare r record; begin for r in select b.id from public.bills b join accounting.companies c on c.association_id=b.association_id where accounting.gl_managed(c.id) loop perform accounting.sync_public_bill(r.id); end loop; for r in select t.id from accounting.transactions t join accounting.companies c on c.id=t.company_id where accounting.gl_managed(c.id) and t.transfer_id is null and t.deposit_id is null loop perform accounting.post_transaction_gl(r.id); end loop; end$$;