mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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>
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user