Accounting: selectable-source / multi-line manual deposits

Deposits no longer force the credit side through Undeposited Funds — the
structural cause of negative Undeposited balances. A deposit can now credit any
account(s): interest income, a refund, an insurance reimbursement, cash straight
to the bank, etc.

- Schema: add accounting.deposit_lines (deposit_id, company_id, account_id,
  amount, memo) for the credit side, plus deposits.source_account_id as a
  single-source fallback. RLS mirrors deposits (staff + company member).
- post_deposit_gl: Dr bank for the total; Cr each deposit_lines row's account
  for its amount; no lines -> Cr source_account_id; neither -> Cr Undeposited
  Funds (backward compatible — existing deposits stay Dr Bank / Cr Undeposited).
  Remainder safety net keeps the entry balanced. New trg_acct_deposit_line_gl
  re-posts when lines change (header trigger fires before lines exist).
- Make Deposit page: GL-driven submit writes the deposit header + deposit_lines
  and marks selected payments deposited. Adds an "Other deposit lines" grid
  (account + amount + memo) alongside the existing Undeposited selection, with a
  running grand total and a soft guard against over-crediting Undeposited.
  Drops the old bank/Undeposited register-transaction inserts and manual balance
  pokes (never exercised in production; carried a money-in sign bug). Deposits
  are GL-only, consistent with the sync-created deposits already in the DB.

Verified Dr/Cr for single-source and multi-line scenarios against the live GL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:00:41 -04:00
parent 7464d55b6c
commit 8f1cbcd3af
3 changed files with 234 additions and 52 deletions
@@ -0,0 +1,53 @@
-- Manual Deposits: let a deposit's source (credit) account be selectable instead of
-- always Undeposited Funds, and support multi-line deposits. This removes the forced
-- routing through Undeposited Funds (the structural cause of negative Undeposited
-- balances) and lets a deposit book interest income, refunds, reimbursements, etc.
-- Single-source fallback: a deposit with no lines credits this account (default
-- Undeposited Funds when null), keeping the existing "deposit received payments" flow.
alter table accounting.deposits
add column if not exists source_account_id uuid references accounting.accounts(id);
-- Multi-line credits: one deposit = Dr Bank (total) and a set of credit lines, each
-- with its own account and amount. The deposit's amount equals the sum of its lines.
create table if not exists accounting.deposit_lines (
id uuid primary key default gen_random_uuid(),
deposit_id uuid not null references accounting.deposits(id) on delete cascade,
company_id uuid not null references accounting.companies(id) on delete cascade,
account_id uuid not null references accounting.accounts(id),
amount numeric not null default 0,
memo text,
created_at timestamptz not null default now()
);
create index if not exists idx_deposit_lines_deposit on accounting.deposit_lines(deposit_id);
alter table accounting.deposit_lines enable row level security;
drop policy if exists "Accounting staff full access" on accounting.deposit_lines;
create policy "Accounting staff full access" on accounting.deposit_lines
for all to authenticated
using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff());
drop policy if exists "Members CRUD deposit_lines" on accounting.deposit_lines;
create policy "Members CRUD deposit_lines" on accounting.deposit_lines
for all to authenticated
using (accounting.is_company_member(company_id, auth.uid()))
with check (accounting.is_company_member(company_id, auth.uid()));
grant select, insert, update, delete on accounting.deposit_lines to authenticated, service_role;
-- The deposit header trigger posts GL on insert (before any lines exist), so re-post
-- whenever the lines change too. post_deposit_gl clears + reposts, so this is idempotent.
create or replace function accounting.tg_deposit_line_gl()
returns trigger language plpgsql security definer set search_path to 'public', 'accounting' as $$
begin
begin
perform accounting.post_deposit_gl(coalesce(new.deposit_id, old.deposit_id));
exception when others then raise warning 'accounting: deposit line GL post failed: %', sqlerrm; end;
return coalesce(new, old);
end$$;
drop trigger if exists trg_acct_deposit_line_gl on accounting.deposit_lines;
create trigger trg_acct_deposit_line_gl
after insert or update or delete on accounting.deposit_lines
for each row execute function accounting.tg_deposit_line_gl();
@@ -0,0 +1,50 @@
-- post_deposit_gl: credit the chosen source account(s) instead of always Undeposited.
-- Debit the bank for the deposit total; credit each deposit_lines row's account for its
-- amount; if there are no lines, credit source_account_id; if neither is set, fall back
-- to Undeposited Funds (backward compatible with every existing deposit). The GL
-- contract (one journal entry per deposit, cleared by external ref acmacc_dep) is
-- otherwise unchanged.
create or replace function accounting.post_deposit_gl(_deposit_id uuid)
returns void language plpgsql security definer set search_path to 'public', 'accounting' as $function$
declare
d accounting.deposits%rowtype;
_je uuid;
_line_count int;
_line_sum numeric;
_remainder numeric;
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;
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;
-- Debit the bank for the full deposit total.
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
values (_je, d.bank_account_id, d.amount, 0, 'Deposit');
select count(*), coalesce(sum(amount), 0) into _line_count, _line_sum
from accounting.deposit_lines where deposit_id = d.id;
if _line_count > 0 then
-- Credit each line's account for its amount.
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
select _je, dl.account_id, 0, dl.amount, coalesce(nullif(dl.memo, ''), 'Deposit')
from accounting.deposit_lines dl where dl.deposit_id = d.id;
-- Safety net: if the lines don't cover the total, balance the remainder to
-- Undeposited Funds so the entry never posts unbalanced (UI keeps them equal).
_remainder := d.amount - _line_sum;
if _remainder > 0.005 then
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
values (_je, accounting.coa_undeposited(d.company_id), 0, _remainder, 'Deposit');
end if;
else
-- No lines: single-source deposit. Credit source_account_id, else Undeposited Funds.
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
values (_je, coalesce(d.source_account_id, accounting.coa_undeposited(d.company_id)), 0, d.amount, 'Deposit');
end if;
end$function$;