Reconciliation: add transfers, non-vendor expenses, non-owner deposits

- Transfer button records two transfer_id-linked legs (GL via post_transfer_gl);
  this account's leg auto-clears into the open reconciliation
- Withdrawals: vendor now optional so non-vendor expenses (bank fees, gov) post
  Dr Expense / Cr Bank via the category account
- Deposits: optional Owner field — pick to clear A/R, or leave blank for a
  non-owner deposit (interest, refund) booking income

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 15:00:52 -04:00
parent c94057c433
commit 59c23dfec1
@@ -18,7 +18,7 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban, Pencil, RotateCcw } from "lucide-react"; import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban, Pencil, RotateCcw, ArrowLeftRight } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
@@ -95,8 +95,14 @@ export default function AccountingReconcileDetailPage() {
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const [addSaving, setAddSaving] = useState(false); const [addSaving, setAddSaving] = useState(false);
const [addTx, setAddTx] = useState<{ type: "credit" | "debit"; date: string; amount: string; description: string; coa_account_id: string; reference: string; vendor_id: string }>( const [addTx, setAddTx] = useState<{ type: "credit" | "debit"; date: string; amount: string; description: string; coa_account_id: string; reference: string; vendor_id: string; customer_id: string }>(
{ type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "" }, { type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "", customer_id: "" },
);
// Bank-to-bank transfer recorded from the reconciliation screen.
const [transferOpen, setTransferOpen] = useState(false);
const [transferSaving, setTransferSaving] = useState(false);
const [transfer, setTransfer] = useState<{ direction: "out" | "in"; other_account_id: string; date: string; amount: string; memo: string }>(
{ direction: "out", other_account_id: "", date: "", amount: "", memo: "" },
); );
// Add an accrual bill (expense on the bill date) + the withdrawal that pays it. // Add an accrual bill (expense on the bill date) + the withdrawal that pays it.
const [billOpen, setBillOpen] = useState(false); const [billOpen, setBillOpen] = useState(false);
@@ -146,6 +152,19 @@ export default function AccountingReconcileDetailPage() {
(await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [], (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [],
}); });
const { data: customers = [] } = useQuery({
queryKey: ["customers-lookup", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("customers").select("id,name").eq("company_id", cid).order("name")).data ?? [],
});
// Other bank accounts available as the far side of a transfer.
const bankAccounts = useMemo(
() => (allAccounts as any[]).filter((a: any) => a.is_bank && a.id !== accountId),
[allAccounts, accountId],
);
const { data: txs = [] } = useQuery({ const { data: txs = [] } = useQuery({
queryKey: ["recon-txs", accountId, active?.statement_end_date], queryKey: ["recon-txs", accountId, active?.statement_end_date],
enabled: !!accountId && !!active, enabled: !!accountId && !!active,
@@ -157,7 +176,7 @@ export default function AccountingReconcileDetailPage() {
// and not voided. // and not voided.
const { data } = await accounting const { data } = await accounting
.from("transactions") .from("transactions")
.select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided,coa_account_id,vendor_id,category,vendors(name),customers(name)") .select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided,coa_account_id,vendor_id,customer_id,category,vendors(name),customers(name)")
.eq("account_id", accountId) .eq("account_id", accountId)
.is("reconciliation_id", null) .is("reconciliation_id", null)
.eq("voided", false) .eq("voided", false)
@@ -351,7 +370,7 @@ export default function AccountingReconcileDetailPage() {
setAddTx({ setAddTx({
type, type,
date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }), date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "", customer_id: "",
}); });
setAddOpen(true); setAddOpen(true);
}; };
@@ -367,6 +386,7 @@ export default function AccountingReconcileDetailPage() {
coa_account_id: t.coa_account_id ?? "", coa_account_id: t.coa_account_id ?? "",
reference: t.reference ?? "", reference: t.reference ?? "",
vendor_id: t.vendor_id ?? "", vendor_id: t.vendor_id ?? "",
customer_id: t.customer_id ?? "",
}); });
setAddOpen(true); setAddOpen(true);
}; };
@@ -376,10 +396,18 @@ export default function AccountingReconcileDetailPage() {
const amt = Number(addTx.amount); const amt = Number(addTx.amount);
if (!amt || amt <= 0) return toast.error("Enter an amount"); if (!amt || amt <= 0) return toast.error("Enter an amount");
if (!addTx.description.trim()) return toast.error("Enter a description"); if (!addTx.description.trim()) return toast.error("Enter a description");
// Category/vendor are required when creating; on edit they're optional so // A category account is required so the entry posts to the GL. Vendor (on a
// imported (GL) register rows can be edited without re-categorizing. // withdrawal) and owner (on a deposit) are BOTH optional — this is what lets
if (!editId && !addTx.coa_account_id) return toast.error("Pick a category account"); // you record non-vendor expenses and non-owner deposits. On edit everything
if (!editId && addTx.type === "debit" && !addTx.vendor_id) return toast.error("Vendor is required for withdrawals"); // is optional so imported (GL) register rows can be edited without
// re-categorizing. For a deposit an owner can stand in for the category
// (clears A/R instead of booking income).
if (!editId) {
if (addTx.type === "credit" && !addTx.coa_account_id && !addTx.customer_id)
return toast.error("Pick a category account or an owner");
if (addTx.type === "debit" && !addTx.coa_account_id)
return toast.error("Pick a category account");
}
setAddSaving(true); setAddSaving(true);
try { try {
const coaName = (allAccounts as any[]).find((a) => a.id === addTx.coa_account_id)?.name ?? ""; const coaName = (allAccounts as any[]).find((a) => a.id === addTx.coa_account_id)?.name ?? "";
@@ -392,7 +420,8 @@ export default function AccountingReconcileDetailPage() {
amount: Math.abs(amt), amount: Math.abs(amt),
type: addTx.type, type: addTx.type,
reference: addTx.reference.trim() || null, reference: addTx.reference.trim() || null,
vendor_id: addTx.vendor_id || null, vendor_id: addTx.type === "debit" ? (addTx.vendor_id || null) : null,
customer_id: addTx.type === "credit" ? (addTx.customer_id || null) : null,
}; };
if (addTx.coa_account_id) { payload.coa_account_id = addTx.coa_account_id; payload.category = coaName; } if (addTx.coa_account_id) { payload.coa_account_id = addTx.coa_account_id; payload.category = coaName; }
const { error } = await accounting.from("transactions").update(payload).eq("id", editId); const { error } = await accounting.from("transactions").update(payload).eq("id", editId);
@@ -413,8 +442,9 @@ export default function AccountingReconcileDetailPage() {
amount: Math.abs(amt), amount: Math.abs(amt),
type: addTx.type, // credit = deposit (money in), debit = withdrawal (money out) type: addTx.type, // credit = deposit (money in), debit = withdrawal (money out)
category: coaName, category: coaName,
coa_account_id: addTx.coa_account_id, coa_account_id: addTx.coa_account_id || null,
vendor_id: addTx.type === "debit" ? (addTx.vendor_id || null) : null, vendor_id: addTx.type === "debit" ? (addTx.vendor_id || null) : null,
customer_id: addTx.type === "credit" ? (addTx.customer_id || null) : null,
reference: addTx.reference.trim() || null, reference: addTx.reference.trim() || null,
cleared: true, cleared: true,
}) })
@@ -431,6 +461,62 @@ export default function AccountingReconcileDetailPage() {
} }
}; };
const openTransfer = () => {
setTransfer({
direction: "out",
other_account_id: "",
date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
amount: "", memo: "",
});
setTransferOpen(true);
};
// Record a bank-to-bank transfer as two transfer_id-linked legs (the GL trigger
// routes them to post_transfer_gl). The leg on THIS account is auto-cleared into
// the open reconciliation; the far leg lives on the other account's register.
const saveTransfer = async () => {
if (!active) return;
const amt = Number(transfer.amount);
if (!amt || amt <= 0) return toast.error("Enter an amount");
if (!transfer.other_account_id) return toast.error("Pick the other account");
if (transfer.other_account_id === accountId) return toast.error("Accounts must be different");
setTransferSaving(true);
try {
const transferId = crypto.randomUUID();
const otherAcc = (allAccounts as any[]).find((a) => a.id === transfer.other_account_id);
const thisAcc = account as any;
// "out" = money leaves THIS account (debit here / credit the other).
const thisType: "credit" | "debit" = transfer.direction === "out" ? "debit" : "credit";
const otherType: "credit" | "debit" = transfer.direction === "out" ? "credit" : "debit";
const otherName = otherAcc?.name ?? "account";
const thisName = thisAcc?.name ?? "account";
const memo = transfer.memo.trim();
const rows = [
{
company_id: cid, account_id: accountId, date: transfer.date, type: thisType, amount: amt,
description: `Transfer ${transfer.direction === "out" ? "to" : "from"} ${otherName}${memo ? `${memo}` : ""}`,
category: "Transfer", transfer_id: transferId, cleared: true,
},
{
company_id: cid, account_id: transfer.other_account_id, date: transfer.date, type: otherType, amount: amt,
description: `Transfer ${transfer.direction === "out" ? "from" : "to"} ${thisName}${memo ? `${memo}` : ""}`,
category: "Transfer", transfer_id: transferId,
},
];
const { data, error } = await accounting.from("transactions").insert(rows).select("id,account_id");
if (error || !data) return toast.error(error?.message ?? "Failed to record transfer");
setTransferOpen(false);
toast.success("Transfer recorded");
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
const thisLeg = (data as any[]).find((r) => r.account_id === accountId);
if (thisLeg) setChecked((prev) => new Set(prev).add(thisLeg.id)); // auto-clear into this reconciliation
} finally {
setTransferSaving(false);
}
};
const openBill = () => { const openBill = () => {
const today = active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); const today = active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" });
setBill({ bill_date: today, payment_date: today, amount: "", description: "", coa_account_id: "", vendor_id: "", reference: "" }); setBill({ bill_date: today, payment_date: today, amount: "", description: "", coa_account_id: "", vendor_id: "", reference: "" });
@@ -606,6 +692,9 @@ export default function AccountingReconcileDetailPage() {
<Button size="sm" variant="outline" className="h-9 gap-1" onClick={openBill}> <Button size="sm" variant="outline" className="h-9 gap-1" onClick={openBill}>
<Plus className="h-3.5 w-3.5" /> Bill <Plus className="h-3.5 w-3.5" /> Bill
</Button> </Button>
<Button size="sm" variant="outline" className="h-9 gap-1" onClick={openTransfer}>
<ArrowLeftRight className="h-3.5 w-3.5" /> Transfer
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -925,16 +1014,28 @@ export default function AccountingReconcileDetailPage() {
</div> </div>
{addTx.type === "debit" && ( {addTx.type === "debit" && (
<div> <div>
<Label>Vendor <span className="text-destructive">*</span></Label> <Label>Vendor <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Select value={addTx.vendor_id} onValueChange={(v) => setAddTx({ ...addTx, vendor_id: v })}> <Select value={addTx.vendor_id || "__none"} onValueChange={(v) => setAddTx({ ...addTx, vendor_id: v === "__none" ? "" : v })}>
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="No vendor" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none">No vendor</SelectItem>
{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)} {(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
{(vendors as any[]).length === 0 && ( <p className="mt-1 text-xs text-muted-foreground">Leave blank for a non-vendor expense (e.g. bank fee, government).</p>
<p className="mt-1 text-xs text-muted-foreground">No vendors yet — add one on the Vendors page first.</p> </div>
)} )}
{addTx.type === "credit" && (
<div>
<Label>Owner <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Select value={addTx.customer_id || "__none"} onValueChange={(v) => setAddTx({ ...addTx, customer_id: v === "__none" ? "" : v })}>
<SelectTrigger><SelectValue placeholder="No owner" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">No owner</SelectItem>
{(customers as any[]).map((c: any) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">Pick an owner to clear A/R, or leave blank for a non-owner deposit (interest, refund, etc.).</p>
</div> </div>
)} )}
<div> <div>
@@ -1020,6 +1121,64 @@ export default function AccountingReconcileDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Transfer modal */}
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Record Transfer</DialogTitle>
<DialogDescription>
Move money between {(account as any)?.name ?? "this account"} and another bank account. Both legs are
recorded; the one on this account is added to this reconciliation automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Direction</Label>
<Select value={transfer.direction} onValueChange={(v) => setTransfer({ ...transfer, direction: v as "out" | "in" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="out">Transfer out (money leaves this account)</SelectItem>
<SelectItem value="in">Transfer in (money enters this account)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{transfer.direction === "out" ? "To account" : "From account"}</Label>
<Select value={transfer.other_account_id} onValueChange={(v) => setTransfer({ ...transfer, other_account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select bank account" /></SelectTrigger>
<SelectContent>
{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>)}
</SelectContent>
</Select>
{bankAccounts.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">No other bank accounts found for this association.</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date</Label>
<Input type="date" value={transfer.date} onChange={(e) => setTransfer({ ...transfer, date: e.target.value })} />
</div>
<div>
<Label>Amount</Label>
<Input type="number" step="0.01" min="0" value={transfer.amount} onChange={(e) => setTransfer({ ...transfer, amount: e.target.value })} placeholder="0.00" />
</div>
</div>
<div>
<Label>Memo (optional)</Label>
<Input value={transfer.memo} onChange={(e) => setTransfer({ ...transfer, memo: e.target.value })} placeholder="e.g. Move to reserves" />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setTransferOpen(false)}>Cancel</Button>
<Button onClick={saveTransfer} disabled={transferSaving}>
{transferSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Record Transfer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Adjustment modal */} {/* Adjustment modal */}
<Dialog open={adjOpen} onOpenChange={setAdjOpen}> <Dialog open={adjOpen} onOpenChange={setAdjOpen}>
<DialogContent> <DialogContent>