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/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index e47270a..d9e9b89 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -172,8 +172,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 +201,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,8 +604,17 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci return bill.status; }; + const selectedSystem = associations.find((a: any) => a.id === form.association_id)?.accounting_system ?? null; const filteredAccounts = form.association_id - ? accounts.filter((a: any) => !a.association_id || a.association_id === form.association_id) + ? accounts.filter((a: any) => { + // Platform associations use the accounts managed in the accounting + // dashboard (synced into chart_of_accounts as accounting_system 'platform'). + if (selectedSystem === "platform") { + return a.accounting_system === "platform" && a.association_id === form.association_id; + } + // Buildium / Zoho keep their existing scoping; never surface platform-only rows. + return a.accounting_system !== "platform" && (!a.association_id || a.association_id === form.association_id); + }) : []; const filteredVendors = form.association_id diff --git a/src/pages/accounting/AccountingBankingPage.tsx b/src/pages/accounting/AccountingBankingPage.tsx index 7233530..9cb2030 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 } from "./lib/autoBill"; type TxForm = { account_id: string; @@ -256,14 +257,27 @@ 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. + // • Vendor has OPEN bill(s) → 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 those bills (FIFO). + // • No open 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. + let openVendorBills: any[] = []; + if (type === "debit" && vendor_id) { + const { data: vbills } = await accounting + .from("bills").select("id,number,total,paid_amount,issue_date,status") + .eq("company_id", cid).eq("vendor_id", vendor_id); + openVendorBills = (vbills ?? []).filter((b: any) => + !["void", "draft"].includes(b.status) && Number(b.total) - Number(b.paid_amount ?? 0) > 0.005); + } + const debitClearsAp = type === "debit" && openVendorBills.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, }; @@ -273,8 +287,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 +305,23 @@ export default function AccountingBankingPage() { bankAccountId: account_id, }); } + + // When the payment cleared A/P (vendor had open bills), apply it to those + // bills oldest-first so they show paid. The expense lives on the bill, so no + // expense is booked here. With no open bill the payment already posted the + // expense directly (above) — nothing further to do. + if (debitClearsAp) { + let remaining = amount; + const ordered = [...openVendorBills].sort((a, b) => String(a.issue_date ?? "").localeCompare(String(b.issue_date ?? ""))); + for (const b of ordered) { + 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 +1045,7 @@ export default function AccountingBankingPage() { }} /> )} + ); } 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)} /> +
+
+ +