diff --git a/drizzle/0002_tax_deductible.sql b/drizzle/0002_tax_deductible.sql new file mode 100644 index 0000000..acc7c15 --- /dev/null +++ b/drizzle/0002_tax_deductible.sql @@ -0,0 +1 @@ +ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 226fccb..6a24583 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -16,5 +16,12 @@ "tag": "0001_supreme_the_enforcers", "breakpoints": true } + ,{ + "idx": 2, + "version": "7", + "when": 1775400000000, + "tag": "0002_tax_deductible", + "breakpoints": true + } ] } \ No newline at end of file diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index a35d7c7..caf3304 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -39,6 +39,7 @@ interface ExpenseFormData { category: string; billable: boolean; reimbursable: boolean; + taxDeductible: boolean; notes: string; clientId: string; } @@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = { category: "", billable: false, reimbursable: false, + taxDeductible: false, notes: "", clientId: "", }; @@ -89,6 +91,7 @@ export default function ExpensesPage() { category: expense.category ?? "", billable: expense.billable, reimbursable: expense.reimbursable, + taxDeductible: expense.taxDeductible ?? false, notes: expense.notes ?? "", clientId: expense.clientId ?? "", }); @@ -97,13 +100,14 @@ export default function ExpensesPage() { const handleSubmit = () => { if (!form.description.trim()) { toast.error("Description is required"); return; } if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; } - const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined }; + const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible }; if (editId) update.mutate({ id: editId, ...payload }); else create.mutate(payload); }; const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0); + const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0); return (
@@ -114,7 +118,7 @@ export default function ExpensesPage() { {/* Summary cards */} -
+

Total

@@ -127,7 +131,13 @@ export default function ExpensesPage() {

{formatCurrency(billableTotal)}

- + + +

Deductible

+

{formatCurrency(deductibleTotal)}

+
+
+

Count

{expenses.length}

@@ -159,6 +169,7 @@ export default function ExpensesPage() {

{expense.description}

{expense.billable && Billable} {expense.reimbursable && Reimbursable} + {expense.taxDeductible && Tax Deductible} {expense.category && {expense.category}}

@@ -229,7 +240,7 @@ export default function ExpensesPage() {

-
+
+
diff --git a/src/app/dashboard/reports/page.tsx b/src/app/dashboard/reports/page.tsx index 2a6b7e7..ae7364e 100644 --- a/src/app/dashboard/reports/page.tsx +++ b/src/app/dashboard/reports/page.tsx @@ -1,10 +1,14 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { api } from "~/trpc/react"; import { PageHeader } from "~/components/layout/page-header"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { StatusBadge } from "~/components/data/status-badge"; +import { Button } from "~/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; +import { Separator } from "~/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { formatCurrency } from "~/lib/currency"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import type { StoredInvoiceStatus } from "~/types/invoice"; @@ -19,18 +23,23 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; -import { TrendingUp, DollarSign, Clock, Users } from "lucide-react"; +import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react"; export default function ReportsPage() { - const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery(); + const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery(); + const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery(); const { data: stats } = api.dashboard.getStats.useQuery(); - const now = new Date(); + const isLoading = invoicesLoading || expensesLoading; - const reportData = useMemo(() => { + const now = new Date(); + const currentYear = now.getFullYear(); + const [taxYear, setTaxYear] = useState(String(currentYear)); + + // Overview data (last 12 months) + const overviewData = useMemo(() => { if (!invoices.length) return null; - // Revenue by month (last 12 months) const monthMap: Record = {}; for (let i = 11; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); @@ -41,7 +50,6 @@ export default function ReportsPage() { let totalRevenue = 0; let totalPending = 0; let totalHours = 0; - let overdueCount = 0; for (const inv of invoices) { const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); @@ -52,7 +60,6 @@ export default function ReportsPage() { } else if (status === "sent" || status === "overdue") { totalPending += inv.totalAmount; } - if (status === "overdue") overdueCount++; totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0); } @@ -61,35 +68,122 @@ export default function ReportsPage() { revenue, })); - // Top clients by revenue (paid only) - const clientMap: Record = {}; + const clientMap: Record = {}; for (const inv of invoices) { const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); if (status === "paid" && inv.client) { const id = inv.client.id; - if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 }; + if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 }; clientMap[id]!.revenue += inv.totalAmount; - clientMap[id]!.count += 1; } } - const topClients = Object.values(clientMap) - .sort((a, b) => b.revenue - a.revenue) - .slice(0, 6); + const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6); - // Status breakdown const statusCount: Record = { draft: 0, sent: 0, paid: 0, overdue: 0 }; for (const inv of invoices) { const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); statusCount[s] = (statusCount[s] ?? 0) + 1; } - return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount }; + return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount }; }, [invoices]); + // Tax summary for selected year + const taxData = useMemo(() => { + const year = parseInt(taxYear); + + const yearInvoices = invoices.filter((inv) => { + const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); + return status === "paid" && new Date(inv.issueDate).getFullYear() === year; + }); + const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year); + + const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0); + const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0); + const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0); + const deductibleExpenses = yearExpenses + .filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible) + .reduce((s, exp) => s + exp.amount, 0); + + const netProfit = grossIncome - deductibleExpenses; + const seTaxBase = Math.max(0, netProfit) * 0.9235; + const selfEmploymentTax = seTaxBase * 0.153; + const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2); + const federalEstimate = taxableIncome * 0.22; + const totalEstimated = selfEmploymentTax + federalEstimate; + + const quarters = [1, 2, 3, 4].map((q) => { + const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2]; + return { + label: `Q${q}`, + income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0), + expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0), + }; + }); + + return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses }; + }, [invoices, expenses, taxYear]); + + const availableYears = useMemo(() => { + const years = new Set([currentYear, currentYear - 1]); + for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear()); + for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); + return Array.from(years).sort((a, b) => b - a); + }, [invoices, expenses, currentYear]); + + const avgInvoice = invoices.length > 0 + ? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1) + : 0; + + function exportCSV() { + const rows: string[] = [ + `Tax Year ${taxYear} - Income & Expense Report`, + `Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`, + "", + "INCOME (Paid Invoices)", + "Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total", + ...taxData.yearInvoices.map((inv) => { + const taxAmt = inv.totalAmount * (inv.taxRate ?? 0); + return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(","); + }), + `,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`, + "", + "EXPENSES", + "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", + ...taxData.yearExpenses.map((exp) => [ + new Date(exp.date).toLocaleDateString("en-US"), + `"${exp.description}"`, + `"${exp.category ?? ""}"`, + exp.amount.toFixed(2), + exp.currency, + exp.billable ? "Yes" : "No", + exp.reimbursable ? "Yes" : "No", + (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No", + ].join(",")), + `,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`, + "", + "TAX SUMMARY", + `Gross Income,${taxData.grossIncome.toFixed(2)}`, + `Tax Collected,${taxData.taxCollected.toFixed(2)}`, + `Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`, + `Net Profit,${taxData.netProfit.toFixed(2)}`, + `Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`, + `Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`, + `Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`, + ]; + const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `tax-report-${taxYear}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + if (isLoading) { return (
- +
{[...Array(4)].map((_, i) =>
)}
@@ -97,165 +191,264 @@ export default function ReportsPage() { ); } - const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0; - return (
- + - {/* KPI cards */} -
- - -
-
- -
-

Total Revenue

-
-

{formatCurrency(reportData?.totalRevenue ?? 0)}

-
-
- - -
-
- -
-

Pending

-
-

{formatCurrency(reportData?.totalPending ?? 0)}

-
-
- - -
-
- -
-

Avg Invoice

-
-

{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}

-
-
- - -
-
- -
-

Total Hours

-
-

{(reportData?.totalHours ?? 0).toFixed(1)}h

-
-
-
+ + + Overview + Tax Summary + - {/* Revenue trend chart */} - - - - Revenue (Last 12 Months) - - - -
- - - - - - - - - - - `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> - [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> - - - + {/* ── OVERVIEW TAB ── */} + +
+ + +
+
+

Total Revenue

+
+

{formatCurrency(overviewData?.totalRevenue ?? 0)}

+
+
+ + +
+
+

Pending

+
+

{formatCurrency(overviewData?.totalPending ?? 0)}

+
+
+ + +
+
+

Avg Invoice

+
+

{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}

+
+
+ + +
+
+

Total Hours

+
+

{(overviewData?.totalHours ?? 0).toFixed(1)}h

+
+
- - -
- {/* Top clients */} - - - - Top Clients by Revenue - - - - {!reportData?.topClients.length ? ( -

No paid invoices yet.

- ) : ( -
+ + + Revenue (Last 12 Months) + + +
- - `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> - + + + + + + + + + + `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> - + + + +
+
+
+ +
+ + + Top Clients by Revenue + + + {!overviewData?.topClients.length ? ( +

No paid invoices yet.

+ ) : ( +
+ + + `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> + + [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> + + + +
+ )} +
+
+ + + Invoice Status Breakdown + + {Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => ( +
+ +
+
+
+
+ {count} +
+
+ ))} + {invoices.length === 0 &&

No invoices yet.

} + + +
+ + {stats && ( + + Recent Activity + +
+ {stats.recentInvoices.map((inv) => ( +
+
+

{inv.client?.name ?? "—"}

+

{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}

+
+
+ +

{formatCurrency(inv.totalAmount)}

+
+
+ ))} +
+
+
+ )} + + + {/* ── TAX SUMMARY TAB ── */} + +
+
+ Tax Year + +
+ +
+ + {/* Income */} + + + Income + + +
+ Gross Income (paid invoices) + {formatCurrency(taxData.grossIncome)} +
+ {taxData.taxCollected > 0 && ( +
+ Tax Collected from Clients + {formatCurrency(taxData.taxCollected)} +
+ )} + +
+ Total Invoiced (inc. tax) + {formatCurrency(taxData.totalInvoiced)} +
+
+
+ + {/* Expenses */} + + + Expenses & Deductions + + +
+ Total Expenses + {formatCurrency(taxData.totalExpenses)} +
+
+ Tax-Deductible Expenses + {formatCurrency(taxData.deductibleExpenses)} +
+ {taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && ( +

Mark expenses as "Tax Deductible" in the Expenses page to include them here.

+ )} +
+
+ + {/* Estimated tax */} + + + Estimated Tax Liability + + +
+ Net Profit (income − deductible expenses) + {formatCurrency(taxData.netProfit)} +
+
+ Self-Employment Tax (15.3% on 92.35% of net) + {formatCurrency(taxData.selfEmploymentTax)} +
+
+ Federal Income Tax (est. 22% bracket) + {formatCurrency(taxData.federalEstimate)} +
+ +
+ Total Estimated Tax + {formatCurrency(taxData.totalEstimated)} +
+

+ Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing. +

+
+
+ + {/* Quarterly chart */} + + Quarterly Breakdown + +
+ + + + + `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> + [formatCurrency(v), name === "income" ? "Income" : "Expenses"]} + contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} + /> + +
- )} -
-
- - {/* Invoice status breakdown */} - - - Invoice Status Breakdown - - - {Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => ( -
- -
-
-
-
- {count} -
+
+ Income + Expenses
- ))} - {invoices.length === 0 && ( -

No invoices yet.

- )} - - -
- - {/* Monthly stats table */} - {stats && ( - - - Recent Activity - - -
- {stats.recentInvoices.map((inv) => ( -
-
-

{inv.client?.name ?? "—"}

-

{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}

-
-
- -

{formatCurrency(inv.totalAmount)}

-
-
- ))} -
-
-
- )} + + + +
); } diff --git a/src/components/data/invoice-view.tsx b/src/components/data/invoice-view.tsx index c9abaf5..f4ab95f 100644 --- a/src/components/data/invoice-view.tsx +++ b/src/components/data/invoice-view.tsx @@ -423,18 +423,24 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
Subtotal - {formatCurrency(invoice.totalAmount)} + {formatCurrency(invoice.totalAmount, invoice.currency)}
-
- Tax - $0.00 -
+ {(invoice.taxRate ?? 0) > 0 && ( +
+ + Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%) + + + {formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)} + +
+ )}
Total - {formatCurrency(invoice.totalAmount)} + {formatCurrency(invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), invoice.currency)}
diff --git a/src/server/api/routers/expenses.ts b/src/server/api/routers/expenses.ts index c544080..3ca7da5 100644 --- a/src/server/api/routers/expenses.ts +++ b/src/server/api/routers/expenses.ts @@ -15,6 +15,7 @@ const createExpenseSchema = z.object({ category: z.string().optional().or(z.literal("")), billable: z.boolean().default(false), reimbursable: z.boolean().default(false), + taxDeductible: z.boolean().default(false), notes: z.string().optional().or(z.literal("")), clientId: z.string().optional().or(z.literal("")), businessId: z.string().optional().or(z.literal("")), diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 973929b..828587a 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -160,6 +160,16 @@ async function isMigrationApplied(client: Pool, tag: string): Promise { `); return parseInt(rows[0]?.count ?? "0") > 0; } + if (tag === "0002_tax_deductible") { + // 0002 adds taxDeductible to beenvoice_expense + const { rows } = await client.query<{ count: string }>(` + SELECT COUNT(*)::text AS count FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'beenvoice_expense' + AND column_name = 'taxDeductible' + `); + return parseInt(rows[0]?.count ?? "0") > 0; + } // Unknown migration — assume not applied so it runs return false; } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 40ca870..d69fe4e 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -334,6 +334,7 @@ export const expenses = createTable( category: d.varchar({ length: 100 }), billable: d.boolean().default(false).notNull(), reimbursable: d.boolean().default(false).notNull(), + taxDeductible: d.boolean().default(false).notNull(), notes: d.varchar({ length: 500 }), createdById: d .varchar({ length: 255 })