diff --git a/supabase/migrations/20260601150000_accounting_post_gl_from_subledgers.sql b/supabase/migrations/20260601150000_accounting_post_gl_from_subledgers.sql new file mode 100644 index 0000000..4b83835 --- /dev/null +++ b/supabase/migrations/20260601150000_accounting_post_gl_from_subledgers.sql @@ -0,0 +1,226 @@ +-- 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();