Files
acmcc/supabase/migrations/20260601150000_accounting_post_gl_from_subledgers.sql
T
admin 04d1bdfb49 Post AR/AP sub-ledgers to the general ledger (fix empty P&L/Balance Sheet)
The P&L and Balance Sheet are GL-driven, but invoices/payments/bills never
posted to journal_entries (the referenced syncBillsInvoicesToLedger was never
built), so accounts with no GL activity showed $0 and were hidden.

Adds an idempotent GL posting engine (migration applied to prod), scoped to
companies whose GL is "managed" (no imported/foreign journal entries) to avoid
double-counting Bridgewater/Bent Oak:
- invoice  -> Dr Accounts Receivable / Cr income (keyword-mapped, default Assessment Fees)
- payment  -> Dr Undeposited Funds   / Cr Accounts Receivable
- bill     -> Dr expense (bill_items) / Cr Accounts Payable (+ paid leg to bank)
Auto-creates AR (1100) / AP (2000) where missing. AFTER triggers on
invoices/payments_received/bills keep the GL in sync; existing docs backfilled.
Verified debits=credits and the balance-sheet invariant holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:39:07 -04:00

227 lines
12 KiB
PL/PgSQL

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