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(null); const [plaidTargetAcct, setPlaidTargetAcct] = useState(""); const [syncingAcctId, setSyncingAcctId] = useState(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(""); const [txDialog, setTxDialog] = useState<{ open: boolean; mode: "deposit" | "payment" | "edit" }>({ open: false, mode: "deposit" }); const [editId, setEditId] = useState(null); const [txForm, setTxForm] = useState(EMPTY_TX); const [transferOpen, setTransferOpen] = useState(false); const [transfer, setTransfer] = useState(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(null); const [selected, setSelected] = useState>(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); 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: associationName ?? "Association", companyAddress: 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, }); const w = window.open(""); if (w) { w.document.write(``); } }; 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 => 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

Select an association.

; if (companyLoading) return
; if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; return (

Banking

{bankAccounts.map((acc: any) => { const conn = plaidByAcct.get(acc.id); const isSyncing = syncingAcctId === acc.id; return ( setSelectedAccountId(acc.id)} >
{acc.type} · bank
{acc.name}
{money(acc.balance, cur)}
{conn && ( Connected )}
{conn ? (
e.stopPropagation()}>
) : (
e.stopPropagation()}>
)}
); })} {bankAccounts.length === 0 && ( No bank accounts yet. Add one to get started. )}
{activeAccount && (
{activeAccount.name} — Register
Computed balance: {money(computedBalance, cur)}
setSearch(e.target.value)} className="max-w-sm mt-2" />
{selected.size > 0 && (
{selected.size} selected
Category
Direction
)}
Date Ref # Description Category Payment Deposit Balance {filteredRegister.map((row) => ( {!row.reconciliation_id && !row.transfer_id && ( toggleOne(row.id)} aria-label="Select transaction" /> )} {fmtDate(row.date)} {row.reference ?? "—"} {row.transfer_id && ( TFR )} {row.reconciliation_id && ( R )} {row.description} {row.category ?? "—"} {row.type === "debit" ? money(row.amount, cur) : ""} {row.type === "credit" ? money(row.amount, cur) : ""} {money(row.running, cur)}
))} {filteredRegister.length === 0 && ( No transactions yet. Record a deposit or payment to get started. )} {filteredRegister.length > 0 && ( Totals {money(totalDebits, cur)} {money(totalCredits, cur)} {money(computedBalance, cur)} )}
)} {/* Deposit / Payment / Edit dialog */} { if (!o) { setTxDialog({ open: false, mode: "deposit" }); setEditId(null); setTxForm(EMPTY_TX); } else setTxDialog((d) => ({ ...d, open: true })); }}> {dialogTitle}
{txDialog.mode === "edit" && (
)}
setTxForm({ ...txForm, date: e.target.value })} />
onAmountChange(e.target.value)} />
{txForm.type === "debit" ? (
) : (
)}
setTxForm({ ...txForm, reference: e.target.value })} className="font-mono" />
setTxForm({ ...txForm, memo: e.target.value })} />
{txForm.type === "debit" && !editId && (
Print check after saving
Opens print dialog with check #{txForm.reference || "—"}
setTxForm({ ...txForm, printCheck: v })} />
)}
{/* Transfer dialog */} Transfer between accounts
setTransfer({ ...transfer, date: e.target.value })} />
setTransfer({ ...transfer, amount: e.target.value })} />
setTransfer({ ...transfer, memo: e.target.value })} />
{/* Bank register import dialog */} { setImportOpen(o); if (!o) setImportResult(null); }}> Import bank register

Import transactions into {activeAccount?.name ?? "this account"}. Columns: date, description, amount, reference, category, cleared. Use a signed amount+ for money in, for money out. Imported rows are uncategorized — set the offset account from the register afterward.

{ const f = e.target.files?.[0]; if (f) handleRegisterImport(f); }} />
{importResult && (
Imported {importResult.inserted} transaction{importResult.inserted !== 1 ? "s" : ""} {importResult.skipped > 0 && <> · {importResult.skipped} skipped (missing date/amount)}.
)}
{/* New account dialog */} New bank account
setAcctForm({ ...acctForm, name: e.target.value })} placeholder="e.g. Operating Checking" />
setAcctForm({ ...acctForm, code: e.target.value })} placeholder="e.g. 1010" />
{/* Plaid Link — mounts only when a link token is ready */} {plaidLinkToken && ( { setPlaidLinkToken(null); qc.invalidateQueries({ queryKey: ["plaid-connections", cid] }); }} /> )}
); } 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; }