mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -11,10 +11,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { money, fmtDate } from "./lib/format";
|
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 { EmptyState } from "./components/EmptyState";
|
||||||
import { ensureUndepositedFunds } from "./lib/undeposited";
|
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() {
|
export default function AccountingDepositsPage() {
|
||||||
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
const cid = companyId ?? "";
|
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 [depositDate, setDepositDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
|
||||||
const [memo, setMemo] = useState("");
|
const [memo, setMemo] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [lines, setLines] = useState<ManualLine[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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 ?? [],
|
(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
|
// Two sources of "awaiting deposit": transactions parked on the Undeposited
|
||||||
// Funds account (banking flow) and payments_received not yet deposited (incl.
|
// Funds account (banking flow) and payments_received not yet deposited (incl.
|
||||||
// payments synced from the main app's owner ledger). Both are unified below.
|
// 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));
|
return rows.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
}, [pendingTx, pendingPmt]);
|
}, [pendingTx, pendingPmt]);
|
||||||
|
|
||||||
const selectedTotal = useMemo(
|
const undepositedTotal = useMemo(
|
||||||
() => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0),
|
() => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0),
|
||||||
[pending, selected]
|
[pending, selected]
|
||||||
);
|
);
|
||||||
|
const manualTotal = useMemo(
|
||||||
|
() => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0),
|
||||||
|
[lines]
|
||||||
|
);
|
||||||
|
const grandTotal = undepositedTotal + manualTotal;
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selected.size === pending.length) setSelected(new Set());
|
if (selected.size === pending.length) setSelected(new Set());
|
||||||
else setSelected(new Set(pending.map((r) => r.key)));
|
else setSelected(new Set(pending.map((r) => r.key)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addLine = () => setLines((ls) => [...ls, { ...EMPTY_LINE }]);
|
||||||
|
const updateLine = (i: number, patch: Partial<ManualLine>) =>
|
||||||
|
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 () => {
|
const submitDeposit = async () => {
|
||||||
if (!bankAccountId) return toast.error("Choose a bank account");
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const bank = (bankAccounts as any[]).find((a) => a.id === bankAccountId);
|
|
||||||
const chosen = pending.filter((r) => selected.has(r.key));
|
const chosen = pending.filter((r) => selected.has(r.key));
|
||||||
const txIds = chosen.filter((r) => r.kind === "tx").map((r) => r.id);
|
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 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
|
const { data: dep, error: depErr } = await accounting
|
||||||
.from("deposits")
|
.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()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (depErr || !dep) throw new Error(depErr?.message ?? "Failed to create deposit");
|
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
|
// 2) Deposit lines (credit side). The selected payments collapse into one
|
||||||
await accounting.from("transactions").insert({
|
// Undeposited Funds line; manual lines book to their chosen accounts.
|
||||||
company_id: cid,
|
// accounting.post_deposit_gl posts Dr Bank (total) / Cr each line.
|
||||||
account_id: bankAccountId,
|
const lineRows: any[] = [];
|
||||||
date: depositDate,
|
if (undepositedTotal > 0 && undepositedId) {
|
||||||
type: "debit",
|
lineRows.push({
|
||||||
amount: selectedTotal,
|
deposit_id: dep.id, company_id: cid, account_id: undepositedId,
|
||||||
description: `Deposit · ${count} payment${count > 1 ? "s" : ""}${memo ? " · " + memo : ""}`,
|
amount: undepositedTotal, memo: `Cleared ${chosen.length} payment${chosen.length !== 1 ? "s" : ""}`,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
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();
|
for (const l of cleanLines) {
|
||||||
if (und) {
|
lineRows.push({ deposit_id: dep.id, company_id: cid, account_id: l.account_id, amount: Number(l.amount), memo: l.memo || null });
|
||||||
await accounting.from("accounts").update({ balance: Number(und.balance) - txTotal }).eq("id", undepositedId);
|
}
|
||||||
}
|
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) {
|
if (pmtIds.length) {
|
||||||
await accounting.from("payments_received")
|
await accounting.from("payments_received")
|
||||||
.update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId })
|
.update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId })
|
||||||
.in("id", pmtIds);
|
.in("id", pmtIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Bank balance reflects the full deposit
|
toast.success(`Deposit of ${money(grandTotal, cur)} recorded`);
|
||||||
if (bank) {
|
|
||||||
await accounting.from("accounts").update({ balance: Number(bank.balance) + selectedTotal }).eq("id", bank.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`Deposit of ${money(selectedTotal, cur)} recorded`);
|
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
|
setLines([]);
|
||||||
setMemo("");
|
setMemo("");
|
||||||
qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] });
|
qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] });
|
qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
|
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["all-accounts", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -191,7 +215,8 @@ export default function AccountingDepositsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Make Deposit</h1>
|
<h1 className="text-2xl font-semibold">Make Deposit</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -226,7 +251,7 @@ export default function AccountingDepositsPage() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
|
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Selected: <b>{money(selectedTotal, cur)}</b> ({selected.size} of {pending.length})
|
Selected: <b>{money(undepositedTotal, cur)}</b> ({selected.size} of {pending.length})
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -276,9 +301,63 @@ export default function AccountingDepositsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Card>
|
||||||
<Button onClick={submitDeposit} disabled={saving || selected.size === 0 || !bankAccountId}>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
{saving ? "Recording…" : `Deposit ${money(selectedTotal, cur)}`}
|
<div>
|
||||||
|
<CardTitle className="text-base">Other deposit lines</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Book a deposit directly to an account — interest income, a refund, a reimbursement, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={addLine}><Plus className="h-4 w-4 mr-1" />Add line</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No additional lines. Add one to deposit to a specific account.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<div key={i} className="grid gap-2 md:grid-cols-12 items-center">
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<Select value={l.account_id} onValueChange={(v) => updateLine(i, { account_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Source account" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(allAccounts as any[]).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.code ? `${a.code} · ` : ""}{a.name}<span className="text-muted-foreground"> · {a.type}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-4">
|
||||||
|
<Input value={l.memo} placeholder="Memo (optional)" onChange={(e) => updateLine(i, { memo: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Input
|
||||||
|
type="number" inputMode="decimal" step="0.01" placeholder="0.00"
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
value={l.amount} onChange={(e) => updateLine(i, { amount: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-1 flex justify-end">
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => removeLine(i)} aria-label="Remove line">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{money(undepositedTotal, cur)} from Undeposited + {money(manualTotal, cur)} direct
|
||||||
|
</div>
|
||||||
|
<Button onClick={submitDeposit} disabled={saving || grandTotal <= 0 || !bankAccountId}>
|
||||||
|
{saving ? "Recording…" : `Deposit ${money(grandTotal, cur)}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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$;
|
||||||
Reference in New Issue
Block a user