diff --git a/src/App.tsx b/src/App.tsx index cef0324..8658243 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { AccountingOpeningBalancesPage, AccountingExpensesPage, AccountingEstimatesPage, + AccountingSalesReceiptsPage, AccountingReconcileDetailPage, AccountingBudgetDetailPage, AccountingCustomerDetailPage, @@ -384,6 +385,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/lib/chartOfAccountsSource.ts b/src/lib/chartOfAccountsSource.ts index bfc31ef..42777d8 100644 --- a/src/lib/chartOfAccountsSource.ts +++ b/src/lib/chartOfAccountsSource.ts @@ -54,7 +54,10 @@ function fromPlatform(row: any, associationId: string): NormalizedAccount { * - `platform` → the Accounting module's `accounting.accounts` (single source of * truth once an association is on the platform). Returns [] if the association * has no `accounting.companies` row yet. - * - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system. + * - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system AND, when + * an association is given, by that association. Each association owns an independent + * set of accounts (one row per association — see the per-association COA migration), + * so two associations can both have a "5000" meaning different things. * * Returned rows are normalized to {@link NormalizedAccount} so callers never * branch on the source. @@ -81,11 +84,14 @@ export async function fetchChartOfAccounts( return (data ?? []).map((row) => fromPlatform(row, associationId)); } - const { data, error } = await supabase + let query = supabase .from("chart_of_accounts") .select("*") - .eq("accounting_system", system) - .order("account_number", { ascending: true }); + .eq("accounting_system", system); + // Scope to the current association so each one sees only its own accounts. When no + // association is supplied, fall back to the whole system set (back-compat). + if (associationId) query = query.eq("association_id", associationId); + const { data, error } = await query.order("account_number", { ascending: true }); if (error) throw error; return (data ?? []).map(fromPublic); } diff --git a/src/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index 0ab1e6b..fb940d3 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -18,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx"; const statusColors: Record = { pending: "bg-amber-100 text-amber-700", approved: "bg-emerald-100 text-emerald-700", @@ -172,8 +173,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci setBills([]); setApprovalsByBill({}); const [aRes2, coaRes2, vRes2] = await Promise.all([ - supabase.from("associations").select("id, name").eq("status", "active").order("name"), - supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"), + supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"), + supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"), supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"), ]); setAssociations(aRes2.data || []); @@ -201,8 +202,8 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci if (data.length < PAGE) break; } const [aRes, coaRes, vRes] = await Promise.all([ - supabase.from("associations").select("id, name").eq("status", "active").order("name"), - supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type").eq("account_type", "expense").order("account_number"), + supabase.from("associations").select("id, name, accounting_system").eq("status", "active").order("name"), + supabase.from("chart_of_accounts").select("id, account_name, account_number, account_type, accounting_system, association_id").eq("account_type", "expense").eq("is_active", true).order("account_number"), supabase.from("vendors").select("id, name, address, association_id, association_ids").eq("is_active", true).order("name"), ]); @@ -604,10 +605,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci return bill.status; }; - const filteredAccounts = form.association_id - ? accounts.filter((a: any) => !a.association_id || a.association_id === form.association_id) - : []; - const filteredVendors = form.association_id ? vendors.filter((v: any) => v.association_id === form.association_id || (Array.isArray(v.association_ids) && v.association_ids.includes(form.association_id))) : []; @@ -1153,20 +1150,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci {/* GL Account */}
- + placeholder={form.association_id ? "Select GL Account" : "Select a client first"} + />
{/* Request Approval From */} @@ -1401,12 +1392,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
- + setForm({ ...form, expense_account_id: v })} + disabled={!form.association_id} + placeholder="Select GL Account" + />
{vendorNotFound && ( diff --git a/src/pages/accounting/AccountingBankingPage.tsx b/src/pages/accounting/AccountingBankingPage.tsx index 7233530..57ac34c 100644 --- a/src/pages/accounting/AccountingBankingPage.tsx +++ b/src/pages/accounting/AccountingBankingPage.tsx @@ -20,6 +20,7 @@ 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; @@ -222,11 +223,48 @@ export default function AccountingBankingPage() { const acc = (accounts as any[]).find((a) => a.id === accountId); if (!acc || selected.size === 0) return; const ids = [...selected]; - const { error } = await accounting.from("transactions") - .update({ coa_account_id: accountId, category: acc.name }).in("id", ids); - if (error) return toast.error(error.message); - toast.success(`Set category for ${ids.length} transaction${ids.length !== 1 ? "s" : ""}`); + 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") => { @@ -256,16 +294,30 @@ export default function AccountingBankingPage() { const description = [partyName, coaName, memo].filter(Boolean).join(" · "); const category = coaName; - // A vendor payment (debit) clears Accounts Payable — the expense was already - // recognized when the bill was entered (accrual). Leaving coa_account_id null - // with the vendor set makes post_transaction_gl post Dr A/P / Cr Bank; the - // chosen expense account is retained as the display `category` only. Customer - // deposits (credits) clear A/R via customer_id, so they need no change here. + // 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: type === "debit" ? null : (coa_account_id || 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) { @@ -273,8 +325,9 @@ export default function AccountingBankingPage() { if (error) return toast.error(error.message); toast.success("Transaction updated"); } else { - const { error } = await accounting.from("transactions").insert({ ...payload, company_id: cid }); - if (error) return toast.error(error.message); + 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) { @@ -290,6 +343,22 @@ export default function AccountingBankingPage() { 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); @@ -1013,6 +1082,7 @@ export default function AccountingBankingPage() { }} /> )} + ); } diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index 69d9f71..aa3439a 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -74,10 +74,16 @@ export default function AccountingBillsPage() { enabled: !!cid, queryFn: async () => (await accounting.from("bills").select("*, vendors(name,address)").eq("company_id", cid).order("issue_date", { ascending: false })).data ?? [], }); + // Single vendor roster = public.vendors, scoped to this company's association. + // The chosen public vendor is mapped to its accounting.vendors row on save. const { data: vendors = [] } = useQuery({ - queryKey: ["vendors-lookup", cid], - enabled: !!cid, - queryFn: async () => (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [], + queryKey: ["vendors-lookup", associationId], + enabled: !!associationId, + queryFn: async () => (await supabase.from("vendors") + .select("id,name") + .eq("is_active", true) + .or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`) + .order("name")).data ?? [], }); const { data: expenseAccounts = [] } = useQuery({ queryKey: ["expense-accounts", cid], @@ -128,7 +134,14 @@ export default function AccountingBillsPage() { const openEdit = async (b: any) => { setEditId(b.id); - setVendorId(b.vendor_id ?? ""); + // The dropdown is keyed by public vendor id; map the stored accounting + // vendor back to its source public vendor when one exists. + let pubVendorId = ""; + if (b.vendor_id) { + const { data: av } = await accounting.from("vendors").select("external_source, external_id").eq("id", b.vendor_id).maybeSingle(); + if (av?.external_source === "acmacc_vendor" && av?.external_id) pubVendorId = String(av.external_id); + } + setVendorId(pubVendorId); setNumber(b.number ?? ""); setIssueDate(b.issue_date ?? issueDate); setDueDate(b.due_date ?? ""); @@ -245,6 +258,17 @@ export default function AccountingBillsPage() { let attachmentUrl = uploadedUrl; if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file); + // Resolve the chosen public vendor to its accounting.vendors row (find-or-create). + let acctVendorId: string | null = null; + if (vendorId) { + const { data: mapped, error: mapErr } = await supabase.rpc("ensure_accounting_vendor", { + _association_id: associationId, + _public_vendor_id: vendorId, + }); + if (mapErr) return toast.error(mapErr.message); + acctVendorId = (mapped as string) ?? null; + } + const itemRows = (billId: string) => items.map(i => ({ bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate, amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2), @@ -253,7 +277,7 @@ export default function AccountingBillsPage() { if (editId) { const { error } = await accounting.from("bills").update({ - vendor_id: vendorId || null, number, + vendor_id: acctVendorId, number, issue_date: issueDate, due_date: dueDate || null, subtotal, tax, total, notes: notes || null, @@ -265,7 +289,7 @@ export default function AccountingBillsPage() { toast.success("Bill updated"); } else { const { data: bill, error } = await accounting.from("bills").insert({ - company_id: cid, vendor_id: vendorId || null, number, + company_id: cid, vendor_id: acctVendorId, number, issue_date: issueDate, due_date: dueDate || null, subtotal, tax, total, status: "open", notes: notes || null, @@ -415,13 +439,14 @@ export default function AccountingBillsPage() { reference: refLabel, coa_account_id: null, // → posts against Accounts Payable (via vendor) vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P + bill_id: payBill.id, // links the payment to the bill it settles }); // 2) Bank balance auto-updated by DB trigger trg_sync_account_balance - // 3) Update bill paid amount + // 3) Update bill paid amount (partial payments leave the bill partially_paid) const newPaid = Number(payBill.paid_amount ?? 0) + Number(payAmount); - await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) ? "paid" : "open" }).eq("id", payBill.id); + await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) - 0.005 ? "paid" : "partially_paid" }).eq("id", payBill.id); // 4) If check + print: insert check record, print, mark printed, bump next # if (payMethod === "check") { diff --git a/src/pages/accounting/AccountingDepositsPage.tsx b/src/pages/accounting/AccountingDepositsPage.tsx index cd742ce..2ccd900 100644 --- a/src/pages/accounting/AccountingDepositsPage.tsx +++ b/src/pages/accounting/AccountingDepositsPage.tsx @@ -11,10 +11,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Textarea } from "@/components/ui/textarea"; import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; -import { Landmark, Loader2 } from "lucide-react"; +import { Landmark, Loader2, Plus, Trash2 } from "lucide-react"; import { EmptyState } from "./components/EmptyState"; import { ensureUndepositedFunds } from "./lib/undeposited"; +type ManualLine = { account_id: string; amount: string; memo: string }; +const EMPTY_LINE: ManualLine = { account_id: "", amount: "", memo: "" }; + export default function AccountingDepositsPage() { const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); const cid = companyId ?? ""; @@ -26,6 +29,7 @@ export default function AccountingDepositsPage() { const [depositDate, setDepositDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); const [memo, setMemo] = useState(""); const [selected, setSelected] = useState>(new Set()); + const [lines, setLines] = useState([]); const [saving, setSaving] = useState(false); useEffect(() => { @@ -40,6 +44,15 @@ export default function AccountingDepositsPage() { (await accounting.from("accounts").select("id,name,code,balance").eq("company_id", cid).eq("is_bank", true).order("code")).data ?? [], }); + // All accounts — for the source-account picker on deposit lines (income, A/R, + // reserve, clearing, etc.), so a deposit isn't forced through Undeposited Funds. + const { data: allAccounts = [] } = useQuery({ + queryKey: ["all-accounts", cid], + enabled: !!cid, + queryFn: async () => + (await accounting.from("accounts").select("id,name,code,type,balance").eq("company_id", cid).order("type").order("code")).data ?? [], + }); + // Two sources of "awaiting deposit": transactions parked on the Undeposited // Funds account (banking flow) and payments_received not yet deposited (incl. // payments synced from the main app's owner ledger). Both are unified below. @@ -90,89 +103,100 @@ export default function AccountingDepositsPage() { return rows.sort((a, b) => b.date.localeCompare(a.date)); }, [pendingTx, pendingPmt]); - const selectedTotal = useMemo( + const undepositedTotal = useMemo( () => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0), [pending, selected] ); + const manualTotal = useMemo( + () => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0), + [lines] + ); + const grandTotal = undepositedTotal + manualTotal; const toggleAll = () => { if (selected.size === pending.length) setSelected(new Set()); else setSelected(new Set(pending.map((r) => r.key))); }; + const addLine = () => setLines((ls) => [...ls, { ...EMPTY_LINE }]); + const updateLine = (i: number, patch: Partial) => + setLines((ls) => ls.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); + const removeLine = (i: number) => setLines((ls) => ls.filter((_, idx) => idx !== i)); + const submitDeposit = async () => { if (!bankAccountId) return toast.error("Choose a bank account"); - if (selected.size === 0) return toast.error("Select at least one payment"); + if (grandTotal <= 0) return toast.error("Add at least one payment or deposit line"); + + // Validate manual lines: each must have an account and a positive amount. + const cleanLines = lines.filter((l) => l.account_id || Number(l.amount)); + for (const l of cleanLines) { + if (!l.account_id) return toast.error("Every deposit line needs a source account"); + if (!(Number(l.amount) > 0)) return toast.error("Every deposit line needs a positive amount"); + } + + // Optional guard: warn (non-blocking) if crediting Undeposited Funds would exceed + // what is currently held there — i.e. depositing more than is sitting in it. + const manualToUndeposited = cleanLines + .filter((l) => l.account_id === undepositedId) + .reduce((s, l) => s + Number(l.amount), 0); + const toUndeposited = undepositedTotal + manualToUndeposited; + if (toUndeposited > 0 && undepositedId) { + const held = Number((allAccounts as any[]).find((a) => a.id === undepositedId)?.balance ?? 0); + if (toUndeposited > held + 0.005) { + toast.warning(`Crediting ${money(toUndeposited, cur)} to Undeposited Funds, which holds ${money(held, cur)}.`); + } + } + setSaving(true); 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 + // 1) Deposit header — amount is the sum of all credit lines. const { data: dep, error: depErr } = await accounting .from("deposits") - .insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: selectedTotal, memo: memo || null }) + .insert({ company_id: cid, bank_account_id: bankAccountId, date: depositDate, amount: grandTotal, memo: memo || null }) .select() .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, + // 2) Deposit lines (credit side). The selected payments collapse into one + // Undeposited Funds line; manual lines book to their chosen accounts. + // accounting.post_deposit_gl posts Dr Bank (total) / Cr each line. + const lineRows: any[] = []; + if (undepositedTotal > 0 && undepositedId) { + lineRows.push({ + deposit_id: dep.id, company_id: cid, account_id: undepositedId, + amount: undepositedTotal, memo: `Cleared ${chosen.length} payment${chosen.length !== 1 ? "s" : ""}`, }); - 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); - } + } + for (const l of cleanLines) { + lineRows.push({ deposit_id: dep.id, company_id: cid, account_id: l.account_id, amount: Number(l.amount), memo: l.memo || null }); + } + if (lineRows.length) { + const { error: lineErr } = await accounting.from("deposit_lines").insert(lineRows); + if (lineErr) throw new Error(lineErr.message); } - // 4) Mark selected payments_received as deposited so they leave the queue + // 3) Clear the deposited items from the awaiting-deposit queue. + if (txIds.length) { + await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds); + } if (pmtIds.length) { 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`); + toast.success(`Deposit of ${money(grandTotal, cur)} recorded`); setSelected(new Set()); + setLines([]); setMemo(""); qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] }); qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] }); qc.invalidateQueries({ queryKey: ["bank-accounts", cid] }); + qc.invalidateQueries({ queryKey: ["all-accounts", cid] }); qc.invalidateQueries({ queryKey: ["accounts", cid] }); qc.invalidateQueries({ queryKey: ["transactions", cid] }); } catch (e: any) { @@ -191,7 +215,8 @@ export default function AccountingDepositsPage() {

Make Deposit

- Select customer payments held in Undeposited Funds and deposit them as a single bank transaction. + Deposit customer payments held in Undeposited Funds, or record a deposit straight to an + income, A/R, or other account by adding deposit lines.

@@ -226,7 +251,7 @@ export default function AccountingDepositsPage() { Payments awaiting deposit
- Selected: {money(selectedTotal, cur)} ({selected.size} of {pending.length}) + Selected: {money(undepositedTotal, cur)} ({selected.size} of {pending.length})
@@ -276,9 +301,63 @@ export default function AccountingDepositsPage() { -
- + + + {lines.length === 0 ? ( +

No additional lines. Add one to deposit to a specific account.

+ ) : ( +
+ {lines.map((l, i) => ( +
+
+ +
+
+ updateLine(i, { memo: e.target.value })} /> +
+
+ updateLine(i, { amount: e.target.value })} + /> +
+
+ +
+
+ ))} +
+ )} +
+ + +
+
+ {money(undepositedTotal, cur)} from Undeposited + {money(manualTotal, cur)} direct +
+
diff --git a/src/pages/accounting/AccountingIndex.tsx b/src/pages/accounting/AccountingIndex.tsx index 34efbff..4e9387f 100644 --- a/src/pages/accounting/AccountingIndex.tsx +++ b/src/pages/accounting/AccountingIndex.tsx @@ -17,6 +17,7 @@ export { default as AccountingWorkOrdersPage } from "./AccountingWorkOrdersPage" export { default as AccountingOpeningBalancesPage } from "./AccountingOpeningBalancesPage"; export { default as AccountingExpensesPage } from "./AccountingExpensesPage"; export { default as AccountingEstimatesPage } from "./AccountingEstimatesPage"; +export { default as AccountingSalesReceiptsPage } from "./AccountingSalesReceiptsPage"; export { default as AccountingReconcileDetailPage } from "./AccountingReconcileDetailPage"; export { default as AccountingBudgetDetailPage } from "./AccountingBudgetDetailPage"; export { default as AccountingCustomerDetailPage } from "./AccountingCustomerDetailPage"; diff --git a/src/pages/accounting/AccountingLayout.tsx b/src/pages/accounting/AccountingLayout.tsx index 6e069ce..e631138 100644 --- a/src/pages/accounting/AccountingLayout.tsx +++ b/src/pages/accounting/AccountingLayout.tsx @@ -27,6 +27,7 @@ const NAV: NavSection[] = [ items: [ { to: "receive-payments", label: "Receive Payments" }, { to: "invoices", label: "Invoices" }, + { to: "sales-receipts", label: "Sales Receipts" }, { to: "estimates", label: "Estimates" }, ], }, diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 4ed9eb5..fd8f908 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -1294,18 +1294,22 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null { rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]), }; case "expense-summary": { - const byCat: Record = {}; - // Direct expenses from expenses table - for (const e of d.expenses) byCat[e.category] = (byCat[e.category] ?? 0) + Number(e.amount); - // Bill expenses (accrual — total billed, not just paid) - for (const b of d.bills) { - if (b.status === "void" || b.status === "draft") continue; - const cat = b.vendors?.name ?? "Vendor Expenses"; - byCat[cat] = (byCat[cat] ?? 0) + Number(b.total); + // GL-driven so it follows the same recognition rule as the P&L: a bill's + // expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment + // with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the + // ledger avoids double-counting and never misses direct payments. + const byAcct: Record = {}; + for (const l of (d.glLines ?? []) as any[]) { + const acc = l.accounts; + if (acc?.type !== "expense") continue; + const amt = Number(l.debit) - Number(l.credit); + if (amt === 0) continue; + const name = acc.name ?? "Expense"; + byAcct[name] = (byAcct[name] ?? 0) + amt; } - const rows = Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, amt]) => [cat, m(amt)]); - const total = Object.values(byCat).reduce((s, v) => s + v, 0); - return { title: "Expense Summary (Accrual)", columns: ["Category / Vendor", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] }; + const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]); + const total = Object.values(byAcct).reduce((s, v) => s + v, 0); + return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] }; } case "vendor-balances": { const byVendor: Record = {}; diff --git a/src/pages/accounting/AccountingSalesReceiptsPage.tsx b/src/pages/accounting/AccountingSalesReceiptsPage.tsx new file mode 100644 index 0000000..3ad320c --- /dev/null +++ b/src/pages/accounting/AccountingSalesReceiptsPage.tsx @@ -0,0 +1,347 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { 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 { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Search, Trash2, Receipt, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { money, fmtDate } from "./lib/format"; +import { EmptyState } from "./components/EmptyState"; +import { ensureUndepositedFunds } from "./lib/undeposited"; + +const generateNumber = () => `SR-${Date.now().toString().slice(-6)}`; +const today = () => new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); + +export default function AccountingSalesReceiptsPage() { + const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); + const cid = companyId ?? ""; + const cur = "USD"; + const qc = useQueryClient(); + + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [saving, setSaving] = useState(false); + + // Form state + const [number, setNumber] = useState(generateNumber()); + const [date, setDate] = useState(today()); + const [customerName, setCustomerName] = useState(""); + const [customerAddress, setCustomerAddress] = useState(""); + const [incomeAccountId, setIncomeAccountId] = useState(""); + const [depositAccountId, setDepositAccountId] = useState(""); + const [quantity, setQuantity] = useState(1); + const [rate, setRate] = useState(0); + const [memo, setMemo] = useState(""); + + const total = useMemo(() => +(Number(quantity) * Number(rate)).toFixed(2), [quantity, rate]); + + const { data: receipts = [], isLoading } = useQuery({ + queryKey: ["sales-receipts", cid], + enabled: !!cid, + queryFn: async () => { + const { data } = await accounting + .from("sales_receipts") + .select("*, income_account:accounts!sales_receipts_income_account_id_fkey(name,code), deposit_account:accounts!sales_receipts_deposit_account_id_fkey(name,code)") + .eq("company_id", cid) + .order("receipt_date", { ascending: false }) + .order("created_at", { ascending: false }); + return data ?? []; + }, + }); + + const { data: incomeAccounts = [] } = useQuery({ + queryKey: ["income-accounts", cid], + enabled: !!cid, + queryFn: async () => + (await accounting.from("accounts").select("id,name,code").eq("company_id", cid).eq("type", "income").order("code")).data ?? [], + }); + + const { data: depositAccounts = [] } = useQuery({ + queryKey: ["deposit-accounts", cid], + enabled: !!cid, + queryFn: async () => { + const { data } = await accounting + .from("accounts") + .select("id,name,code,is_system") + .eq("company_id", cid) + .or("is_bank.eq.true,name.eq.Undeposited Funds") + .order("code"); + return data ?? []; + }, + }); + + const reset = () => { + setNumber(generateNumber()); + setDate(today()); + setCustomerName(""); + setCustomerAddress(""); + setIncomeAccountId(""); + setDepositAccountId(""); + setQuantity(1); + setRate(0); + setMemo(""); + }; + + const openDialog = async () => { + reset(); + // Make sure there's somewhere to deposit to. + await ensureUndepositedFunds(cid); + qc.invalidateQueries({ queryKey: ["deposit-accounts", cid] }); + setOpen(true); + }; + + const save = async () => { + if (!number.trim()) return toast.error("Receipt number is required"); + if (!incomeAccountId) return toast.error("Select an income account"); + if (!depositAccountId) return toast.error("Select a deposit account"); + if (total <= 0) return toast.error("Amount must be greater than 0"); + + setSaving(true); + try { + const incomeName = (incomeAccounts as any[]).find((a) => a.id === incomeAccountId)?.name ?? "Sale"; + const desc = `Sales Receipt ${number}${customerName ? " · " + customerName : ""} · ${incomeName}`; + + // 1. Record the receipt document + const { data: sr, error: srErr } = await accounting + .from("sales_receipts") + .insert({ + company_id: cid, + number, + receipt_date: date, + customer_name: customerName || null, + customer_address: customerAddress || null, + income_account_id: incomeAccountId, + deposit_account_id: depositAccountId, + quantity, + rate, + total, + memo: memo || null, + }) + .select("id") + .single(); + if (srErr || !sr) throw new Error(srErr?.message ?? "Failed to save sales receipt"); + + // 2. Post the money in: debit deposit account, credit income account. + // The transaction triggers handle GL posting + account balances. + const { data: txn, error: txnErr } = await accounting + .from("transactions") + .insert({ + company_id: cid, + account_id: depositAccountId, + coa_account_id: incomeAccountId, + date, + type: "credit", + amount: total, + description: desc, + category: "Sales Receipt", + reference: number, + }) + .select("id") + .single(); + if (txnErr || !txn) { + // Roll back the orphaned document so we don't leave a receipt with no GL impact. + await accounting.from("sales_receipts").delete().eq("id", sr.id); + throw new Error(txnErr?.message ?? "Failed to post sales receipt"); + } + + await accounting.from("sales_receipts").update({ transaction_id: txn.id }).eq("id", sr.id); + + toast.success("Sales receipt recorded"); + setOpen(false); + reset(); + qc.invalidateQueries({ queryKey: ["sales-receipts", cid] }); + qc.invalidateQueries({ queryKey: ["accounts", cid] }); + qc.invalidateQueries({ queryKey: ["transactions", cid] }); + } catch (e: any) { + toast.error(e?.message ?? "Failed"); + } finally { + setSaving(false); + } + }; + + const remove = async (r: any) => { + if (!confirm(`Delete sales receipt ${r.number}? This also reverses its accounting entry.`)) return; + // Delete the transaction first so its GL + balances are reversed by triggers. + if (r.transaction_id) { + const { error } = await accounting.from("transactions").delete().eq("id", r.transaction_id); + if (error) return toast.error(error.message); + } + const { error } = await accounting.from("sales_receipts").delete().eq("id", r.id); + if (error) return toast.error(error.message); + toast.success("Sales receipt deleted"); + qc.invalidateQueries({ queryKey: ["sales-receipts", cid] }); + qc.invalidateQueries({ queryKey: ["accounts", cid] }); + qc.invalidateQueries({ queryKey: ["transactions", cid] }); + }; + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return receipts as any[]; + return (receipts as any[]).filter((r) => + `${r.number} ${r.customer_name ?? ""} ${r.income_account?.name ?? ""}`.toLowerCase().includes(q) + ); + }, [receipts, search]); + + if (!associationId) return

Select an association.

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

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

; + + return ( +
+
+
+

Sales Receipts

+

{filtered.length} of {(receipts as any[]).length}

+
+ { if (!v) { setOpen(false); reset(); } }}> + + + + + New Sales Receipt +
+
+
+ + setCustomerName(e.target.value)} /> +
+
+ + setNumber(e.target.value)} /> +
+
+ +