mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
2d216e24c9
DB triggers on public.owner_ledger_entries (migration applied to prod): charges (debit) -> accounting.invoices; payments (credit) -> accounting.payments_received (deposited=false, Undeposited Funds). Customer balance recomputed authoritatively from the source ledger; ledger payments FIFO-applied to ledger invoices. Keyed external_source='acmacc_ledger'. Backfilled 6,756 invoices + 4,253 payments; balances reconcile exactly. Frontend: customer Ledger tab now renders real payments_received credits (true dates/amounts); Make Deposit page surfaces undeposited payments_received alongside Undeposited Funds transactions and deposits both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { accounting } from "@/lib/accountingClient";
|
|
import { useCompanyId } from "./lib/useCompanyId";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
import { money, fmtDate } from "./lib/format";
|
|
import { Landmark, Loader2 } from "lucide-react";
|
|
import { EmptyState } from "./components/EmptyState";
|
|
import { ensureUndepositedFunds } from "./lib/undeposited";
|
|
|
|
export default function AccountingDepositsPage() {
|
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
|
const cid = companyId ?? "";
|
|
const cur = "USD";
|
|
const qc = useQueryClient();
|
|
|
|
const [undepositedId, setUndepositedId] = useState<string>("");
|
|
const [bankAccountId, setBankAccountId] = useState("");
|
|
const [depositDate, setDepositDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
|
|
const [memo, setMemo] = useState("");
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!cid) return;
|
|
ensureUndepositedFunds(cid).then((id) => setUndepositedId(id)).catch(() => {});
|
|
}, [cid]);
|
|
|
|
const { data: bankAccounts = [] } = useQuery({
|
|
queryKey: ["bank-accounts", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () =>
|
|
(await accounting.from("accounts").select("id,name,code,balance").eq("company_id", cid).eq("is_bank", true).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.
|
|
const { data: pendingTx = [] } = useQuery({
|
|
queryKey: ["undeposited-tx", cid, undepositedId],
|
|
enabled: !!cid && !!undepositedId,
|
|
queryFn: async () => {
|
|
const { data } = await accounting
|
|
.from("transactions")
|
|
.select("*")
|
|
.eq("company_id", cid)
|
|
.eq("account_id", undepositedId)
|
|
.eq("type", "debit")
|
|
.is("deposit_id", null)
|
|
.order("date", { ascending: false });
|
|
return data ?? [];
|
|
},
|
|
});
|
|
|
|
const { data: pendingPmt = [] } = useQuery({
|
|
queryKey: ["undeposited-pmt", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () => {
|
|
const { data } = await accounting
|
|
.from("payments_received")
|
|
.select("id,payment_date,amount,method,reference,memo,customer_id")
|
|
.eq("company_id", cid)
|
|
.eq("deposited", false)
|
|
.order("payment_date", { ascending: false });
|
|
return data ?? [];
|
|
},
|
|
});
|
|
|
|
type PendingRow = { key: string; kind: "tx" | "pmt"; id: string; date: string; description: string; reference: string | null; amount: number };
|
|
|
|
const pending = useMemo<PendingRow[]>(() => {
|
|
const rows: PendingRow[] = [
|
|
...(pendingTx as any[]).map((t) => ({
|
|
key: `tx:${t.id}`, kind: "tx" as const, id: t.id, date: t.date,
|
|
description: t.description, reference: t.reference ?? null, amount: Number(t.amount),
|
|
})),
|
|
...(pendingPmt as any[]).map((p) => ({
|
|
key: `pmt:${p.id}`, kind: "pmt" as const, id: p.id, date: p.payment_date,
|
|
description: [p.method, p.memo].filter(Boolean).join(" · ") || "Customer payment",
|
|
reference: p.reference ?? null, amount: Number(p.amount),
|
|
})),
|
|
];
|
|
return rows.sort((a, b) => b.date.localeCompare(a.date));
|
|
}, [pendingTx, pendingPmt]);
|
|
|
|
const selectedTotal = useMemo(
|
|
() => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0),
|
|
[pending, selected]
|
|
);
|
|
|
|
const toggleAll = () => {
|
|
if (selected.size === pending.length) setSelected(new Set());
|
|
else setSelected(new Set(pending.map((r) => r.key)));
|
|
};
|
|
|
|
const submitDeposit = async () => {
|
|
if (!bankAccountId) return toast.error("Choose a bank account");
|
|
if (selected.size === 0) return toast.error("Select at least one payment");
|
|
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
|
|
const { data: dep, error: depErr } = await accounting
|
|
.from("deposits")
|
|
.insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: selectedTotal, 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,
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 4) Mark selected payments_received as deposited so they leave the queue
|
|
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`);
|
|
setSelected(new Set());
|
|
setMemo("");
|
|
qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] });
|
|
qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] });
|
|
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
|
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
|
} catch (e: any) {
|
|
toast.error(e?.message ?? "Failed");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Make Deposit</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Select customer payments held in Undeposited Funds and deposit them as a single bank transaction.
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-base">Deposit details</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<div>
|
|
<Label>Deposit to</Label>
|
|
<Select value={bankAccountId} onValueChange={setBankAccountId}>
|
|
<SelectTrigger><SelectValue placeholder="Bank account" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(bankAccounts as any[]).map((a) => (
|
|
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Date</Label>
|
|
<Input type="date" value={depositDate} onChange={(e) => setDepositDate(e.target.value)} />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<Label>Memo</Label>
|
|
<Textarea rows={1} value={memo} onChange={(e) => setMemo(e.target.value)} maxLength={200} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
|
|
<div className="text-sm">
|
|
Selected: <b>{money(selectedTotal, cur)}</b> ({selected.size} of {pending.length})
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.size > 0 && selected.size === pending.length}
|
|
onChange={toggleAll}
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead>Description</TableHead>
|
|
<TableHead>Reference</TableHead>
|
|
<TableHead className="text-right">Amount</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pending.map((r) => (
|
|
<TableRow key={r.key} className="cursor-pointer" onClick={() => {
|
|
const s = new Set(selected);
|
|
s.has(r.key) ? s.delete(r.key) : s.add(r.key);
|
|
setSelected(s);
|
|
}}>
|
|
<TableCell><input type="checkbox" checked={selected.has(r.key)} readOnly /></TableCell>
|
|
<TableCell>{fmtDate(r.date)}</TableCell>
|
|
<TableCell>{r.description}</TableCell>
|
|
<TableCell className="text-muted-foreground">{r.reference ?? "—"}</TableCell>
|
|
<TableCell className="text-right tabular-nums">{money(r.amount, cur)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{pending.length === 0 && (
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableCell colSpan={5} className="p-0">
|
|
<EmptyState
|
|
icon={Landmark}
|
|
title="No payments awaiting deposit"
|
|
description="Customer payments received to Undeposited Funds will appear here."
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end">
|
|
<Button onClick={submitDeposit} disabled={saving || selected.size === 0 || !bankAccountId}>
|
|
{saving ? "Recording…" : `Deposit ${money(selectedTotal, cur)}`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|