-- Post the AR/AP sub-ledgers (invoices, payments_received, bills) to the general -- ledger (journal_entries/_lines) so the GL-driven P&L and Balance Sheet show -- the real accounts. This implements the long-referenced (never-built) -- "syncBillsInvoicesToLedger". -- -- Scope guard: only companies whose GL is "managed" by us — i.e. they have no -- foreign/imported journal entries (external_source null or not 'acmacc_%'). -- That excludes associations with an imported GL (e.g. Bridgewater) so we never -- double-count. Every posting is keyed by journal_entries.external_source/ -- external_id, so it is idempotent. -- -- Postings (double-entry): -- invoice -> Dr Accounts Receivable / Cr income (keyword-mapped) -- payment -> Dr Undeposited Funds / Cr Accounts Receivable -- bill -> Dr expense (bill_items) / Cr Accounts Payable -- bill paid-> Dr Accounts Payable / Cr operating bank -- Is this company's GL managed by the sub-ledger postings (no imported GL)? create or replace function accounting.gl_managed(_company_id uuid) returns boolean language sql stable security definer set search_path to 'public','accounting' as $$ select not exists ( select 1 from accounting.journal_entries je where je.company_id = _company_id and (je.external_source is null or je.external_source not like 'acmacc_%') ); $$; -- Find an account by code (then by any name pattern), else create it. create or replace function accounting.coa_get_or_create( _company_id uuid, _match text[], _code text, _name text, _type accounting.account_type ) returns uuid language plpgsql security definer set search_path to 'public','accounting' as $$ declare _id uuid; _m text; begin if _code is not null then select id into _id from accounting.accounts where company_id=_company_id and code=_code and type=_type limit 1; if _id is not null then return _id; end if; end if; foreach _m in array coalesce(_match, array[]::text[]) loop select id into _id from accounting.accounts where company_id=_company_id and type=_type and name ilike _m order by code nulls last limit 1; if _id is not null then return _id; end if; end loop; insert into accounting.accounts (company_id, name, code, type, balance, is_bank, is_system) values (_company_id, _name, _code, _type, 0, false, false) returning id into _id; return _id; end$$; create or replace function accounting.coa_ar(_company_id uuid) returns uuid language sql security definer set search_path to 'public','accounting' as $$ select accounting.coa_get_or_create(_company_id, array['%receivable%'], '1100', 'Accounts Receivable', 'asset'::accounting.account_type); $$; create or replace function accounting.coa_ap(_company_id uuid) returns uuid language sql security definer set search_path to 'public','accounting' as $$ select accounting.coa_get_or_create(_company_id, array['%payable%'], '2000', 'Accounts Payable', 'liability'::accounting.account_type); $$; create or replace function accounting.coa_undeposited(_company_id uuid) returns uuid language sql security definer set search_path to 'public','accounting' as $$ select accounting.coa_get_or_create(_company_id, array['Undeposited Funds'], '1499', 'Undeposited Funds', 'asset'::accounting.account_type); $$; create or replace function accounting.coa_default_expense(_company_id uuid) returns uuid language sql security definer set search_path to 'public','accounting' as $$ select accounting.coa_get_or_create(_company_id, array['%administrative%','%operating%','%management%'], '5000', 'Administrative', 'expense'::accounting.account_type); $$; create or replace function accounting.coa_operating_bank(_company_id uuid) returns uuid language sql stable security definer set search_path to 'public','accounting' as $$ select id from accounting.accounts where company_id=_company_id and is_bank and name not ilike '%undeposited%' order by code nulls last limit 1; $$; -- Income account for an invoice, by keyword on its description. create or replace function accounting.coa_income_for(_company_id uuid, _desc text) returns uuid language plpgsql security definer set search_path to 'public','accounting' as $$ declare _d text := lower(coalesce(_desc,'')); begin if _d like '%late%' then return accounting.coa_get_or_create(_company_id, array['%late%'], '4070', 'Late Fees', 'income'); end if; if _d like '%admin%' then return accounting.coa_get_or_create(_company_id, array['%admin%'], '4080', 'Admin. Fees', 'income'); end if; if _d like '%interest%' then return accounting.coa_get_or_create(_company_id, array['%interest%'], '4060', 'Interest', 'income'); end if; if _d like '%violation%' or _d like '%fine%' then return accounting.coa_get_or_create(_company_id, array['%violation%','%fine%'], '4045', 'Violations', 'income'); end if; return accounting.coa_get_or_create(_company_id, array['%assessment%','%dues%'], '4010', 'Assessment Fees', 'income'); end$$; -- Remove a keyed journal entry (lines first) for re-posting / cleanup. create or replace function accounting._gl_clear(_company_id uuid, _src text, _ext text) returns void language plpgsql security definer set search_path to 'public','accounting' as $$ begin delete from accounting.journal_entry_lines where journal_entry_id in ( select id from accounting.journal_entries where company_id=_company_id and external_source=_src and external_id=_ext); delete from accounting.journal_entries where company_id=_company_id and external_source=_src and external_id=_ext; end$$; create or replace function accounting.post_invoice_gl(_invoice_id uuid) returns void language plpgsql security definer set search_path to 'public','accounting' as $$ declare i accounting.invoices%rowtype; _ar uuid; _inc uuid; _je uuid; begin select * into i from accounting.invoices where id=_invoice_id; if not found then return; end if; perform accounting._gl_clear(i.company_id, 'acmacc_inv', i.id::text); if not accounting.gl_managed(i.company_id) then return; end if; if coalesce(i.total,0) = 0 or i.status = 'void' then return; end if; _ar := accounting.coa_ar(i.company_id); _inc := accounting.coa_income_for(i.company_id, coalesce(nullif(i.notes,''), i.number)); insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) values (i.company_id, i.issue_date, coalesce(nullif(i.notes,''), 'Invoice ' || i.number), i.number, 'acmacc_inv', i.id::text) returning id into _je; insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _ar, i.total, 0, 'Invoice ' || i.number), (_je, _inc, 0, i.total, 'Invoice ' || i.number); end$$; create or replace function accounting.post_payment_gl(_payment_id uuid) returns void language plpgsql security definer set search_path to 'public','accounting' as $$ declare p accounting.payments_received%rowtype; _ar uuid; _cash uuid; _je uuid; begin select * into p from accounting.payments_received where id=_payment_id; if not found then return; end if; perform accounting._gl_clear(p.company_id, 'acmacc_pay', p.id::text); if not accounting.gl_managed(p.company_id) then return; end if; if coalesce(p.amount,0) = 0 then return; end if; _ar := accounting.coa_ar(p.company_id); _cash := accounting.coa_undeposited(p.company_id); insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) values (p.company_id, p.payment_date, coalesce(nullif(p.memo,''), 'Payment received'), p.reference, 'acmacc_pay', p.id::text) returning id into _je; insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _cash, p.amount, 0, 'Payment'), (_je, _ar, 0, p.amount, 'Payment'); end$$; create or replace function accounting.post_bill_gl(_bill_id uuid) returns void language plpgsql security definer set search_path to 'public','accounting' as $$ declare b accounting.bills%rowtype; _ap uuid; _defexp uuid; _bank uuid; _je uuid; _debits numeric := 0; it record; begin select * into b from accounting.bills where id=_bill_id; if not found then return; end if; perform accounting._gl_clear(b.company_id, 'acmacc_bill', b.id::text); perform accounting._gl_clear(b.company_id, 'acmacc_billpay', b.id::text); if not accounting.gl_managed(b.company_id) then return; end if; if coalesce(b.total,0) = 0 or b.status = 'void' then return; end if; _ap := accounting.coa_ap(b.company_id); _defexp := accounting.coa_default_expense(b.company_id); -- Bill: Dr expense(s) / Cr A/P insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) values (b.company_id, b.issue_date, coalesce(nullif(b.notes,''), 'Bill ' || b.number), b.number, 'acmacc_bill', b.id::text) returning id into _je; for it in select coalesce(account_id, _defexp) as acct, amount from accounting.bill_items where bill_id=b.id and amount <> 0 loop insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, it.acct, it.amount, 0, 'Bill ' || b.number); _debits := _debits + it.amount; end loop; if _debits < b.total then insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _defexp, b.total - _debits, 0, 'Bill ' || b.number); end if; insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _ap, 0, b.total, 'Bill ' || b.number); -- Paid portion: Dr A/P / Cr operating bank if coalesce(b.paid_amount,0) > 0 then _bank := coalesce(accounting.coa_operating_bank(b.company_id), accounting.coa_undeposited(b.company_id)); insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) values (b.company_id, coalesce(b.updated_at::date, b.issue_date), 'Bill payment ' || b.number, b.number, 'acmacc_billpay', b.id::text) returning id into _je; insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values (_je, _ap, b.paid_amount, 0, 'Bill payment ' || b.number), (_je, _bank, 0, b.paid_amount, 'Bill payment ' || b.number); end if; end$$; -- ── Triggers (error-swallowed) ─────────────────────────────────────────────── create or replace function accounting.tg_invoice_gl() returns trigger language plpgsql security definer set search_path to 'public','accounting' as $$ begin begin if tg_op='DELETE' then perform accounting._gl_clear(old.company_id,'acmacc_inv',old.id::text); return old; end if; perform accounting.post_invoice_gl(new.id); exception when others then raise warning 'accounting: invoice GL post failed for %: %', coalesce(new.id,old.id), sqlerrm; end; return coalesce(new, old); end$$; create or replace function accounting.tg_payment_gl() returns trigger language plpgsql security definer set search_path to 'public','accounting' as $$ begin begin if tg_op='DELETE' then perform accounting._gl_clear(old.company_id,'acmacc_pay',old.id::text); return old; end if; perform accounting.post_payment_gl(new.id); exception when others then raise warning 'accounting: payment GL post failed for %: %', coalesce(new.id,old.id), sqlerrm; end; return coalesce(new, old); end$$; create or replace function accounting.tg_bill_gl() returns trigger language plpgsql security definer set search_path to 'public','accounting' as $$ begin begin if tg_op='DELETE' then perform accounting._gl_clear(old.company_id,'acmacc_bill',old.id::text); perform accounting._gl_clear(old.company_id,'acmacc_billpay',old.id::text); return old; end if; perform accounting.post_bill_gl(new.id); exception when others then raise warning 'accounting: bill GL post failed for %: %', coalesce(new.id,old.id), sqlerrm; end; return coalesce(new, old); end$$; drop trigger if exists trg_acct_invoice_gl on accounting.invoices; create trigger trg_acct_invoice_gl after insert or update or delete on accounting.invoices for each row execute function accounting.tg_invoice_gl(); drop trigger if exists trg_acct_payment_gl on accounting.payments_received; create trigger trg_acct_payment_gl after insert or update or delete on accounting.payments_received for each row execute function accounting.tg_payment_gl(); drop trigger if exists trg_acct_bill_gl on accounting.bills; create trigger trg_acct_bill_gl after insert or update or delete on accounting.bills for each row execute function accounting.tg_bill_gl();