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 */}
-
-
-
-
- {formatCurrency(reportData?.totalRevenue ?? 0)}
-
-
-
-
-
- {formatCurrency(reportData?.totalPending ?? 0)}
-
-
-
-
-
- {formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
-
-
-
-
-
- {(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 ── */}
+
+
+
+
+
+ {formatCurrency(overviewData?.totalRevenue ?? 0)}
+
+
+
+
+
+ {formatCurrency(overviewData?.totalPending ?? 0)}
+
+
+
+
+
+ {formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
+
+
+
+
+
+ {(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]) => (
+
+ ))}
+ {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]) => (
-
-
-
+
+ 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 })