Files
acmcc/src/pages/accounting/AccountingBankingPage.tsx
T
admin f66165a8f5 Checks: Banking print now applies field positions + MICR gaps
Banking's generateCheckPDF opts omitted fieldPositions (and micr gaps), so the
per-field x/y position adjustments saved in Check Setup had no effect on checks
printed from Banking. Pass them through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:50:38 -04:00

1135 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "./lib/useCompanyId";
import { useAuth } from "@/contexts/AuthContext";
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 { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2, Pencil, ArrowLeftRight, Printer, Link2, RefreshCw, Unlink, FileUp, Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import { generateCheckPDF } from "./lib/checkPdf";
import { parseCsv, pick, parseDateStr } from "./lib/csv";
import { usePlaidLink } from "react-plaid-link";
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill";
type TxForm = {
account_id: string;
date: string;
amount: string;
type: "credit" | "debit";
reference: string;
coa_account_id: string;
vendor_id: string;
customer_id: string;
memo: string;
printCheck: boolean;
};
type TransferForm = {
from_account_id: string;
to_account_id: string;
date: string;
amount: string;
memo: string;
};
const EMPTY_TX: TxForm = {
account_id: "",
date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
amount: "",
type: "credit",
reference: "",
coa_account_id: "",
vendor_id: "",
customer_id: "",
memo: "",
printCheck: false,
};
const EMPTY_TRANSFER: TransferForm = {
from_account_id: "",
to_account_id: "",
date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
amount: "",
memo: "Account Transfer",
};
export default function AccountingBankingPage() {
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId();
const { user } = useAuth();
const cid = companyId ?? "";
const cur = "USD";
const qc = useQueryClient();
const [plaidLinkToken, setPlaidLinkToken] = useState<string | null>(null);
const [plaidTargetAcct, setPlaidTargetAcct] = useState<string>("");
const [syncingAcctId, setSyncingAcctId] = useState<string | null>(null);
const { data: plaidConnections = [] } = useQuery({
queryKey: ["plaid-connections", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("plaid_connections").select("*").eq("company_id", cid)).data ?? [],
});
const plaidByAcct = new Map((plaidConnections as any[]).map((c) => [c.account_id, c]));
const openPlaidLink = async (accountId: string) => {
if (!user?.id) return toast.error("Must be logged in");
setPlaidTargetAcct(accountId);
try {
const { link_token } = await createLinkToken(user.id, cid);
setPlaidLinkToken(link_token);
} catch (e: any) {
toast.error(e?.message ?? "Failed to start Plaid Link");
}
};
const syncAccount = async (accountId: string) => {
setSyncingAcctId(accountId);
try {
const result = await syncPlaidTransactions(cid, accountId);
toast.success(`Synced: +${result.added} new, ${result.modified} updated, ${result.removed} removed`);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
} catch (e: any) {
toast.error(e?.message ?? "Sync failed");
} finally {
setSyncingAcctId(null);
}
};
const disconnectAccount = async (accountId: string) => {
if (!confirm("Disconnect this bank feed? Existing transactions are kept.")) return;
try {
await disconnectPlaid(accountId);
toast.success("Bank feed disconnected");
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
} catch (e: any) {
toast.error(e?.message ?? "Disconnect failed");
}
};
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [txDialog, setTxDialog] = useState<{ open: boolean; mode: "deposit" | "payment" | "edit" }>({ open: false, mode: "deposit" });
const [editId, setEditId] = useState<string | null>(null);
const [txForm, setTxForm] = useState<TxForm>(EMPTY_TX);
const [transferOpen, setTransferOpen] = useState(false);
const [transfer, setTransfer] = useState<TransferForm>(EMPTY_TRANSFER);
const [acctDialog, setAcctDialog] = useState(false);
const [acctForm, setAcctForm] = useState({ name: "", code: "", type: "asset" as const, is_bank: true });
const [search, setSearch] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{ inserted: number; skipped: number } | null>(null);
const importFileRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const { data: accounts = [] } = useQuery({
queryKey: ["accounts", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("accounts").select("*").eq("company_id", cid).order("code")).data ?? [],
});
const bankAccounts = useMemo(() => (accounts as any[]).filter((a) => a.is_bank), [accounts]);
const incomeAccounts = useMemo(() => (accounts as any[]).filter((a) => a.type === "income"), [accounts]);
const expenseAccounts = useMemo(() => (accounts as any[]).filter((a) => a.type === "expense"), [accounts]);
const { data: vendors = [] } = useQuery({
queryKey: ["vendors-lookup", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("vendors").select("id,name,address").eq("company_id", cid).order("name")).data ?? [],
});
const { data: customers = [] } = useQuery({
queryKey: ["customers", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("customers").select("id,name").eq("company_id", cid).order("name")).data ?? [],
});
const { data: checkSettings } = useQuery({
queryKey: ["check-settings", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("check_settings").select("*").eq("company_id", cid).maybeSingle()).data,
});
const activeAccountId = selectedAccountId || bankAccounts[0]?.id || "";
const { data: txs = [] } = useQuery({
queryKey: ["transactions", cid, activeAccountId],
enabled: !!cid && !!activeAccountId,
queryFn: async () =>
(
await accounting
.from("transactions")
.select("*, bank_account:accounts!account_id(name), coa:accounts!coa_account_id(name), vendors(name), customers(name)")
.eq("company_id", cid)
.eq("account_id", activeAccountId)
.order("date", { ascending: true })
.order("created_at", { ascending: true })
).data ?? [],
});
const register = useMemo(() => {
let bal = 0;
return (txs as any[]).map((tx) => {
const amt = Number(tx.amount ?? 0);
if (tx.type === "credit") bal += amt;
else bal -= amt;
return { ...tx, running: bal };
});
}, [txs]);
const filteredRegister = useMemo(() => {
if (!search.trim()) return register;
const q = search.toLowerCase();
return register.filter(
(r) =>
r.description?.toLowerCase().includes(q) ||
r.category?.toLowerCase().includes(q) ||
r.reference?.toLowerCase().includes(q)
);
}, [register, search]);
const computedBalance = register.length > 0 ? register[register.length - 1].running : 0;
const activeAccount = (accounts as any[]).find((a) => a.id === activeAccountId);
const selectableIds = useMemo(
() => filteredRegister.filter((r: any) => !r.reconciliation_id && !r.transfer_id).map((r: any) => r.id),
[filteredRegister]
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id: string) => selected.has(id));
useEffect(() => { setSelected(new Set()); }, [activeAccountId]);
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableIds));
const toggleOne = (id: string) =>
setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const bulkSetCategory = async (accountId: string) => {
const acc = (accounts as any[]).find((a) => a.id === accountId);
if (!acc || selected.size === 0) return;
const ids = [...selected];
const rows = (register as any[]).filter((r) => selected.has(r.id));
// Accrual A/P: before categorizing a vendor debit to an expense account, check
// whether it actually settles an open bill (matchOpenBills). A debit that
// uniquely matches one is a bill payment — clear A/P (coa null, link the bill)
// instead of re-hitting the expense, which was already booked on the bill.
// • exactly one match → auto-apply to the bill
// • more than one match → leave for the user to resolve in Pay Bills (skip)
// • no match → categorize as a direct expense (below)
const billPayments: { id: string; bill: any; amount: number }[] = [];
let ambiguous = 0;
for (const r of rows) {
if (r.type !== "debit" || !r.vendor_id) continue;
const matches = await matchOpenBills({ companyId: cid, vendorId: r.vendor_id, amount: Number(r.amount), date: r.date });
if (matches.length === 1) billPayments.push({ id: r.id, bill: matches[0], amount: Number(r.amount) });
else if (matches.length > 1) ambiguous++;
}
const handled = new Set([...billPayments.map((p) => p.id)]);
const categorizeIds = ids.filter((id) => !handled.has(id));
if (categorizeIds.length) {
const { error } = await accounting.from("transactions")
.update({ coa_account_id: accountId, category: acc.name }).in("id", categorizeIds);
if (error) return toast.error(error.message);
}
for (const p of billPayments) {
const bal = Number(p.bill.total) - Number(p.bill.paid_amount ?? 0);
const { error } = await accounting.from("transactions")
.update({ coa_account_id: null, bill_id: p.bill.id, category: `Bill Payment · ${p.bill.number}` })
.eq("id", p.id);
if (error) return toast.error(error.message);
await applyPaymentToBill(p.bill.id, Math.min(p.amount, bal));
}
const parts: string[] = [];
if (categorizeIds.length) parts.push(`categorized ${categorizeIds.length}`);
if (billPayments.length) parts.push(`applied ${billPayments.length} to open bills`);
toast.success(parts.length ? parts.join(", ") : "No changes");
if (ambiguous) toast.warning(`${ambiguous} debit${ambiguous !== 1 ? "s" : ""} match multiple open bills — resolve in Pay Bills`);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
if (billPayments.length) qc.invalidateQueries({ queryKey: ["bills", cid] });
setSelected(new Set());
};
const bulkSetDirection = async (type: "debit" | "credit") => {
if (selected.size === 0) return;
const ids = [...selected];
const { error } = await accounting.from("transactions").update({ type }).in("id", ids);
if (error) return toast.error(error.message);
toast.success(`Set ${ids.length} transaction${ids.length !== 1 ? "s" : ""} to ${type === "credit" ? "deposit" : "payment"}`);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
const saveTx = async () => {
const { account_id, type, reference, date, coa_account_id, vendor_id, customer_id, memo } = txForm;
const amount = Math.abs(Number(txForm.amount));
if (!account_id) return toast.error("Bank account required");
if (!amount || amount <= 0) return toast.error("Amount can't be zero");
if (!coa_account_id) return toast.error(`${type === "credit" ? "Income" : "Expense"} account (COA) required`);
if (type === "debit" && !vendor_id) return toast.error("Vendor required for payments");
if (type === "credit" && !customer_id) return toast.error("Homeowner required for deposits");
const coaName = (accounts as any[]).find((a) => a.id === coa_account_id)?.name ?? "";
const vendorName = (vendors as any[]).find((v) => v.id === vendor_id)?.name ?? "";
const customerName = (customers as any[]).find((c) => c.id === customer_id)?.name ?? "";
const partyName = type === "debit" ? vendorName : customerName;
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
const category = coaName;
// Vendor-payment recognition rule: count the expense for the bill when it is
// entered (accrual), or — when no bill exists — when the payment is made.
// • Debit matches an OPEN bill (matchOpenBills: same vendor, amount within
// $0.01 or partial, date within ±30 days) → this payment clears Accounts
// Payable (coa null, vendor set → post_transaction_gl posts Dr A/P / Cr
// Bank); the expense was already recognized on the bill. We then apply it
// to the matched bill(s).
// • No matching bill → the payment IS the expense: keep the chosen expense
// account so it posts Dr Expense / Cr Bank on the payment date.
// Customer deposits (credits) clear A/R via customer_id and are unchanged.
const matchedBills = type === "debit" && vendor_id
? await matchOpenBills({ companyId: cid, vendorId: vendor_id, amount, date })
: [];
const debitClearsAp = matchedBills.length > 0;
const payload: any = {
account_id, date, description, amount, type, category, reference: reference || null,
coa_account_id: debitClearsAp ? null : (coa_account_id || null),
vendor_id: vendor_id || null,
customer_id: customer_id || null,
// Link the settled bill only when a single bill matches (the guard requires
// coa null on any bill-linked row, which holds here). Multi-bill payments
// stay unlinked but still clear A/P via the vendor branch.
bill_id: matchedBills.length === 1 ? matchedBills[0].id : null,
};
if (editId) {
const { error } = await accounting.from("transactions").update(payload).eq("id", editId);
if (error) return toast.error(error.message);
toast.success("Transaction updated");
} else {
const { data: inserted, error } = await accounting
.from("transactions").insert({ ...payload, company_id: cid }).select("id").single();
if (error || !inserted) return toast.error(error?.message ?? "Failed to record");
toast.success(type === "credit" ? "Deposit recorded" : "Payment recorded");
if (type === "debit" && txForm.printCheck) {
const vendorRecord = (vendors as any[]).find((v) => v.id === vendor_id);
await printCheckForPayment({
vendorId: vendor_id,
vendorName: vendorRecord?.name ?? "Payee",
vendorAddress: vendorRecord?.address ?? undefined,
amount,
date,
memo: memo || coaName,
checkNumber: parseInt(txForm.reference) || (checkSettings as any)?.next_check_number || 1001,
bankAccountId: account_id,
});
}
// When the payment cleared A/P, apply it to the matched bill(s) oldest-first
// so they show paid. The expense lives on the bill, so no expense is booked
// here. With no matching bill the payment already posted the expense directly
// (above) — nothing further to do.
if (debitClearsAp) {
let remaining = amount;
for (const b of matchedBills) {
if (remaining <= 0.005) break;
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
const applied = Math.min(bal, remaining);
await applyPaymentToBill(b.id, applied);
remaining -= applied;
}
qc.invalidateQueries({ queryKey: ["bills", cid] });
}
}
setTxDialog({ open: false, mode: "deposit" });
setEditId(null);
setTxForm(EMPTY_TX);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
qc.invalidateQueries({ queryKey: ["check-settings", cid] });
};
const printCheckForPayment = async ({
vendorId, vendorName, vendorAddress, amount, date, memo, checkNumber, bankAccountId,
}: {
vendorId: string; vendorName: string; vendorAddress?: string;
amount: number; date: string; memo: string; checkNumber: number; bankAccountId: string;
}) => {
const cs = checkSettings as any;
const bankAccount = (bankAccounts as any[]).find((a) => a.id === bankAccountId);
// Return address = association name + mailing/office address from General Settings.
const { data: companyInfo } = await accounting.from("companies").select("name, address").eq("id", cid).maybeSingle();
await accounting.from("checks").insert({
company_id: cid,
bank_account_id: bankAccountId,
check_number: checkNumber,
date,
payee_vendor_id: vendorId || null,
payee_name: vendorName,
amount,
memo: memo || undefined,
status: "printed",
printed_at: new Date().toISOString(),
});
if (cs?.id) {
await accounting.from("check_settings").update({
next_check_number: Math.max((cs.next_check_number ?? 1001), checkNumber + 1),
}).eq("id", cs.id);
}
const dataUrl = generateCheckPDF([{
companyName: (companyInfo as any)?.name ?? associationName ?? "Association",
companyAddress: (companyInfo as any)?.address ?? undefined,
bankName: cs?.bank_name ?? bankAccount?.name ?? undefined,
bankAddress: cs?.bank_address ?? undefined,
routingNumber: cs?.routing_number ?? undefined,
accountNumber: cs?.account_number ?? undefined,
fractionalRouting: cs?.fractional_routing ?? undefined,
checkNumber,
date: fmtDate(date),
payee: vendorName,
payeeAddress: vendorAddress || undefined,
amount,
memo: memo || undefined,
printSignature: cs?.print_signature ?? false,
signatureDataUrl: cs?.signature_url ?? undefined,
}], {
style: cs?.default_style ?? "voucher",
position: cs?.default_position ?? "top",
fontSize: cs?.font_size ?? "medium",
offsetX: cs?.offset_x ?? 0,
offsetY: cs?.offset_y ?? 0,
micrOffsetY: cs?.micr_offset_y ?? 0,
micrGap1: cs?.micr_gap_1 ?? 1,
micrGap2: cs?.micr_gap_2 ?? 1,
fieldPositions: cs?.field_positions ?? {},
});
const w = window.open("");
if (w) {
w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
}
};
const deleteTx = async (tx: any) => {
if (tx.reconciliation_id) return toast.error("Reconciled transactions can't be deleted");
if (!confirm("Delete this transaction?")) return;
if (tx.transfer_id) {
const { error } = await accounting.from("transactions").delete().eq("transfer_id", tx.transfer_id);
if (error) return toast.error(error.message);
} else {
const { error } = await accounting.from("transactions").delete().eq("id", tx.id);
if (error) return toast.error(error.message);
}
toast.success("Transaction deleted");
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
const openEdit = (tx: any) => {
if (tx.reconciliation_id) return toast.error("Reconciled transactions can't be edited");
setEditId(tx.id);
setTxForm({
account_id: tx.account_id ?? activeAccountId,
date: tx.date,
amount: tx.type === "debit" ? String(-Math.abs(Number(tx.amount))) : String(tx.amount),
type: tx.type,
reference: tx.reference ?? "",
coa_account_id: tx.coa_account_id ?? "",
vendor_id: tx.vendor_id ?? "",
customer_id: tx.customer_id ?? "",
memo: "",
printCheck: false,
});
setTxDialog({ open: true, mode: "edit" });
};
const openNewTx = () => {
setEditId(null);
setTxForm({ ...EMPTY_TX, account_id: activeAccountId, type: "credit" });
setTxDialog({ open: true, mode: "deposit" });
};
const onAmountChange = (v: string) => {
setTxForm((prev) => {
const t = v.trim();
const nextType: "credit" | "debit" =
t === "" ? prev.type : (t.startsWith("-") || Number(v) < 0 ? "debit" : "credit");
return nextType !== prev.type
? { ...prev, amount: v, type: nextType, coa_account_id: "", vendor_id: "", customer_id: "" }
: { ...prev, amount: v };
});
};
const coaOptions = txForm.type === "credit" ? incomeAccounts : expenseAccounts;
const saveTransfer = async () => {
const { from_account_id, to_account_id, date, memo } = transfer;
const amount = Number(transfer.amount);
if (!from_account_id || !to_account_id) return toast.error("Both accounts required");
if (from_account_id === to_account_id) return toast.error("Accounts must be different");
if (!amount || amount <= 0) return toast.error("Amount must be greater than zero");
const transferId = crypto.randomUUID();
const fromAcc = (accounts as any[]).find((a) => a.id === from_account_id);
const toAcc = (accounts as any[]).find((a) => a.id === to_account_id);
const { error } = await accounting.from("transactions").insert([
{
company_id: cid,
account_id: from_account_id,
date,
description: `Transfer to ${toAcc?.name ?? "account"}${memo ? ` — ${memo}` : ""}`,
amount,
type: "debit",
category: "Transfer",
transfer_id: transferId,
},
{
company_id: cid,
account_id: to_account_id,
date,
description: `Transfer from ${fromAcc?.name ?? "account"}${memo ? ` — ${memo}` : ""}`,
amount,
type: "credit",
category: "Transfer",
transfer_id: transferId,
},
]);
if (error) return toast.error(error.message);
toast.success("Transfer recorded");
setTransferOpen(false);
setTransfer(EMPTY_TRANSFER);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
const downloadRegisterTemplate = () => {
const csv =
"date,description,amount,reference,category,cleared\n" +
"2026-01-15,Opening deposit,1200.00,DEP-001,Income,true\n" +
"2026-01-18,Landscaping invoice,-350.00,1042,Maintenance,false\n";
const blob = new Blob([csv], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "bank-register-template.csv";
a.click();
URL.revokeObjectURL(a.href);
};
const handleRegisterImport = async (file: File) => {
if (!activeAccountId) { toast.error("Select a bank account first"); return; }
setImporting(true);
setImportResult(null);
try {
const rows = parseCsv(await file.text());
if (rows.length === 0) { toast.error("CSV is empty or unreadable"); return; }
let skipped = 0;
const payload = rows
.map((r) => {
const dateRaw = pick(r.date, r.transaction_date, r["transaction date"], r.posted, r["post date"]);
if (!dateRaw) { skipped++; return null; }
const debit = parseFloat(pick(r.debit, r.withdrawal, r.withdrawals, r["debit amount"]) || "0") || 0;
const credit = parseFloat(pick(r.credit, r.deposit, r.deposits, r["credit amount"]) || "0") || 0;
const signed = parseFloat(pick(r.amount) || "0") || 0;
let amount: number;
let type: "debit" | "credit";
if (debit > 0 || credit > 0) {
amount = debit > 0 ? debit : credit;
type = debit > 0 ? "debit" : "credit";
} else if (signed !== 0) {
amount = Math.abs(signed);
const t = pick(r.type).toLowerCase();
type = t === "debit" || t === "credit" ? (t as "debit" | "credit") : (signed < 0 ? "debit" : "credit");
} else {
skipped++; return null;
}
const clearedRaw = pick(r.cleared, r.reconciled, r.status).toLowerCase();
return {
company_id: cid,
account_id: activeAccountId,
date: parseDateStr(dateRaw),
description: pick(r.description, r.payee, r.memo, r.name) || "Imported transaction",
amount,
type,
category: pick(r.category) || null,
reference: pick(r.reference, r["check number"], r["check #"], r.check, r.cheque) || null,
cleared: ["true", "yes", "1", "cleared", "reconciled", "c"].includes(clearedRaw),
};
})
.filter((x): x is NonNullable<typeof x> => x !== null);
if (payload.length === 0) { toast.warning("No rows imported — check the column names against the template."); return; }
let inserted = 0;
for (let i = 0; i < payload.length; i += 500) {
const chunk = payload.slice(i, i + 500);
const { error, count } = await accounting.from("transactions").insert(chunk, { count: "exact" });
if (error) { toast.error(error.message); break; }
inserted += count ?? chunk.length;
}
setImportResult({ inserted, skipped });
if (inserted > 0) {
toast.success(`Imported ${inserted} transaction${inserted !== 1 ? "s" : ""}${skipped ? ` (${skipped} skipped)` : ""}`);
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Import failed");
} finally {
setImporting(false);
if (importFileRef.current) importFileRef.current.value = "";
}
};
const saveAccount = async () => {
if (!acctForm.name.trim()) return toast.error("Name required");
const { error } = await accounting.from("accounts").insert({ ...acctForm, company_id: cid });
if (error) return toast.error(error.message);
toast.success("Account added");
setAcctDialog(false);
setAcctForm({ name: "", code: "", type: "asset", is_bank: true });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
const dialogTitle = editId
? "Edit transaction"
: (txForm.type === "debit" ? "New payment" : "New transaction");
const totalDebits = filteredRegister.reduce((s, r) => s + (r.type === "debit" ? Number(r.amount) : 0), 0);
const totalCredits = filteredRegister.reduce((s, r) => s + (r.type === "credit" ? Number(r.amount) : 0), 0);
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 className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Banking</h1>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setAcctDialog(true)}>
<Plus className="h-4 w-4 mr-1" /> New account
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{bankAccounts.map((acc: any) => {
const conn = plaidByAcct.get(acc.id);
const isSyncing = syncingAcctId === acc.id;
return (
<Card
key={acc.id}
className={`cursor-pointer transition-colors ${acc.id === activeAccountId ? "ring-2 ring-primary" : "hover:bg-muted/50"}`}
onClick={() => setSelectedAccountId(acc.id)}
>
<CardContent className="p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wide">{acc.type} · bank</div>
<div className="font-medium mt-0.5">{acc.name}</div>
<div className="text-xl font-semibold mt-1">{money(acc.balance, cur)}</div>
</div>
{conn && (
<Badge className="bg-emerald-100 text-emerald-700 border-0 shrink-0 text-[10px]">
<Link2 className="h-2.5 w-2.5 mr-1" />Connected
</Badge>
)}
</div>
{conn ? (
<div className="flex gap-1.5 pt-1" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="outline" className="h-7 text-xs flex-1"
disabled={isSyncing}
onClick={() => syncAccount(acc.id)}>
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncing ? "animate-spin" : ""}`} />
{isSyncing ? "Syncing…" : conn.last_sync_at ? `Synced ${new Date(conn.last_sync_at).toLocaleDateString()}` : "Sync now"}
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => disconnectAccount(acc.id)}>
<Unlink className="h-3 w-3" />
</Button>
</div>
) : (
<div onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="outline" className="h-7 text-xs w-full"
onClick={() => openPlaidLink(acc.id)}>
<Link2 className="h-3 w-3 mr-1" /> Connect bank feed
</Button>
</div>
)}
</CardContent>
</Card>
);
})}
{bankAccounts.length === 0 && (
<Card className="col-span-full">
<CardContent className="p-8 text-center text-muted-foreground">
No bank accounts yet. Add one to get started.
</CardContent>
</Card>
)}
</div>
{activeAccount && (
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{activeAccount.name} Register</CardTitle>
<div className="text-sm text-muted-foreground mt-0.5">
Computed balance: <span className="font-semibold text-foreground">{money(computedBalance, cur)}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={openNewTx}>
<Plus className="h-4 w-4 mr-1" /> New transaction
</Button>
<Button size="sm" variant="outline" onClick={() => { setTransfer(EMPTY_TRANSFER); setTransferOpen(true); }}>
<ArrowLeftRight className="h-4 w-4 mr-1" /> Transfer
</Button>
<Button size="sm" variant="outline" onClick={() => { setImportResult(null); setImportOpen(true); }}>
<FileUp className="h-4 w-4 mr-1" /> Import
</Button>
</div>
</div>
<Input
placeholder="Search description, category, reference…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm mt-2"
/>
</CardHeader>
<CardContent className="p-0">
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/40 px-4 py-2.5">
<span className="text-sm font-medium">{selected.size} selected</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Category</span>
<Select value="" onValueChange={bulkSetCategory}>
<SelectTrigger className="h-8 w-56 text-sm"><SelectValue placeholder="Set category…" /></SelectTrigger>
<SelectContent>
{incomeAccounts.length > 0 && (
<SelectGroup>
<SelectLabel>Income</SelectLabel>
{incomeAccounts.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectGroup>
)}
{expenseAccounts.length > 0 && (
<SelectGroup>
<SelectLabel>Expense</SelectLabel>
{expenseAccounts.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Direction</span>
<Select value="" onValueChange={(v) => bulkSetDirection(v as "debit" | "credit")}>
<SelectTrigger className="h-8 w-44 text-sm"><SelectValue placeholder="Set direction…" /></SelectTrigger>
<SelectContent>
<SelectItem value="credit">Deposit (money in)</SelectItem>
<SelectItem value="debit">Payment (money out)</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => setSelected(new Set())}>Clear</Button>
</div>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[36px] px-2">
<Checkbox checked={allSelected} onCheckedChange={toggleAll} disabled={selectableIds.length === 0} aria-label="Select all" />
</TableHead>
<TableHead className="w-[100px]">Date</TableHead>
<TableHead className="w-[80px]">Ref #</TableHead>
<TableHead>Description</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right w-[110px]">Payment</TableHead>
<TableHead className="text-right w-[110px]">Deposit</TableHead>
<TableHead className="text-right w-[120px]">Balance</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRegister.map((row) => (
<TableRow key={row.id} className={`hover:bg-muted/40 ${selected.has(row.id) ? "bg-primary/5" : ""}`}>
<TableCell className="px-2">
{!row.reconciliation_id && !row.transfer_id && (
<Checkbox checked={selected.has(row.id)} onCheckedChange={() => toggleOne(row.id)} aria-label="Select transaction" />
)}
</TableCell>
<TableCell className="text-sm">{fmtDate(row.date)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.reference ?? "—"}</TableCell>
<TableCell className="text-sm">
<span className="flex items-center gap-1.5">
{row.transfer_id && (
<Badge variant="outline" className="text-[10px] py-0 px-1">TFR</Badge>
)}
{row.reconciliation_id && (
<Badge variant="outline" className="text-[10px] py-0 px-1 bg-emerald-50 text-emerald-700 border-emerald-200">R</Badge>
)}
{row.description}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.category ?? "—"}</TableCell>
<TableCell className="text-right text-sm text-red-600">
{row.type === "debit" ? money(row.amount, cur) : ""}
</TableCell>
<TableCell className="text-right text-sm text-emerald-600">
{row.type === "credit" ? money(row.amount, cur) : ""}
</TableCell>
<TableCell className={`text-right text-sm font-medium ${row.running < 0 ? "text-destructive" : ""}`}>
{money(row.running, cur)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(row)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => deleteTx(row)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filteredRegister.length === 0 && (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
No transactions yet. Record a deposit or payment to get started.
</TableCell>
</TableRow>
)}
{filteredRegister.length > 0 && (
<TableRow className="bg-muted font-medium">
<TableCell colSpan={5} className="text-sm">Totals</TableCell>
<TableCell className="text-right text-sm text-red-600">{money(totalDebits, cur)}</TableCell>
<TableCell className="text-right text-sm text-emerald-600">{money(totalCredits, cur)}</TableCell>
<TableCell className={`text-right text-sm ${computedBalance < 0 ? "text-destructive" : ""}`}>
{money(computedBalance, cur)}
</TableCell>
<TableCell />
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Deposit / Payment / Edit dialog */}
<Dialog open={txDialog.open} onOpenChange={(o) => { if (!o) { setTxDialog({ open: false, mode: "deposit" }); setEditId(null); setTxForm(EMPTY_TX); } else setTxDialog((d) => ({ ...d, open: true })); }}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{dialogTitle}</DialogTitle></DialogHeader>
<div className="space-y-3">
{txDialog.mode === "edit" && (
<div>
<Label>Bank account *</Label>
<Select value={txForm.account_id} onValueChange={(v) => setTxForm({ ...txForm, account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date *</Label>
<Input type="date" value={txForm.date} onChange={(e) => setTxForm({ ...txForm, date: e.target.value })} />
</div>
<div>
<Label>Amount <span className="text-muted-foreground text-xs">(+ deposit / payment)</span> *</Label>
<Input
type="number"
step="0.01"
placeholder="0.00"
className={Number(txForm.amount) < 0 ? "text-red-600" : ""}
value={txForm.amount}
onChange={(e) => onAmountChange(e.target.value)}
/>
</div>
</div>
{txForm.type === "debit" ? (
<div>
<Label>Vendor *</Label>
<Select value={txForm.vendor_id} onValueChange={(v) => setTxForm({ ...txForm, vendor_id: v })}>
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
<SelectContent>
{(vendors as any[]).map((v: any) => (
<SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label>Homeowner *</Label>
<Select value={txForm.customer_id} onValueChange={(v) => setTxForm({ ...txForm, customer_id: v })}>
<SelectTrigger><SelectValue placeholder="Select homeowner" /></SelectTrigger>
<SelectContent>
{(customers as any[]).map((c: any) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label>{txForm.type === "credit" ? "Income account *" : "Expense account *"}</Label>
<Select value={txForm.coa_account_id} onValueChange={(v) => setTxForm({ ...txForm, coa_account_id: v })}>
<SelectTrigger><SelectValue placeholder={`Select ${txForm.type === "credit" ? "income" : "expense"} account`} /></SelectTrigger>
<SelectContent>
{coaOptions.map((a: any) => (
<SelectItem key={a.id} value={a.id}>
{a.code ? `${a.code} · ` : ""}{a.name}
</SelectItem>
))}
{coaOptions.length === 0 && (
<SelectItem value="__none" disabled>
No {txForm.type === "credit" ? "income" : "expense"} accounts add them in Chart of Accounts
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{txForm.type === "debit" ? "Check # *" : "Ref #"}</Label>
<Input
maxLength={40}
placeholder={txForm.type === "debit" ? "Check number" : "Optional"}
value={txForm.reference}
onChange={(e) => setTxForm({ ...txForm, reference: e.target.value })}
className="font-mono"
/>
</div>
<div>
<Label>Memo <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Input maxLength={120} placeholder="Additional notes" value={txForm.memo} onChange={(e) => setTxForm({ ...txForm, memo: e.target.value })} />
</div>
</div>
{txForm.type === "debit" && !editId && (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-4 py-3">
<div className="flex items-center gap-2">
<Printer className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm font-medium">Print check after saving</div>
<div className="text-xs text-muted-foreground">Opens print dialog with check #{txForm.reference || "—"}</div>
</div>
</div>
<Switch
checked={txForm.printCheck}
onCheckedChange={(v) => setTxForm({ ...txForm, printCheck: v })}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setTxDialog({ open: false, mode: "deposit" }); setEditId(null); setTxForm(EMPTY_TX); }}>Cancel</Button>
<Button onClick={saveTx}>
{txForm.type === "debit" && txForm.printCheck && !editId ? "Save & Print Check" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Transfer dialog */}
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Transfer between accounts</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>From account</Label>
<Select value={transfer.from_account_id} onValueChange={(v) => setTransfer({ ...transfer, from_account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
<SelectContent>{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>To account</Label>
<Select value={transfer.to_account_id} onValueChange={(v) => setTransfer({ ...transfer, to_account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
<SelectContent>{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
</Select>
</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" min={0} step="0.01" placeholder="0.00" value={transfer.amount} onChange={(e) => setTransfer({ ...transfer, amount: e.target.value })} />
</div>
</div>
<div>
<Label>Memo</Label>
<Input maxLength={200} value={transfer.memo} onChange={(e) => setTransfer({ ...transfer, memo: e.target.value })} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTransferOpen(false)}>Cancel</Button>
<Button onClick={saveTransfer}>Record transfer</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bank register import dialog */}
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) setImportResult(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Import bank register</DialogTitle></DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Import transactions into <span className="font-medium text-foreground">{activeAccount?.name ?? "this account"}</span>.
Columns: <code className="text-xs">date, description, amount, reference, category, cleared</code>.
Use a <span className="font-medium text-foreground">signed amount</span> <code className="text-xs">+</code> for
money in, <code className="text-xs"></code> for money out.
Imported rows are uncategorized set the offset account from the register afterward.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={downloadRegisterTemplate}>
<Download className="h-4 w-4 mr-1" /> Download template
</Button>
<Button size="sm" disabled={importing || !activeAccountId} onClick={() => importFileRef.current?.click()}>
{importing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileUp className="h-4 w-4 mr-1" />}
{importing ? "Importing…" : "Choose CSV file"}
</Button>
<input
ref={importFileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleRegisterImport(f); }}
/>
</div>
{importResult && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
Imported <span className="font-semibold">{importResult.inserted}</span> transaction{importResult.inserted !== 1 ? "s" : ""}
{importResult.skipped > 0 && <> · {importResult.skipped} skipped (missing date/amount)</>}.
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setImportOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* New account dialog */}
<Dialog open={acctDialog} onOpenChange={setAcctDialog}>
<DialogContent>
<DialogHeader><DialogTitle>New bank account</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>Name</Label><Input maxLength={80} value={acctForm.name} onChange={(e) => setAcctForm({ ...acctForm, name: e.target.value })} placeholder="e.g. Operating Checking" /></div>
<div><Label>Code</Label><Input maxLength={20} value={acctForm.code} onChange={(e) => setAcctForm({ ...acctForm, code: e.target.value })} placeholder="e.g. 1010" /></div>
<div>
<Label>Account type</Label>
<Select value={acctForm.type} onValueChange={(v) => setAcctForm({ ...acctForm, type: v as any })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{["asset", "liability", "equity", "income", "expense"].map((x) => (
<SelectItem key={x} value={x}>{x}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter><Button onClick={saveAccount}>Add account</Button></DialogFooter>
</DialogContent>
</Dialog>
{/* Plaid Link — mounts only when a link token is ready */}
{plaidLinkToken && (
<PlaidLinkButton
linkToken={plaidLinkToken}
accountId={plaidTargetAcct}
companyId={cid}
onDone={() => {
setPlaidLinkToken(null);
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
}}
/>
)}
</div>
);
}
function PlaidLinkButton({
linkToken, accountId, companyId, onDone,
}: {
linkToken: string;
accountId: string;
companyId: string;
onDone: () => void;
}) {
const { open, ready } = usePlaidLink({
token: linkToken,
onSuccess: async (publicToken, metadata) => {
try {
const account = metadata.accounts?.[0];
await exchangePlaidToken({
publicToken,
companyId,
accountId,
plaidAccountId: account?.id ?? "",
institutionName: metadata.institution?.name ?? undefined,
institutionId: metadata.institution?.institution_id ?? undefined,
mask: account?.mask ?? undefined,
});
toast.success(`Connected to ${metadata.institution?.name ?? "bank"} — click Sync now to import transactions`);
} catch (e: any) {
toast.error(e?.message ?? "Connection failed");
}
onDone();
},
onExit: (err) => {
if (err) toast.error(`Plaid: ${err.display_message ?? err.error_message ?? "Closed"}`);
onDone();
},
});
useEffect(() => {
if (ready) open();
}, [ready, open]);
return null;
}