From f0c34160df76cfa15dfe161a52370a6deac03386 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sun, 10 May 2026 17:17:58 -0400 Subject: [PATCH] feat: add recurring invoices, public links, time tracker, payments, reminders - Recurring invoices: schedule-based auto-generation with CRUD UI at /dashboard/invoices/recurring and POST /api/cron/generate-recurring cron endpoint - Public invoice link: generate/revoke shareable /i/[token] page for unauthenticated clients with PDF download - Live time tracker: localStorage-persisted timer widget in invoice editor that appends a line item on stop (rounds to nearest 0.25h) - Partial payment tracking: record payments per invoice, auto-mark paid when fully covered, balance due display - Send reminder: email reminder via Resend with custom message dialog and last-sent indicator Co-Authored-By: Claude Sonnet 4.6 --- .claude/launch.json | 12 + src/app/api/cron/generate-recurring/route.ts | 16 + src/app/dashboard/invoices/[id]/page.tsx | 659 +++++++++++++----- src/app/dashboard/invoices/recurring/page.tsx | 534 ++++++++++++++ src/app/i/[token]/page.tsx | 209 ++++++ src/components/forms/invoice-form.tsx | 2 + src/components/forms/invoice-line-items.tsx | 21 +- src/components/invoice/time-tracker.tsx | 136 ++++ src/env.js | 2 + src/lib/email-templates/reminder-email.ts | 135 ++++ src/lib/navigation.ts | 2 + src/server/api/root.ts | 9 +- src/server/api/routers/invoices.ts | 129 +++- src/server/api/routers/payments.ts | 103 +++ src/server/api/routers/recurring-invoices.ts | 297 ++++++++ src/server/db/schema.ts | 151 ++++ 16 files changed, 2222 insertions(+), 195 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/app/api/cron/generate-recurring/route.ts create mode 100644 src/app/dashboard/invoices/recurring/page.tsx create mode 100644 src/app/i/[token]/page.tsx create mode 100644 src/components/invoice/time-tracker.tsx create mode 100644 src/lib/email-templates/reminder-email.ts create mode 100644 src/server/api/routers/payments.ts create mode 100644 src/server/api/routers/recurring-invoices.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..17b92d3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "beenvoice-dev", + "runtimeExecutable": "bun", + "runtimeArgs": ["dev"], + "port": 3000, + "autoPort": false + } + ] +} diff --git a/src/app/api/cron/generate-recurring/route.ts b/src/app/api/cron/generate-recurring/route.ts new file mode 100644 index 0000000..255226a --- /dev/null +++ b/src/app/api/cron/generate-recurring/route.ts @@ -0,0 +1,16 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { env } from "~/env"; +import { db } from "~/server/db"; +import { generateDueRecurringInvoices } from "~/server/api/routers/recurring-invoices"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + const secret = env.CRON_SECRET; + + if (secret && authHeader !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const generated = await generateDueRecurringInvoices(db); + return NextResponse.json({ generated }); +} diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index 30bb5a1..1c8005f 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -1,13 +1,32 @@ "use client"; -import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react"; +import { + AlertTriangle, + Bell, + Building, + Check, + Copy, + DollarSign, + Edit, + FileText, + Link2, + Link2Off, + Loader2, + Mail, + MapPin, + Phone, + Plus, + Trash2, + User, +} from "lucide-react"; import Link from "next/link"; import { notFound, useParams, useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import { toast } from "sonner"; -import { StatusBadge, type StatusType } from "~/components/data/status-badge"; +import { StatusBadge } from "~/components/data/status-badge"; import { PageHeader } from "~/components/layout/page-header"; import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Dialog, @@ -17,7 +36,22 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; import { Separator } from "~/components/ui/separator"; +import { Textarea } from "~/components/ui/textarea"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; import { getEffectiveInvoiceStatus, isInvoiceOverdue, @@ -28,96 +62,154 @@ import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton"; import { PDFDownloadButton } from "./_components/pdf-download-button"; import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button"; -import { - AlertTriangle, - Building, - Check, - FileText, - Mail, - MapPin, - Phone, - User, -} from "lucide-react"; +const PAYMENT_METHODS = [ + { value: "cash", label: "Cash" }, + { value: "check", label: "Check" }, + { value: "bank_transfer", label: "Bank Transfer" }, + { value: "credit_card", label: "Credit Card" }, + { value: "paypal", label: "PayPal" }, + { value: "other", label: "Other" }, +] as const; + +function methodLabel(method: string) { + return PAYMENT_METHODS.find((m) => m.value === method)?.label ?? method; +} + +function daysSince(date: Date) { + return Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000); +} function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { const router = useRouter(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [recordPaymentOpen, setRecordPaymentOpen] = useState(false); + const [reminderOpen, setReminderOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [paymentAmount, setPaymentAmount] = useState(""); + const [paymentMethod, setPaymentMethod] = useState("other"); + const [paymentNotes, setPaymentNotes] = useState(""); + const [reminderMessage, setReminderMessage] = useState(""); + const [copied, setCopied] = useState(false); const { data: invoice, isLoading } = api.invoices.getById.useQuery({ id: invoiceId, }); + const { data: payments, isLoading: paymentsLoading } = + api.payments.getByInvoice.useQuery({ invoiceId }); const utils = api.useUtils(); + const invalidate = () => { + void utils.invoices.getById.invalidate({ id: invoiceId }); + void utils.payments.getByInvoice.invalidate({ invoiceId }); + }; + const deleteInvoice = api.invoices.delete.useMutation({ onSuccess: () => { - toast.success("Invoice deleted successfully"); + toast.success("Invoice deleted"); router.push("/dashboard/invoices"); }, - onError: (error) => { - toast.error(error.message ?? "Failed to delete invoice"); - }, + onError: (e) => toast.error(e.message ?? "Failed to delete invoice"), }); const updateStatus = api.invoices.updateStatus.useMutation({ onSuccess: (data) => { toast.success(data.message); - void utils.invoices.getById.invalidate({ id: invoiceId }); - }, - onError: (error) => { - toast.error(error.message ?? "Failed to update invoice status"); + invalidate(); }, + onError: (e) => toast.error(e.message ?? "Failed to update status"), }); - const handleDelete = () => { - setDeleteDialogOpen(true); - }; + const createPayment = api.payments.create.useMutation({ + onSuccess: () => { + toast.success("Payment recorded"); + setRecordPaymentOpen(false); + setPaymentAmount(""); + setPaymentMethod("other"); + setPaymentNotes(""); + invalidate(); + }, + onError: (e) => toast.error(e.message ?? "Failed to record payment"), + }); - const handleMarkAsPaid = () => { - updateStatus.mutate({ - id: invoiceId, - status: "paid", - }); - }; + const deletePayment = api.payments.delete.useMutation({ + onSuccess: () => { + toast.success("Payment removed"); + invalidate(); + }, + onError: (e) => toast.error(e.message ?? "Failed to remove payment"), + }); - const confirmDelete = () => { - deleteInvoice.mutate({ id: invoiceId }); - }; + const generatePublicToken = api.invoices.generatePublicToken.useMutation({ + onSuccess: () => { + toast.success("Share link generated"); + void utils.invoices.getById.invalidate({ id: invoiceId }); + }, + onError: (e) => toast.error(e.message ?? "Failed to generate link"), + }); - if (isLoading) { - return ; - } + const revokePublicToken = api.invoices.revokePublicToken.useMutation({ + onSuccess: () => { + toast.success("Share link revoked"); + void utils.invoices.getById.invalidate({ id: invoiceId }); + }, + onError: (e) => toast.error(e.message ?? "Failed to revoke link"), + }); - if (!invoice) { - notFound(); - } + const sendReminder = api.invoices.sendReminder.useMutation({ + onSuccess: () => { + toast.success("Reminder sent"); + setReminderOpen(false); + setReminderMessage(""); + void utils.invoices.getById.invalidate({ id: invoiceId }); + }, + onError: (e) => toast.error(e.message ?? "Failed to send reminder"), + }); - const formatDate = (date: Date) => { - return new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }).format(new Date(date)); - }; + if (isLoading) return ; + if (!invoice) notFound(); - const formatCurrency = (amount: number, currency = invoice.currency) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - }).format(amount); - }; + const formatDate = (date: Date) => + new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format( + new Date(date), + ); - const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); + const formatCurrency = (amount: number, currency = invoice.currency) => + new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); + + const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0); const taxAmount = (subtotal * invoice.taxRate) / 100; const total = subtotal + taxAmount; + const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0); + const balanceDue = total - totalPaid; const storedStatus = invoice.status as StoredInvoiceStatus; - const effectiveStatus = getEffectiveInvoiceStatus( - storedStatus, - invoice.dueDate, - ); + const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate); const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate); + const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue"; - const getStatusType = (): StatusType => { - return effectiveStatus; + const publicUrl = invoice.publicToken + ? `${window.location.origin}/i/${invoice.publicToken}` + : null; + + const handleCopyLink = async () => { + if (!publicUrl) return; + await navigator.clipboard.writeText(publicUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleRecordPayment = () => { + const amount = parseFloat(paymentAmount); + if (isNaN(amount) || amount <= 0) { + toast.error("Enter a valid payment amount"); + return; + } + createPayment.mutate({ + invoiceId, + amount, + date: new Date(), + method: paymentMethod as Parameters[0]["method"], + notes: paymentNotes || undefined, + }); }; return ( @@ -127,20 +219,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { description="View and manage invoice information" variant="gradient" > - + - {/* Content */}
{/* Left Column */}
@@ -154,24 +241,23 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {

{invoice.invoiceNumber}

- +
-
- Issued {formatDate(invoice.issueDate)} -
+
Issued {formatDate(invoice.issueDate)}
Due {formatDate(invoice.dueDate)}
-

- Total Amount -

-

- {formatCurrency(total)} -

+

Total Amount

+

{formatCurrency(total)}

+ {totalPaid > 0 && balanceDue > 0 && ( +

+ Balance due: {formatCurrency(balanceDue)} +

+ )}
@@ -188,8 +274,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {

Invoice Overdue

{Math.ceil( - (new Date().getTime() - - new Date(invoice.dueDate).getTime()) / + (new Date().getTime() - new Date(invoice.dueDate).getTime()) / (1000 * 60 * 60 * 24), )}{" "} days past due date @@ -200,9 +285,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { )} - {/* Client & Business Info */} + {/* Client & Business */}

- {/* Client Information */} @@ -211,24 +295,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { -
-

- {invoice.client.name} -

-
- +

{invoice.client.name}

{invoice.client.email && (
- - {invoice.client.email} - + {invoice.client.email}
)} - {invoice.client.phone && (
@@ -237,19 +313,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { {invoice.client.phone}
)} - {(invoice.client.addressLine1 ?? invoice.client.city) && (
- {invoice.client.addressLine1 && ( -
{invoice.client.addressLine1}
- )} - {invoice.client.addressLine2 && ( -
{invoice.client.addressLine2}
- )} + {invoice.client.addressLine1 &&
{invoice.client.addressLine1}
} + {invoice.client.addressLine2 &&
{invoice.client.addressLine2}
} {(invoice.client.city ?? invoice.client.state ?? invoice.client.postalCode) && ( @@ -263,9 +334,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { .join(", ")}
)} - {invoice.client.country && ( -
{invoice.client.country}
- )} + {invoice.client.country &&
{invoice.client.country}
}
)} @@ -273,7 +342,6 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { - {/* Business Information */} {invoice.business && ( @@ -283,32 +351,24 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { -
-

- {invoice.business.name} -

-
- +

+ {invoice.business.name} +

{invoice.business.email && (
- - {invoice.business.email} - + {invoice.business.email}
)} - {invoice.business.phone && (
- - {invoice.business.phone} - + {invoice.business.phone}
)}
@@ -326,72 +386,126 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { - {invoice.items.map((item, _index) => ( + {invoice.items.map((item) => ( -
-
-
-

- {item.description} -

-
-
- - {formatDate(item.date).replace(/ /g, "\u00A0")} - - - {item.hours.toString().replace(/ /g, "\u00A0")} -  hours - - - @ ${item.rate}/hr - -
-
-
-
-

- {formatCurrency(item.amount)} -

+
+
+

+ {item.description} +

+
+ + {formatDate(item.date).replace(/ /g, " ")} + + + {item.hours.toString()} hours + + @ ${item.rate}/hr
+

+ {formatCurrency(item.amount)} +

))} {/* Totals */} -
-
+
+
+ Subtotal: + {formatCurrency(subtotal)} +
+ {invoice.taxRate > 0 && (
- Subtotal: - - {formatCurrency(subtotal)} - + Tax ({invoice.taxRate}%): + {formatCurrency(taxAmount)}
- {invoice.taxRate > 0 && ( -
- - Tax ({invoice.taxRate}%): - - - {formatCurrency(taxAmount)} + )} + +
+ Total: + {formatCurrency(total)} +
+ {totalPaid > 0 && ( + <> +
+ Paid: + + − {formatCurrency(totalPaid)}
- )} - -
- Total: - - {formatCurrency(total)} - -
-
+ +
+ Balance Due: + + {formatCurrency(Math.max(0, balanceDue))} + +
+ + )}
+ {/* Payments */} + + + + + + Payments + + + + + + {paymentsLoading ? ( +

Loading…

+ ) : (payments ?? []).length === 0 ? ( +

No payments recorded yet.

+ ) : ( +
+ {(payments ?? []).map((p) => ( +
+
+ {formatCurrency(p.amount)} + {methodLabel(p.method)} + {formatDate(p.date)} + {p.notes && ( + + {p.notes} + + )} +
+ +
+ ))} +
+ )} +
+
+ {/* Notes */} {invoice.notes && ( @@ -399,9 +513,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { Notes -

- {invoice.notes} -

+

{invoice.notes}

)} @@ -425,14 +537,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { {invoice.items && invoice.client && ( - + )} - {/* Send Invoice Button - Show for draft, sent, and overdue */} {effectiveStatus === "draft" && ( )} - {(effectiveStatus === "sent" || - effectiveStatus === "overdue") && ( + {(effectiveStatus === "sent" || effectiveStatus === "overdue") && ( )} - {/* Manual Status Updates */} - {(effectiveStatus === "sent" || - effectiveStatus === "overdue") && ( + {/* Send Reminder */} + {canSendReminder && ( +
+ + {invoice.lastReminderSentAt && ( +

+ Last sent {daysSince(invoice.lastReminderSentAt)} day + {daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago +

+ )} +
+ )} + + {/* Share Link */} + + + + + +

Client share link

+ {publicUrl ? ( + <> +
+

{publicUrl}

+ +
+ + + ) : ( + <> +

+ Generate a shareable link your client can use to view this invoice without + logging in. +

+ + + )} +
+
+ + {/* Mark as Paid */} + {(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
- {/* Delete Confirmation Dialog */} + {/* Record Payment Dialog */} + + + + Record Payment + + Record a payment received for invoice {invoice.invoiceNumber}. + + +
+
+ + setPaymentAmount(e.target.value)} + /> +
+
+ + +
+
+ + setPaymentNotes(e.target.value)} + /> +
+
+ + + + +
+
+ + {/* Send Reminder Dialog */} + + + + Send Reminder + + Send a payment reminder to {invoice.client.name} for invoice{" "} + {invoice.invoiceNumber}. + + +
+ +