Add tax features: summary report, deductible expenses, invoice tax fix, CSV export

- Add taxDeductible boolean to expenses schema + migration 0002
- Update expenses router, form, and list to support tax-deductible flag
- Fix invoice-view tax calculation (was hardcoded $0.00; now uses taxRate)
- New Tax Summary tab in Reports: year selector, income/deductions breakdown,
  SE tax + federal income estimates, quarterly bar chart
- CSV export for accountant with income + expense rows and tax summary

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
This commit is contained in:
Claude
2026-04-05 03:21:08 +00:00
parent 1f76cf38a7
commit 74f9696023
8 changed files with 409 additions and 175 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
+7
View File
@@ -16,5 +16,12 @@
"tag": "0001_supreme_the_enforcers", "tag": "0001_supreme_the_enforcers",
"breakpoints": true "breakpoints": true
} }
,{
"idx": 2,
"version": "7",
"when": 1775400000000,
"tag": "0002_tax_deductible",
"breakpoints": true
}
] ]
} }
+19 -4
View File
@@ -39,6 +39,7 @@ interface ExpenseFormData {
category: string; category: string;
billable: boolean; billable: boolean;
reimbursable: boolean; reimbursable: boolean;
taxDeductible: boolean;
notes: string; notes: string;
clientId: string; clientId: string;
} }
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
category: "", category: "",
billable: false, billable: false,
reimbursable: false, reimbursable: false,
taxDeductible: false,
notes: "", notes: "",
clientId: "", clientId: "",
}; };
@@ -89,6 +91,7 @@ export default function ExpensesPage() {
category: expense.category ?? "", category: expense.category ?? "",
billable: expense.billable, billable: expense.billable,
reimbursable: expense.reimbursable, reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "", notes: expense.notes ?? "",
clientId: expense.clientId ?? "", clientId: expense.clientId ?? "",
}); });
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
const handleSubmit = () => { const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; } if (!form.description.trim()) { toast.error("Description is required"); return; }
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); 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 }); if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload); else create.mutate(payload);
}; };
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); 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 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 ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
</PageHeader> </PageHeader>
{/* Summary cards */} {/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p> <p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
@@ -127,7 +131,13 @@ export default function ExpensesPage() {
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p> <p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-2 sm:col-span-1"> <Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p> <p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p> <p className="mt-1 text-2xl font-bold">{expenses.length}</p>
@@ -159,6 +169,7 @@ export default function ExpensesPage() {
<p className="font-medium">{expense.description}</p> <p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>} {expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>} {expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>} {expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
</div> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex gap-6"> <div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} /> <Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
<span className="text-sm">Billable</span> <span className="text-sm">Billable</span>
@@ -238,6 +249,10 @@ export default function ExpensesPage() {
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} /> <Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
<span className="text-sm">Reimbursable</span> <span className="text-sm">Reimbursable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
<span className="text-sm">Tax Deductible</span>
</label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Notes (optional)</Label> <Label>Notes (optional)</Label>
+257 -64
View File
@@ -1,10 +1,14 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; 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 { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -19,18 +23,23 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } 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() { 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 { 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; if (!invoices.length) return null;
// Revenue by month (last 12 months)
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) { for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
@@ -41,7 +50,6 @@ export default function ReportsPage() {
let totalRevenue = 0; let totalRevenue = 0;
let totalPending = 0; let totalPending = 0;
let totalHours = 0; let totalHours = 0;
let overdueCount = 0;
for (const inv of invoices) { for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
@@ -52,7 +60,6 @@ export default function ReportsPage() {
} else if (status === "sent" || status === "overdue") { } else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount; totalPending += inv.totalAmount;
} }
if (status === "overdue") overdueCount++;
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0); totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
} }
@@ -61,35 +68,122 @@ export default function ReportsPage() {
revenue, revenue,
})); }));
// Top clients by revenue (paid only) const clientMap: Record<string, { name: string; revenue: number }> = {};
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
for (const inv of invoices) { for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
if (status === "paid" && inv.client) { if (status === "paid" && inv.client) {
const id = inv.client.id; 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]!.revenue += inv.totalAmount;
clientMap[id]!.count += 1;
} }
} }
const topClients = Object.values(clientMap) const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
// Status breakdown
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 }; const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
for (const inv of invoices) { for (const inv of invoices) {
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
statusCount[s] = (statusCount[s] ?? 0) + 1; statusCount[s] = (statusCount[s] ?? 0) + 1;
} }
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount }; return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
}, [invoices]); }, [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<number>([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) { if (isLoading) {
return ( return (
<div className="page-enter space-y-6"> <div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" /> <PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)} {[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
</div> </div>
@@ -97,42 +191,41 @@ 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 ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" /> <PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
{/* KPI cards */} <Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
</TabsList>
{/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5"> <div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
<DollarSign className="text-primary h-4 w-4" />
</div>
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p> <p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p> <p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5"> <div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
<Clock className="h-4 w-4 text-yellow-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Pending</p> <p className="text-muted-foreground text-xs font-medium">Pending</p>
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p> <p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5"> <div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
<TrendingUp className="h-4 w-4 text-blue-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p> <p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p> <p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
@@ -141,27 +234,22 @@ export default function ReportsPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-green-500/10 rounded p-1.5"> <div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
<Users className="h-4 w-4 text-green-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Total Hours</p> <p className="text-muted-foreground text-xs font-medium">Total Hours</p>
</div> </div>
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p> <p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Revenue trend chart */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-48 w-full md:h-64"> <div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.revenueByMonth ?? []}> <AreaChart data={overviewData?.revenueByMonth ?? []}>
<defs> <defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} /> <stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
@@ -180,20 +268,17 @@ export default function ReportsPage() {
</Card> </Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Top clients */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!reportData?.topClients.length ? ( {!overviewData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p> <p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
) : ( ) : (
<div className="h-48 md:h-56"> <div className="h-48 md:h-56">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={reportData.topClients} layout="vertical"> <BarChart data={overviewData.topClients} layout="vertical">
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} /> <YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> <Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
@@ -205,39 +290,28 @@ export default function ReportsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Invoice status breakdown */}
<Card> <Card>
<CardHeader> <CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => ( {Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
<div key={status} className="flex items-center justify-between"> <div key={status} className="flex items-center justify-between">
<StatusBadge status={status as never} /> <StatusBadge status={status as never} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32"> <div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div <div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
className="bg-primary h-full rounded-full"
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
/>
</div> </div>
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span> <span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
</div> </div>
</div> </div>
))} ))}
{invoices.length === 0 && ( {invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Monthly stats table */}
{stats && ( {stats && (
<Card> <Card>
<CardHeader> <CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="divide-y"> <div className="divide-y">
{stats.recentInvoices.map((inv) => ( {stats.recentInvoices.map((inv) => (
@@ -256,6 +330,125 @@ export default function ReportsPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</TabsContent>
{/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span>
<Select value={taxYear} onValueChange={setTaxYear}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={exportCSV} className="gap-2">
<Download className="h-4 w-4" /> Export CSV
</Button>
</div>
{/* Income */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
</div>
{taxData.taxCollected > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax Collected from Clients</span>
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-medium">
<span>Total Invoiced (inc. tax)</span>
<span>{formatCurrency(taxData.totalInvoiced)}</span>
</div>
</CardContent>
</Card>
{/* Expenses */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Expenses</span>
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
</div>
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
)}
</CardContent>
</Card>
{/* Estimated tax */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Net Profit (income deductible expenses)</span>
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total Estimated Tax</span>
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
</div>
<p className="text-muted-foreground text-xs pt-1">
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
</p>
</CardContent>
</Card>
{/* Quarterly chart */}
<Card>
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
<CardContent>
<div className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={taxData.quarters}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]}
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
/>
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div> </div>
); );
} }
+10 -4
View File
@@ -423,18 +423,24 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span> <span className="text-muted-foreground">Subtotal</span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount)} {formatCurrency(invoice.totalAmount, invoice.currency)}
</span> </span>
</div> </div>
{(invoice.taxRate ?? 0) > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax</span> <span className="text-muted-foreground">
<span className="text-foreground font-medium">$0.00</span> Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
</span>
<span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)}
</span>
</div> </div>
)}
<Separator /> <Separator />
<div className="flex justify-between text-lg font-bold"> <div className="flex justify-between text-lg font-bold">
<span className="text-foreground">Total</span> <span className="text-foreground">Total</span>
<span className="text-primary"> <span className="text-primary">
{formatCurrency(invoice.totalAmount)} {formatCurrency(invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), invoice.currency)}
</span> </span>
</div> </div>
</div> </div>
+1
View File
@@ -15,6 +15,7 @@ const createExpenseSchema = z.object({
category: z.string().optional().or(z.literal("")), category: z.string().optional().or(z.literal("")),
billable: z.boolean().default(false), billable: z.boolean().default(false),
reimbursable: z.boolean().default(false), reimbursable: z.boolean().default(false),
taxDeductible: z.boolean().default(false),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
clientId: z.string().optional().or(z.literal("")), clientId: z.string().optional().or(z.literal("")),
businessId: z.string().optional().or(z.literal("")), businessId: z.string().optional().or(z.literal("")),
+10
View File
@@ -160,6 +160,16 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
`); `);
return parseInt(rows[0]?.count ?? "0") > 0; 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 // Unknown migration — assume not applied so it runs
return false; return false;
} }
+1
View File
@@ -334,6 +334,7 @@ export const expenses = createTable(
category: d.varchar({ length: 100 }), category: d.varchar({ length: 100 }),
billable: d.boolean().default(false).notNull(), billable: d.boolean().default(false).notNull(),
reimbursable: d.boolean().default(false).notNull(), reimbursable: d.boolean().default(false).notNull(),
taxDeductible: d.boolean().default(false).notNull(),
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })