From 8f1cbcd3afa4d0f0ae2e050bec07c3a8f248d8c9 Mon Sep 17 00:00:00 2001 From: renee-png Date: Thu, 4 Jun 2026 13:00:41 -0400 Subject: [PATCH] Accounting: selectable-source / multi-line manual deposits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../accounting/AccountingDepositsPage.tsx | 183 +++++++++++++----- ...130000_deposit_lines_selectable_source.sql | 53 +++++ ...604130500_post_deposit_gl_multi_source.sql | 50 +++++ 3 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 supabase/migrations/20260604130000_deposit_lines_selectable_source.sql create mode 100644 supabase/migrations/20260604130500_post_deposit_gl_multi_source.sql diff --git a/src/pages/accounting/AccountingDepositsPage.tsx b/src/pages/accounting/AccountingDepositsPage.tsx index cd742ce..2ccd900 100644 --- a/src/pages/accounting/AccountingDepositsPage.tsx +++ b/src/pages/accounting/AccountingDepositsPage.tsx @@ -11,10 +11,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Textarea } from "@/components/ui/textarea"; import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; -import { Landmark, Loader2 } from "lucide-react"; +import { Landmark, Loader2, Plus, Trash2 } from "lucide-react"; import { EmptyState } from "./components/EmptyState"; import { ensureUndepositedFunds } from "./lib/undeposited"; +type ManualLine = { account_id: string; amount: string; memo: string }; +const EMPTY_LINE: ManualLine = { account_id: "", amount: "", memo: "" }; + export default function AccountingDepositsPage() { const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); const cid = companyId ?? ""; @@ -26,6 +29,7 @@ export default function AccountingDepositsPage() { const [depositDate, setDepositDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); const [memo, setMemo] = useState(""); const [selected, setSelected] = useState>(new Set()); + const [lines, setLines] = useState([]); const [saving, setSaving] = useState(false); useEffect(() => { @@ -40,6 +44,15 @@ export default function AccountingDepositsPage() { (await accounting.from("accounts").select("id,name,code,balance").eq("company_id", cid).eq("is_bank", true).order("code")).data ?? [], }); + // All accounts — for the source-account picker on deposit lines (income, A/R, + // reserve, clearing, etc.), so a deposit isn't forced through Undeposited Funds. + const { data: allAccounts = [] } = useQuery({ + queryKey: ["all-accounts", cid], + enabled: !!cid, + queryFn: async () => + (await accounting.from("accounts").select("id,name,code,type,balance").eq("company_id", cid).order("type").order("code")).data ?? [], + }); + // Two sources of "awaiting deposit": transactions parked on the Undeposited // Funds account (banking flow) and payments_received not yet deposited (incl. // payments synced from the main app's owner ledger). Both are unified below. @@ -90,89 +103,100 @@ export default function AccountingDepositsPage() { return rows.sort((a, b) => b.date.localeCompare(a.date)); }, [pendingTx, pendingPmt]); - const selectedTotal = useMemo( + const undepositedTotal = useMemo( () => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0), [pending, selected] ); + const manualTotal = useMemo( + () => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0), + [lines] + ); + const grandTotal = undepositedTotal + manualTotal; const toggleAll = () => { if (selected.size === pending.length) setSelected(new Set()); else setSelected(new Set(pending.map((r) => r.key))); }; + const addLine = () => setLines((ls) => [...ls, { ...EMPTY_LINE }]); + const updateLine = (i: number, patch: Partial) => + setLines((ls) => ls.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); + const removeLine = (i: number) => setLines((ls) => ls.filter((_, idx) => idx !== i)); + const submitDeposit = async () => { if (!bankAccountId) return toast.error("Choose a bank account"); - if (selected.size === 0) return toast.error("Select at least one payment"); + if (grandTotal <= 0) return toast.error("Add at least one payment or deposit line"); + + // Validate manual lines: each must have an account and a positive amount. + const cleanLines = lines.filter((l) => l.account_id || Number(l.amount)); + for (const l of cleanLines) { + if (!l.account_id) return toast.error("Every deposit line needs a source account"); + if (!(Number(l.amount) > 0)) return toast.error("Every deposit line needs a positive amount"); + } + + // Optional guard: warn (non-blocking) if crediting Undeposited Funds would exceed + // what is currently held there — i.e. depositing more than is sitting in it. + const manualToUndeposited = cleanLines + .filter((l) => l.account_id === undepositedId) + .reduce((s, l) => s + Number(l.amount), 0); + const toUndeposited = undepositedTotal + manualToUndeposited; + if (toUndeposited > 0 && undepositedId) { + const held = Number((allAccounts as any[]).find((a) => a.id === undepositedId)?.balance ?? 0); + if (toUndeposited > held + 0.005) { + toast.warning(`Crediting ${money(toUndeposited, cur)} to Undeposited Funds, which holds ${money(held, cur)}.`); + } + } + setSaving(true); try { - const bank = (bankAccounts as any[]).find((a) => a.id === bankAccountId); const chosen = pending.filter((r) => selected.has(r.key)); const txIds = chosen.filter((r) => r.kind === "tx").map((r) => r.id); const pmtIds = chosen.filter((r) => r.kind === "pmt").map((r) => r.id); - const txTotal = chosen.filter((r) => r.kind === "tx").reduce((s, r) => s + r.amount, 0); - const count = chosen.length; - // 1) Create deposit record + // 1) Deposit header — amount is the sum of all credit lines. const { data: dep, error: depErr } = await accounting .from("deposits") - .insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: selectedTotal, memo: memo || null }) + .insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: grandTotal, memo: memo || null }) .select() .single(); if (depErr || !dep) throw new Error(depErr?.message ?? "Failed to create deposit"); - const ref = `DEP-${dep.id.slice(0, 8).toUpperCase()}`; - // 2) Single debit on bank account for the full deposit - await accounting.from("transactions").insert({ - company_id: cid, - account_id: bankAccountId, - date: depositDate, - type: "debit", - amount: selectedTotal, - description: `Deposit · ${count} payment${count > 1 ? "s" : ""}${memo ? " · " + memo : ""}`, - category: "Deposit", - reference: ref, - deposit_id: dep.id, - }); - - // 3) Offsetting credit on Undeposited Funds — only for the portion actually - // held there as transactions (payments_received aren't posted to it). - if (txTotal > 0) { - await accounting.from("transactions").insert({ - company_id: cid, - account_id: undepositedId, - date: depositDate, - type: "credit", - amount: txTotal, - description: `Deposit cleared · ${txIds.length} payment${txIds.length > 1 ? "s" : ""}`, - category: "Deposit", - reference: ref, - deposit_id: dep.id, + // 2) Deposit lines (credit side). The selected payments collapse into one + // Undeposited Funds line; manual lines book to their chosen accounts. + // accounting.post_deposit_gl posts Dr Bank (total) / Cr each line. + const lineRows: any[] = []; + if (undepositedTotal > 0 && undepositedId) { + lineRows.push({ + deposit_id: dep.id, company_id: cid, account_id: undepositedId, + amount: undepositedTotal, memo: `Cleared ${chosen.length} payment${chosen.length !== 1 ? "s" : ""}`, }); - await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds); - const { data: und } = await accounting.from("accounts").select("balance").eq("id", undepositedId).single(); - if (und) { - await accounting.from("accounts").update({ balance: Number(und.balance) - txTotal }).eq("id", undepositedId); - } + } + for (const l of cleanLines) { + lineRows.push({ deposit_id: dep.id, company_id: cid, account_id: l.account_id, amount: Number(l.amount), memo: l.memo || null }); + } + if (lineRows.length) { + const { error: lineErr } = await accounting.from("deposit_lines").insert(lineRows); + if (lineErr) throw new Error(lineErr.message); } - // 4) Mark selected payments_received as deposited so they leave the queue + // 3) Clear the deposited items from the awaiting-deposit queue. + if (txIds.length) { + await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds); + } if (pmtIds.length) { await accounting.from("payments_received") .update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId }) .in("id", pmtIds); } - // 5) Bank balance reflects the full deposit - if (bank) { - await accounting.from("accounts").update({ balance: Number(bank.balance) + selectedTotal }).eq("id", bank.id); - } - - toast.success(`Deposit of ${money(selectedTotal, cur)} recorded`); + toast.success(`Deposit of ${money(grandTotal, cur)} recorded`); setSelected(new Set()); + setLines([]); setMemo(""); qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] }); qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] }); qc.invalidateQueries({ queryKey: ["bank-accounts", cid] }); + qc.invalidateQueries({ queryKey: ["all-accounts", cid] }); qc.invalidateQueries({ queryKey: ["accounts", cid] }); qc.invalidateQueries({ queryKey: ["transactions", cid] }); } catch (e: any) { @@ -191,7 +215,8 @@ export default function AccountingDepositsPage() {

Make Deposit

- Select customer payments held in Undeposited Funds and deposit them as a single bank transaction. + Deposit customer payments held in Undeposited Funds, or record a deposit straight to an + income, A/R, or other account by adding deposit lines.

@@ -226,7 +251,7 @@ export default function AccountingDepositsPage() { Payments awaiting deposit
- Selected: {money(selectedTotal, cur)} ({selected.size} of {pending.length}) + Selected: {money(undepositedTotal, cur)} ({selected.size} of {pending.length})
@@ -276,9 +301,63 @@ export default function AccountingDepositsPage() { -
- + + + {lines.length === 0 ? ( +

No additional lines. Add one to deposit to a specific account.

+ ) : ( +
+ {lines.map((l, i) => ( +
+
+ +
+
+ updateLine(i, { memo: e.target.value })} /> +
+
+ updateLine(i, { amount: e.target.value })} + /> +
+
+ +
+
+ ))} +
+ )} +
+ + +
+
+ {money(undepositedTotal, cur)} from Undeposited + {money(manualTotal, cur)} direct +
+
diff --git a/supabase/migrations/20260604130000_deposit_lines_selectable_source.sql b/supabase/migrations/20260604130000_deposit_lines_selectable_source.sql new file mode 100644 index 0000000..078e2ff --- /dev/null +++ b/supabase/migrations/20260604130000_deposit_lines_selectable_source.sql @@ -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(); diff --git a/supabase/migrations/20260604130500_post_deposit_gl_multi_source.sql b/supabase/migrations/20260604130500_post_deposit_gl_multi_source.sql new file mode 100644 index 0000000..31e098a --- /dev/null +++ b/supabase/migrations/20260604130500_post_deposit_gl_multi_source.sql @@ -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$;