From f9c1b8af44cd2106ec1d31d07bf4e8e33a24af11 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 2 Jun 2026 01:08:37 -0400 Subject: [PATCH] Fix balance sheet: recognize transfers & deposits (unify cash on bank register) Root cause: accounting.transactions (deposits, transfers, bank payments) never posted to the GL, and the GL's cash came only from synthesized legs that hard-coded Undeposited Funds. So the balance sheet ignored transfers (#1) and showed directly-deposited funds as Undeposited (#2), with banks going negative. - Post the bank register to the GL: customer receipt -> Dr bank/Cr A/R, vendor payment -> Dr A/P/Cr bank, categorized -> Dr/Cr bank+COA, transfer -> Dr dest/ Cr source, deposit -> Dr bank/Cr Undeposited; payments_received -> Undeposited. Retire the synthesized invoice/bill cash legs (acmacc_invpay/billpay) so cash is sourced once, from the register. Triggers on transactions/deposits keep it live. - Fix gl_managed: make it an explicit accounting.companies.gl_auto_post flag instead of inferring from null-source journal entries (a single manual journal entry had silently disabled all automation for Ashley Manor). Verified (Ashley Manor): transfer reflected (Cogent +47,127 vs prior -3,521), deposits land in BOA (Undeposited cleared), R1=0, R7=0, no negative banks. Imported companies (Bridgewater/Bent Oak) untouched; their residuals stay surfaced in the Reconciliation report. Co-Authored-By: Claude Opus 4.8 --- ...accounting_unify_cash_on_bank_register.sql | 226 ++++++++++++++++++ ...60602130000_accounting_gl_managed_flag.sql | 20 ++ 2 files changed, 246 insertions(+) create mode 100644 supabase/migrations/20260602120000_accounting_unify_cash_on_bank_register.sql create mode 100644 supabase/migrations/20260602130000_accounting_gl_managed_flag.sql diff --git a/supabase/migrations/20260602120000_accounting_unify_cash_on_bank_register.sql b/supabase/migrations/20260602120000_accounting_unify_cash_on_bank_register.sql new file mode 100644 index 0000000..eeeb7df --- /dev/null +++ b/supabase/migrations/20260602120000_accounting_unify_cash_on_bank_register.sql @@ -0,0 +1,226 @@ +-- A7 / cash unification: the bank register (accounting.transactions, transfers, +-- deposits) is the real cash ledger but never posted to the GL, so the balance +-- sheet ignored transfers and deposited funds stayed stuck in Undeposited. +-- +-- Fix: post the register to the GL and RETIRE the synthesized cash legs so cash +-- is sourced once, from the register: +-- * customer receipt (transactions.customer_id) -> Dr bank / Cr A/R +-- * vendor payment (transactions.vendor_id) -> Dr A/P / Cr bank +-- * categorized (transactions.coa_account_id) -> Dr/Cr bank + COA +-- * transfer (transfer_id) -> Dr destination bank / Cr source bank (one entry) +-- * deposit (deposit_id) -> Dr bank / Cr Undeposited Funds (one entry) +-- * payments_received (undeposited, owner-ledger) -> Dr Undeposited / Cr A/R +-- Invoices still post Dr A/R / Cr income; bills still post Dr expense / Cr A/P; +-- their cash settlement now comes from the register (acmacc_invpay / acmacc_billpay +-- removed). Convention: transactions use bank-statement signs (credit = money IN). +-- Scoped to gl_managed companies; keyed by external_source/external_id (idempotent). + +-- Invoice: billing only (settlement now comes from register receipts). +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); + perform accounting._gl_clear(i.company_id, 'acmacc_invpay', i.id::text); -- retire old settlement leg + 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$$; + +-- Bill: expense / A/P only (payment now comes from register). +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; _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); -- retire old payment leg + 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); + 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); +end$$; + +-- payments_received = cash received into Undeposited Funds (owner-ledger path). +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; _und 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; + _und := accounting.coa_undeposited(p.company_id); + _ar := accounting.coa_ar(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, _und, p.amount, 0, 'Payment received'), + (_je, _ar, 0, p.amount, 'Payment received'); +end$$; + +-- A bank-register transaction (not a transfer/deposit). Bank-statement convention: +-- credit = money IN (Dr bank), debit = money OUT (Cr bank). Counter by linkage. +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; -- handled elsewhere + 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) + when t.vendor_id is not null then accounting.coa_ap(t.company_id) + when t.coa_account_id is not null then t.coa_account_id + else null end; + if _counter is null then return; end if; -- uncategorized (e.g. opening dup) — skip + + 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$$; + +-- Transfer: Dr destination bank / Cr source bank (one entry per transfer_id). +create or replace function accounting.post_transfer_gl(_transfer_id uuid) returns void +language plpgsql security definer set search_path to 'public','accounting' as $$ +declare _company uuid; _dest uuid; _src uuid; _amt numeric; _je uuid; +begin + select company_id, account_id, amount into _company, _dest, _amt + from accounting.transactions where transfer_id=_transfer_id and type='credit' limit 1; -- money in = destination + select account_id into _src + from accounting.transactions where transfer_id=_transfer_id and type='debit' limit 1; -- money out = source + if _company is null then return; end if; + perform accounting._gl_clear(_company, 'acmacc_xfer', _transfer_id::text); + if not accounting.gl_managed(_company) then return; end if; + if _dest is null or _src is null or coalesce(_amt,0)=0 then return; end if; + insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) + values (_company, (select date from accounting.transactions where transfer_id=_transfer_id limit 1), 'Account transfer', null, 'acmacc_xfer', _transfer_id::text) + returning id into _je; + insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values + (_je, _dest, _amt, 0, 'Transfer in'), + (_je, _src, 0, _amt, 'Transfer out'); +end$$; + +-- Deposit: move cash from Undeposited Funds into the bank (Dr bank / Cr Undeposited). +create or replace function accounting.post_deposit_gl(_deposit_id uuid) returns void +language plpgsql security definer set search_path to 'public','accounting' as $$ +declare d accounting.deposits%rowtype; _und uuid; _je uuid; +begin + select * into d from accounting.deposits where id=_deposit_id; + if not found then return; end if; + perform accounting._gl_clear(d.company_id, 'acmacc_dep', d.id::text); + if not accounting.gl_managed(d.company_id) then return; end if; + if coalesce(d.amount,0)=0 or d.bank_account_id is null then return; end if; + _und := accounting.coa_undeposited(d.company_id); + insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id) + values (d.company_id, d.date, coalesce(nullif(d.memo,''), 'Deposit'), null, 'acmacc_dep', d.id::text) + returning id into _je; + insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values + (_je, d.bank_account_id, d.amount, 0, 'Deposit'), + (_je, _und, 0, d.amount, 'Deposit'); +end$$; + +-- Triggers +create or replace function accounting.tg_transaction_gl() returns trigger +language plpgsql security definer set search_path to 'public','accounting' as $$ +declare r accounting.transactions%rowtype; +begin + begin + r := coalesce(new, old); + if tg_op='DELETE' then + if old.transfer_id is not null then perform accounting.post_transfer_gl(old.transfer_id); + elsif old.deposit_id is not null then perform accounting.post_deposit_gl(old.deposit_id); + else perform accounting._gl_clear(old.company_id,'acmacc_txn',old.id::text); end if; + return old; + end if; + if r.transfer_id is not null then perform accounting.post_transfer_gl(r.transfer_id); + elsif r.deposit_id is not null then perform accounting.post_deposit_gl(r.deposit_id); + else perform accounting.post_transaction_gl(r.id); end if; + exception when others then raise warning 'accounting: txn GL post failed: %', sqlerrm; end; + return coalesce(new, old); +end$$; + +create or replace function accounting.tg_deposit_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_dep',old.id::text); return old; end if; + perform accounting.post_deposit_gl(new.id); + exception when others then raise warning 'accounting: deposit GL post failed: %', sqlerrm; end; + return coalesce(new, old); +end$$; + +drop trigger if exists trg_acct_txn_gl on accounting.transactions; +create trigger trg_acct_txn_gl after insert or update or delete on accounting.transactions + for each row execute function accounting.tg_transaction_gl(); + +drop trigger if exists trg_acct_deposit_gl on accounting.deposits; +create trigger trg_acct_deposit_gl after insert or update or delete on accounting.deposits + for each row execute function accounting.tg_deposit_gl(); + +-- Backfill managed companies +do $$ +declare r record; +begin + for r in select i.id from accounting.invoices i join accounting.companies c on c.id=i.company_id where accounting.gl_managed(c.id) loop + perform accounting.post_invoice_gl(r.id); + end loop; + for r in select b.id from accounting.bills b join accounting.companies c on c.id=b.company_id where accounting.gl_managed(c.id) loop + perform accounting.post_bill_gl(r.id); + end loop; + for r in select p.id from accounting.payments_received p join accounting.companies c on c.id=p.company_id where accounting.gl_managed(c.id) loop + perform accounting.post_payment_gl(r.id); + end loop; + for r in select distinct t.transfer_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 not null loop + perform accounting.post_transfer_gl(r.transfer_id); + end loop; + for r in select distinct t.deposit_id from accounting.transactions t join accounting.companies c on c.id=t.company_id + where accounting.gl_managed(c.id) and t.deposit_id is not null loop + perform accounting.post_deposit_gl(r.deposit_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$$; \ No newline at end of file diff --git a/supabase/migrations/20260602130000_accounting_gl_managed_flag.sql b/supabase/migrations/20260602130000_accounting_gl_managed_flag.sql new file mode 100644 index 0000000..43e9987 --- /dev/null +++ b/supabase/migrations/20260602130000_accounting_gl_managed_flag.sql @@ -0,0 +1,20 @@ +-- Make GL auto-posting an explicit per-company flag instead of inferring it from +-- the presence of non-acmacc journal entries. The old heuristic +-- (accounting.gl_managed = "no foreign/imported journal entries") broke the moment +-- a normal MANUAL journal entry (external_source null) was added to a managed +-- company — it silently flipped the company to "unmanaged" and disabled all GL +-- automation. Companies with a large imported GL are flagged off; platform-managed +-- companies default on. +alter table accounting.companies add column if not exists gl_auto_post boolean not null default true; + +update accounting.companies c set gl_auto_post = false +where ( + select count(*) from accounting.journal_entries je + where je.company_id = c.id + and (je.external_source is null or je.external_source not like 'acmacc_%') +) > 5; + +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 coalesce((select gl_auto_post from accounting.companies where id = _company_id), true); +$$;