diff --git a/src/app/dashboard/invoices/recurring/page.tsx b/src/app/dashboard/invoices/recurring/page.tsx
new file mode 100644
index 0000000..23f6f13
--- /dev/null
+++ b/src/app/dashboard/invoices/recurring/page.tsx
@@ -0,0 +1,534 @@
+"use client";
+
+import {
+ Check,
+ Loader2,
+ Pause,
+ Play,
+ Plus,
+ RefreshCw,
+ Trash2,
+ Zap,
+} from "lucide-react";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { PageHeader } from "~/components/layout/page-header";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { Card, CardContent } from "~/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "~/components/ui/dialog";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import { NumberInput } from "~/components/ui/number-input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Textarea } from "~/components/ui/textarea";
+import { api } from "~/trpc/react";
+
+const SCHEDULES = [
+ { value: "weekly", label: "Weekly" },
+ { value: "biweekly", label: "Every 2 weeks" },
+ { value: "monthly", label: "Monthly" },
+ { value: "quarterly", label: "Quarterly" },
+ { value: "yearly", label: "Yearly" },
+] as const;
+
+type Schedule = (typeof SCHEDULES)[number]["value"];
+
+interface RecurringItemInput {
+ description: string;
+ hours: number;
+ rate: number;
+}
+
+interface RecurringFormState {
+ name: string;
+ clientId: string;
+ businessId: string;
+ schedule: Schedule;
+ invoicePrefix: string;
+ taxRate: number;
+ currency: string;
+ notes: string;
+ emailMessage: string;
+ items: RecurringItemInput[];
+}
+
+const defaultForm = (): RecurringFormState => ({
+ name: "",
+ clientId: "",
+ businessId: "",
+ schedule: "monthly",
+ invoicePrefix: "#",
+ taxRate: 0,
+ currency: "USD",
+ notes: "",
+ emailMessage: "",
+ items: [{ description: "", hours: 0, rate: 0 }],
+});
+
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(date));
+}
+
+function scheduleLabel(s: string) {
+ return SCHEDULES.find((x) => x.value === s)?.label ?? s;
+}
+
+function RecurringForm({
+ form,
+ setForm,
+ clients,
+ businesses,
+}: {
+ form: RecurringFormState;
+ setForm: React.Dispatch
>;
+ clients: { id: string; name: string }[];
+ businesses: { id: string; name: string }[];
+}) {
+ const addItem = () =>
+ setForm((f) => ({ ...f, items: [...f.items, { description: "", hours: 0, rate: 0 }] }));
+
+ const removeItem = (idx: number) =>
+ setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
+
+ const updateItem = (idx: number, field: keyof RecurringItemInput, value: string | number) =>
+ setForm((f) => ({
+ ...f,
+ items: f.items.map((item, i) => (i === idx ? { ...item, [field]: value } : item)),
+ }));
+
+ return (
+
+
+ Template name
+ setForm((f) => ({ ...f, name: e.target.value }))}
+ />
+
+
+
+
+ Client
+ setForm((f) => ({ ...f, clientId: v }))}
+ >
+
+
+
+
+ {clients.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+
+ Business (optional)
+ setForm((f) => ({ ...f, businessId: v }))}
+ >
+
+
+
+
+ None
+ {businesses.map((b) => (
+
+ {b.name}
+
+ ))}
+
+
+
+
+
+
+
+ Schedule
+ setForm((f) => ({ ...f, schedule: v as Schedule }))}
+ >
+
+
+
+
+ {SCHEDULES.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+ Currency
+ setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))}
+ />
+
+
+
+
+ Tax rate (%)
+ setForm((f) => ({ ...f, taxRate: v ?? 0 }))}
+ min={0}
+ max={100}
+ step={0.1}
+ />
+
+
+ {/* Line items */}
+
+
Line items
+ {form.items.map((item, idx) => (
+
+
+ updateItem(idx, "description", e.target.value)}
+ className="flex-1"
+ />
+ {form.items.length > 1 && (
+ removeItem(idx)}
+ >
+
+
+ )}
+
+
+
+ Hours
+ updateItem(idx, "hours", v ?? 0)}
+ min={0}
+ step={0.25}
+ />
+
+
+ Rate ($/hr)
+ updateItem(idx, "rate", v ?? 0)}
+ min={0}
+ step={1}
+ />
+
+
+
+ ))}
+
+
+ Add item
+
+
+
+
+ Notes (optional)
+
+
+ );
+}
+
+export default function RecurringInvoicesPage() {
+ const router = useRouter();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editId, setEditId] = useState(null);
+ const [deleteId, setDeleteId] = useState(null);
+ const [form, setForm] = useState(defaultForm());
+
+ const { data: recurring, isLoading } = api.recurringInvoices.getAll.useQuery();
+ const { data: clients = [] } = api.clients.getAll.useQuery();
+ const { data: businesses = [] } = api.businesses.getAll.useQuery();
+ const utils = api.useUtils();
+
+ const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
+
+ const create = api.recurringInvoices.create.useMutation({
+ onSuccess: () => { toast.success("Recurring invoice created"); setCreateOpen(false); setForm(defaultForm()); invalidate(); },
+ onError: (e) => toast.error(e.message ?? "Failed to create"),
+ });
+
+ const update = api.recurringInvoices.update.useMutation({
+ onSuccess: () => { toast.success("Updated"); setEditId(null); setForm(defaultForm()); invalidate(); },
+ onError: (e) => toast.error(e.message ?? "Failed to update"),
+ });
+
+ const pause = api.recurringInvoices.pause.useMutation({
+ onSuccess: () => { toast.success("Paused"); invalidate(); },
+ onError: (e) => toast.error(e.message),
+ });
+
+ const resume = api.recurringInvoices.resume.useMutation({
+ onSuccess: () => { toast.success("Resumed"); invalidate(); },
+ onError: (e) => toast.error(e.message),
+ });
+
+ const del = api.recurringInvoices.delete.useMutation({
+ onSuccess: () => { toast.success("Deleted"); setDeleteId(null); invalidate(); },
+ onError: (e) => toast.error(e.message),
+ });
+
+ const generateNow = api.recurringInvoices.generateNow.useMutation({
+ onSuccess: (data) => {
+ toast.success("Invoice generated");
+ invalidate();
+ router.push(`/dashboard/invoices/${data.invoiceId}`);
+ },
+ onError: (e) => toast.error(e.message ?? "Failed to generate"),
+ });
+
+ function handleOpenEdit(rec: NonNullable[number]) {
+ setForm({
+ name: rec.name,
+ clientId: rec.clientId,
+ businessId: rec.businessId ?? "",
+ schedule: rec.schedule as Schedule,
+ invoicePrefix: rec.invoicePrefix ?? "#",
+ taxRate: rec.taxRate,
+ currency: rec.currency,
+ notes: rec.notes ?? "",
+ emailMessage: rec.emailMessage ?? "",
+ items: rec.items.map((i) => ({
+ description: i.description,
+ hours: i.hours,
+ rate: i.rate,
+ })),
+ });
+ setEditId(rec.id);
+ }
+
+ function handleSubmit() {
+ const payload = {
+ ...form,
+ businessId: form.businessId || undefined,
+ notes: form.notes || undefined,
+ emailMessage: form.emailMessage || undefined,
+ items: form.items.map((item, idx) => ({ ...item, position: idx })),
+ };
+ if (editId) {
+ update.mutate({ id: editId, ...payload });
+ } else {
+ create.mutate(payload);
+ }
+ }
+
+ const isSubmitting = create.isPending || update.isPending;
+
+ return (
+
+
+ { setForm(defaultForm()); setCreateOpen(true); }}>
+
+ New recurring
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (recurring ?? []).length === 0 ? (
+
+
+
+
+ No recurring invoices yet. Create one to automatically generate draft invoices on a
+ schedule.
+
+ { setForm(defaultForm()); setCreateOpen(true); }}>
+
+ Create first recurring invoice
+
+
+
+ ) : (
+
+ {(recurring ?? []).map((rec) => (
+
+
+
+
+
+
{rec.name}
+
+ {rec.status}
+
+
+
+ {rec.client.name} · {scheduleLabel(rec.schedule)}
+
+
+ Next: {formatDate(rec.nextDueAt)}
+ {rec.lastGeneratedAt && (
+ <> · Last generated: {formatDate(rec.lastGeneratedAt)}>
+ )}
+
+
+
+
+
generateNow.mutate({ id: rec.id })}
+ disabled={generateNow.isPending}
+ >
+
+ Generate now
+
+
handleOpenEdit(rec)}
+ >
+ Edit
+
+ {rec.status === "active" ? (
+
pause.mutate({ id: rec.id })}
+ disabled={pause.isPending}
+ >
+
+ Pause
+
+ ) : (
+
resume.mutate({ id: rec.id })}
+ disabled={resume.isPending}
+ >
+
+ Resume
+
+ )}
+
setDeleteId(rec.id)}
+ >
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Create / Edit Dialog */}
+
{
+ if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }
+ }}
+ >
+
+
+ {editId ? "Edit recurring invoice" : "New recurring invoice"}
+
+ Configure the template. Invoices will be generated as drafts on the selected schedule.
+
+
+
+
+ { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }}
+ >
+ Cancel
+
+
+ {isSubmitting ? (
+ <> Saving…>
+ ) : editId ? (
+ <> Save changes>
+ ) : (
+ <> Create>
+ )}
+
+
+
+
+
+ {/* Delete Confirmation */}
+
{ if (!open) setDeleteId(null); }}>
+
+
+ Delete recurring invoice
+
+ This will stop automatic generation. Already-generated invoices are not affected.
+
+
+
+ setDeleteId(null)}>
+ Cancel
+
+ deleteId && del.mutate({ id: deleteId })}
+ disabled={del.isPending}
+ >
+ {del.isPending ? "Deleting…" : "Delete"}
+
+
+
+
+
+ );
+}
diff --git a/src/app/i/[token]/page.tsx b/src/app/i/[token]/page.tsx
new file mode 100644
index 0000000..4901c2c
--- /dev/null
+++ b/src/app/i/[token]/page.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import { useState } from "react";
+import { Download, Loader2 } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Separator } from "~/components/ui/separator";
+import { api } from "~/trpc/react";
+import { generateInvoicePDF } from "~/lib/pdf-export";
+import { toast } from "sonner";
+
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(new Date(date));
+}
+
+function formatCurrency(amount: number, currency = "USD") {
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
+}
+
+function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
+ const overdue = status === "sent" && new Date(dueDate) < new Date();
+ const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1);
+ const cls = overdue
+ ? "bg-red-50 text-red-700 border-red-200"
+ : status === "paid"
+ ? "bg-green-50 text-green-700 border-green-200"
+ : "bg-yellow-50 text-yellow-700 border-yellow-200";
+ return (
+
+ {label}
+
+ );
+}
+
+function PublicInvoiceView({ token }: { token: string }) {
+ const [downloading, setDownloading] = useState(false);
+
+ const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token });
+
+ const handleDownload = async () => {
+ if (!invoice || downloading) return;
+ setDownloading(true);
+ try {
+ await generateInvoicePDF({
+ invoiceNumber: invoice.invoiceNumber,
+ invoicePrefix: invoice.invoicePrefix,
+ issueDate: new Date(invoice.issueDate),
+ dueDate: new Date(invoice.dueDate),
+ status: invoice.status,
+ totalAmount: invoice.totalAmount,
+ taxRate: invoice.taxRate,
+ currency: invoice.currency ?? "USD",
+ notes: invoice.notes,
+ business: invoice.business,
+ client: invoice.client,
+ items: invoice.items,
+ });
+ } catch {
+ toast.error("Failed to generate PDF");
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error ?? !invoice) {
+ return (
+
+
Invoice not found
+
This link may have expired or been revoked.
+
+ );
+ }
+
+ const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
+ const taxAmount = (subtotal * invoice.taxRate) / 100;
+ const total = subtotal + taxAmount;
+ const senderName = invoice.business
+ ? invoice.business.nickname
+ ? `${invoice.business.name} (${invoice.business.nickname})`
+ : invoice.business.name
+ : null;
+
+ return (
+
+
+ {/* Card */}
+
+ {/* Header */}
+
+
{senderName ?? "Invoice"}
+ {invoice.business?.email && (
+
{invoice.business.email}
+ )}
+
+
+ {/* Body */}
+
+ {/* Invoice meta */}
+
+
+
{invoice.invoiceNumber}
+
+ Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}
+
+
+
+
+
+ {/* Bill to */}
+
+
Bill to
+
{invoice.client.name}
+ {invoice.client.email && (
+
{invoice.client.email}
+ )}
+
+
+
+
+ {/* Line items */}
+
+ {invoice.items.map((item) => (
+
+
+
{item.description}
+
+ {item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr
+
+
+
+ {formatCurrency(item.amount, invoice.currency ?? "USD")}
+
+
+ ))}
+
+
+
+
+ {/* Totals */}
+
+
+ Subtotal
+ {formatCurrency(subtotal, invoice.currency ?? "USD")}
+
+ {invoice.taxRate > 0 && (
+
+ Tax ({invoice.taxRate}%)
+ {formatCurrency(taxAmount, invoice.currency ?? "USD")}
+
+ )}
+
+ Total
+ {formatCurrency(total, invoice.currency ?? "USD")}
+
+
+
+ {/* Notes */}
+ {invoice.notes && (
+ <>
+
+
+
Notes
+
{invoice.notes}
+
+ >
+ )}
+
+ {/* PDF download */}
+
+ {downloading ? (
+ <> Generating PDF…>
+ ) : (
+ <> Download PDF>
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+
+ );
+}
+
+export default function PublicInvoicePage() {
+ const params = useParams();
+ const token = params.token as string;
+ return ;
+}
diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx
index dba3fe6..b76686b 100644
--- a/src/components/forms/invoice-form.tsx
+++ b/src/components/forms/invoice-form.tsx
@@ -805,6 +805,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues}
+ invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
+ defaultRate={formData.items[0]?.rate}
/>
diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx
index 703d35b..d6db28e 100644
--- a/src/components/forms/invoice-line-items.tsx
+++ b/src/components/forms/invoice-line-items.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Plus, Trash2, Zap } from "lucide-react";
+import { Plus, Timer, Trash2, Zap } from "lucide-react";
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
@@ -15,6 +15,7 @@ import {
useLineItemSuggestions,
type LineItemSuggestion,
} from "~/hooks/use-line-item-suggestions";
+import { TimeTracker } from "~/components/invoice/time-tracker";
interface InvoiceItem {
id: string;
@@ -35,6 +36,8 @@ interface InvoiceLineItemsProps {
value: string | number | Date,
) => void;
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
+ invoiceId?: string;
+ defaultRate?: number;
className?: string;
}
@@ -344,6 +347,8 @@ export function InvoiceLineItems({
onRemoveItem,
onUpdateItem,
onAddItemWithValues,
+ invoiceId,
+ defaultRate,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
@@ -417,6 +422,20 @@ export function InvoiceLineItems({
/>
))}
+ {onAddItemWithValues && invoiceId && (
+
+
+ Time tracker
+
+
{
+ onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 });
+ }}
+ />
+
+ )}
{onAddItemWithValues && (
)}
diff --git a/src/components/invoice/time-tracker.tsx b/src/components/invoice/time-tracker.tsx
new file mode 100644
index 0000000..e8ac71d
--- /dev/null
+++ b/src/components/invoice/time-tracker.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Play, Square } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+
+interface TimeTrackerProps {
+ invoiceId: string;
+ onStop: (hours: number, description: string) => void;
+ defaultRate?: number;
+}
+
+interface PersistedState {
+ running: boolean;
+ startedAt: number | null;
+ description: string;
+}
+
+function storageKey(invoiceId: string) {
+ return `time-tracker-${invoiceId}`;
+}
+
+function formatElapsed(seconds: number) {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = seconds % 60;
+ return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
+}
+
+function readPersistedState(invoiceId: string): PersistedState {
+ if (typeof window === "undefined") return { running: false, startedAt: null, description: "" };
+ try {
+ const raw = localStorage.getItem(storageKey(invoiceId));
+ if (raw) return JSON.parse(raw) as PersistedState;
+ } catch {
+ // ignore
+ }
+ return { running: false, startedAt: null, description: "" };
+}
+
+export function TimeTracker({ invoiceId, onStop }: TimeTrackerProps) {
+ const [running, setRunning] = useState(() => readPersistedState(invoiceId).running);
+ const [startedAt, setStartedAt] = useState(
+ () => readPersistedState(invoiceId).startedAt,
+ );
+ const [elapsed, setElapsed] = useState(() => {
+ const s = readPersistedState(invoiceId);
+ if (s.running && s.startedAt) {
+ return Math.max(0, Math.floor((Date.now() - s.startedAt) / 1000));
+ }
+ return 0;
+ });
+ const [description, setDescription] = useState(
+ () => readPersistedState(invoiceId).description,
+ );
+ const intervalRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (running && startedAt !== null) {
+ intervalRef.current = setInterval(() => {
+ setElapsed(Math.floor((Date.now() - startedAt) / 1000));
+ }, 1000);
+ }
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [running, startedAt]);
+
+ function persist(state: PersistedState) {
+ localStorage.setItem(storageKey(invoiceId), JSON.stringify(state));
+ }
+
+ function handleStart() {
+ const now = Date.now();
+ setStartedAt(now);
+ setElapsed(0);
+ setRunning(true);
+ persist({ running: true, startedAt: now, description });
+ }
+
+ function handleStop() {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ const hours = Math.max(0.25, Math.ceil(elapsed / 900) * 0.25);
+ setRunning(false);
+ setStartedAt(null);
+ setElapsed(0);
+ localStorage.removeItem(storageKey(invoiceId));
+ onStop(hours, description);
+ setDescription("");
+ }
+
+ return (
+
+ {running ? (
+ <>
+
+ {formatElapsed(elapsed)}
+
+
{
+ setDescription(e.target.value);
+ persist({ running: true, startedAt, description: e.target.value });
+ }}
+ placeholder="What are you working on?"
+ className="flex-1"
+ />
+
+
+ Stop & add
+
+ >
+ ) : (
+ <>
+
setDescription(e.target.value)}
+ placeholder="What will you work on?"
+ className="flex-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleStart();
+ }
+ }}
+ />
+
+
+ Start timer
+
+ >
+ )}
+
+ );
+}
diff --git a/src/env.js b/src/env.js
index 60b9ee5..3b8c072 100644
--- a/src/env.js
+++ b/src/env.js
@@ -20,6 +20,7 @@ export const env = createEnv({
.default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(),
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
+ CRON_SECRET: z.string().optional(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -85,6 +86,7 @@ export const env = createEnv({
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
+ CRON_SECRET: process.env.CRON_SECRET,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
diff --git a/src/lib/email-templates/reminder-email.ts b/src/lib/email-templates/reminder-email.ts
new file mode 100644
index 0000000..0a6a48a
--- /dev/null
+++ b/src/lib/email-templates/reminder-email.ts
@@ -0,0 +1,135 @@
+interface ReminderEmailTemplateProps {
+ invoice: {
+ invoiceNumber: string;
+ issueDate: Date;
+ dueDate: Date;
+ totalAmount: number;
+ currency?: string | null;
+ client: { name: string; email: string | null };
+ business?: {
+ name: string;
+ nickname?: string | null;
+ email?: string | null;
+ } | null;
+ };
+ customMessage?: string;
+ userName?: string;
+ userEmail?: string;
+}
+
+export function generateReminderEmailTemplate({
+ invoice,
+ customMessage,
+ userName,
+ userEmail,
+}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
+ const formatDate = (date: Date) =>
+ new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
+ new Date(date),
+ );
+
+ const formatCurrency = (amount: number) =>
+ new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: invoice.currency ?? "USD",
+ }).format(amount);
+
+ const senderName =
+ invoice.business?.name
+ ? invoice.business.nickname
+ ? `${invoice.business.name} (${invoice.business.nickname})`
+ : invoice.business.name
+ : userName ?? "Your service provider";
+
+ const isOverdue = new Date(invoice.dueDate) < new Date();
+
+ const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber} — ${formatCurrency(invoice.totalAmount)}`;
+
+ const defaultMessage = isOverdue
+ ? `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} was due on ${formatDate(invoice.dueDate)} and remains outstanding. Please arrange payment at your earliest convenience.`
+ : `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} is due on ${formatDate(invoice.dueDate)}. Please ensure payment is arranged before the due date.`;
+
+ const bodyMessage = customMessage ?? defaultMessage;
+
+ const html = `
+
+
+
+
+ Payment Reminder
+
+
+
+
+
+
+
+ ${senderName}
+ ${userEmail ? `${userEmail}
` : ""}
+
+
+
+
+ ${isOverdue ? "OVERDUE" : "PAYMENT DUE"}
+
+
+
+
+ Dear ${invoice.client.name},
+ ${bodyMessage}
+
+
+
+
+
+
+ Invoice number
+ ${invoice.invoiceNumber}
+
+
+ Issue date
+ ${formatDate(invoice.issueDate)}
+
+
+ Due date
+ ${formatDate(invoice.dueDate)}
+
+
+
+ Amount due
+ ${formatCurrency(invoice.totalAmount)}
+
+
+
+
+
+ If you have already made payment, please disregard this notice. Thank you for your business.
+
+
+
+ Sent by ${senderName} · Powered by beenvoice
+
+
+
+
+
+`;
+
+ const text = `Payment Reminder from ${senderName}
+
+Dear ${invoice.client.name},
+
+${bodyMessage}
+
+Invoice: ${invoice.invoiceNumber}
+Issue date: ${formatDate(invoice.issueDate)}
+Due date: ${formatDate(invoice.dueDate)}
+Amount due: ${formatCurrency(invoice.totalAmount)}
+
+If you have already made payment, please disregard this notice.
+Thank you for your business.
+
+— ${senderName}`;
+
+ return { html, text, subject };
+}
diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts
index df579be..17b6497 100644
--- a/src/lib/navigation.ts
+++ b/src/lib/navigation.ts
@@ -7,6 +7,7 @@ import {
Receipt,
BarChart2,
Shield,
+ RefreshCw,
} from "lucide-react";
export interface NavLink {
@@ -28,6 +29,7 @@ export const navigationConfig: NavSection[] = [
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
+ { name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
],
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index bcacbcc..0ea38ad 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -6,13 +6,10 @@ import { emailRouter } from "~/server/api/routers/email";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { expensesRouter } from "~/server/api/routers/expenses";
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
+import { paymentsRouter } from "~/server/api/routers/payments";
+import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
-/**
- * This is the primary router for your server.
- *
- * All routers added in /api/routers should be manually added here.
- */
export const appRouter = createTRPCRouter({
clients: clientsRouter,
businesses: businessesRouter,
@@ -22,6 +19,8 @@ export const appRouter = createTRPCRouter({
dashboard: dashboardRouter,
expenses: expensesRouter,
invoiceTemplates: invoiceTemplatesRouter,
+ payments: paymentsRouter,
+ recurringInvoices: recurringInvoicesRouter,
});
// export type definition of API
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index 0e9cb2f..19cd11a 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
import { desc, eq, inArray } from "drizzle-orm";
-import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
invoices,
invoiceItems,
@@ -10,6 +10,9 @@ import {
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
+import { Resend } from "resend";
+import { env } from "~/env";
+import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db";
type InvoiceRouterContext = {
@@ -626,4 +629,128 @@ export const invoicesRouter = createTRPCRouter({
});
}
}),
+
+ // ── Public token (shareable link) ──────────────────────────────────────────
+
+ generatePublicToken: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.id, input.id),
+ });
+ if (invoice?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ const token = crypto.randomUUID();
+ await ctx.db
+ .update(invoices)
+ .set({ publicToken: token })
+ .where(eq(invoices.id, input.id));
+ return { token };
+ }),
+
+ revokePublicToken: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.id, input.id),
+ });
+ if (invoice?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await ctx.db
+ .update(invoices)
+ .set({ publicToken: null })
+ .where(eq(invoices.id, input.id));
+ return { success: true };
+ }),
+
+ getByPublicToken: publicProcedure
+ .input(z.object({ token: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.publicToken, input.token),
+ with: { client: true, business: true, items: { orderBy: (i, { asc }) => [asc(i.position)] } },
+ });
+ if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
+ return invoice;
+ }),
+
+ // ── Send reminder ──────────────────────────────────────────────────────────
+
+ sendReminder: protectedProcedure
+ .input(z.object({ id: z.string(), customMessage: z.string().optional() }))
+ .mutation(async ({ ctx, input }) => {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.id, input.id),
+ with: { client: true, business: true },
+ });
+ if (invoice?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ if (!invoice.client?.email) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Client has no email address" });
+ }
+
+ const userName =
+ invoice.business?.emailFromName ?? invoice.business?.name ?? ctx.session.user.name ?? "";
+ const userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
+
+ const { html, text, subject } = generateReminderEmailTemplate({
+ invoice: {
+ invoiceNumber: invoice.invoiceNumber,
+ issueDate: invoice.issueDate,
+ dueDate: invoice.dueDate,
+ totalAmount: invoice.totalAmount,
+ currency: invoice.currency,
+ client: { name: invoice.client.name, email: invoice.client.email },
+ business: invoice.business,
+ },
+ customMessage: input.customMessage,
+ userName,
+ userEmail,
+ });
+
+ // Resolve Resend instance (same two-tier logic as email router)
+ let resendInstance: Resend;
+ let fromEmail: string;
+ if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
+ resendInstance = new Resend(invoice.business.resendApiKey);
+ const fromName = invoice.business.emailFromName ?? invoice.business.name;
+ fromEmail = `${fromName} `;
+ } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
+ resendInstance = new Resend(env.RESEND_API_KEY);
+ fromEmail = `noreply@${env.RESEND_DOMAIN}`;
+ } else if (env.RESEND_API_KEY) {
+ resendInstance = new Resend(env.RESEND_API_KEY);
+ fromEmail = invoice.business?.email ?? "noreply@example.com";
+ } else {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email delivery is not configured. Add a Resend API key.",
+ });
+ }
+
+ const result = await resendInstance.emails.send({
+ from: fromEmail,
+ to: [invoice.client.email],
+ subject,
+ html,
+ text,
+ });
+
+ if (result.error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: result.error.message,
+ });
+ }
+
+ await ctx.db
+ .update(invoices)
+ .set({ lastReminderSentAt: new Date() })
+ .where(eq(invoices.id, input.id));
+
+ return { sent: true };
+ }),
});
diff --git a/src/server/api/routers/payments.ts b/src/server/api/routers/payments.ts
new file mode 100644
index 0000000..54222c7
--- /dev/null
+++ b/src/server/api/routers/payments.ts
@@ -0,0 +1,103 @@
+import { z } from "zod";
+import { eq, sum } from "drizzle-orm";
+import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { invoicePayments, invoices } from "~/server/db/schema";
+import { TRPCError } from "@trpc/server";
+
+const PAYMENT_METHODS = ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"] as const;
+
+export const paymentsRouter = createTRPCRouter({
+ getByInvoice: protectedProcedure
+ .input(z.object({ invoiceId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.id, input.invoiceId),
+ });
+ if (invoice?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ return ctx.db.query.invoicePayments.findMany({
+ where: eq(invoicePayments.invoiceId, input.invoiceId),
+ orderBy: (p, { desc }) => [desc(p.date)],
+ });
+ }),
+
+ create: protectedProcedure
+ .input(
+ z.object({
+ invoiceId: z.string(),
+ amount: z.number().positive(),
+ date: z.date(),
+ method: z.enum(PAYMENT_METHODS).default("other"),
+ notes: z.string().max(500).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.id, input.invoiceId),
+ });
+ if (invoice?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ await ctx.db.insert(invoicePayments).values({
+ invoiceId: input.invoiceId,
+ amount: input.amount,
+ currency: invoice.currency,
+ date: input.date,
+ method: input.method,
+ notes: input.notes ?? null,
+ createdById: ctx.session.user.id,
+ });
+
+ // Auto-mark paid if total payments >= invoice total
+ const [totals] = await ctx.db
+ .select({ paid: sum(invoicePayments.amount) })
+ .from(invoicePayments)
+ .where(eq(invoicePayments.invoiceId, input.invoiceId));
+
+ const totalPaid = Number(totals?.paid ?? 0);
+ if (totalPaid >= invoice.totalAmount && invoice.status !== "paid") {
+ await ctx.db
+ .update(invoices)
+ .set({ status: "paid" })
+ .where(eq(invoices.id, input.invoiceId));
+ }
+
+ return { success: true };
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const payment = await ctx.db.query.invoicePayments.findFirst({
+ where: eq(invoicePayments.id, input.id),
+ });
+ if (!payment) throw new TRPCError({ code: "NOT_FOUND" });
+
+ const invoice = await ctx.db.query.invoices.findFirst({
+ where: eq(invoices.id, payment.invoiceId),
+ });
+ if (invoice?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "FORBIDDEN" });
+ }
+
+ await ctx.db.delete(invoicePayments).where(eq(invoicePayments.id, input.id));
+
+ // Re-check paid status after deletion
+ const [totals] = await ctx.db
+ .select({ paid: sum(invoicePayments.amount) })
+ .from(invoicePayments)
+ .where(eq(invoicePayments.invoiceId, payment.invoiceId));
+
+ const totalPaid = Number(totals?.paid ?? 0);
+ if (totalPaid < invoice.totalAmount && invoice.status === "paid") {
+ await ctx.db
+ .update(invoices)
+ .set({ status: "sent" })
+ .where(eq(invoices.id, payment.invoiceId));
+ }
+
+ return { success: true };
+ }),
+});
diff --git a/src/server/api/routers/recurring-invoices.ts b/src/server/api/routers/recurring-invoices.ts
new file mode 100644
index 0000000..c1229e8
--- /dev/null
+++ b/src/server/api/routers/recurring-invoices.ts
@@ -0,0 +1,297 @@
+import { z } from "zod";
+import { and, eq, lte } from "drizzle-orm";
+import { createTRPCRouter, protectedProcedure } from "../trpc";
+import {
+ recurringInvoices,
+ recurringInvoiceItems,
+ invoices,
+ invoiceItems,
+ clients,
+ businesses,
+} from "~/server/db/schema";
+import { TRPCError } from "@trpc/server";
+import type { db as DbType } from "~/server/db";
+
+export function nextDueDate(schedule: string, from = new Date()): Date {
+ const d = new Date(from);
+ switch (schedule) {
+ case "weekly": d.setDate(d.getDate() + 7); break;
+ case "biweekly": d.setDate(d.getDate() + 14); break;
+ case "monthly": d.setMonth(d.getMonth() + 1); break;
+ case "quarterly": d.setMonth(d.getMonth() + 3); break;
+ case "yearly": d.setFullYear(d.getFullYear() + 1); break;
+ }
+ return d;
+}
+
+type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
+ items: (typeof recurringInvoiceItems.$inferSelect)[];
+};
+
+export async function generateInvoiceFromRecurring(
+ db: typeof DbType,
+ recurring: RecurringWithItems,
+): Promise<{ id: string }> {
+ const now = new Date();
+ const invoiceNumber = `REC-${Date.now()}`;
+
+ const subtotal = recurring.items.reduce((s, i) => s + i.hours * i.rate, 0);
+ const taxAmount = (subtotal * recurring.taxRate) / 100;
+ const total = subtotal + taxAmount;
+
+ const [newInvoice] = await db
+ .insert(invoices)
+ .values({
+ invoiceNumber,
+ invoicePrefix: recurring.invoicePrefix ?? "#",
+ clientId: recurring.clientId,
+ businessId: recurring.businessId ?? null,
+ issueDate: now,
+ dueDate: nextDueDate("monthly", now),
+ status: "draft",
+ totalAmount: total,
+ taxRate: recurring.taxRate,
+ notes: recurring.notes ?? null,
+ emailMessage: recurring.emailMessage ?? null,
+ currency: recurring.currency,
+ createdById: recurring.createdById,
+ })
+ .returning({ id: invoices.id });
+
+ if (!newInvoice) throw new Error("Failed to create invoice");
+
+ if (recurring.items.length > 0) {
+ await db.insert(invoiceItems).values(
+ recurring.items.map((item, idx) => ({
+ invoiceId: newInvoice.id,
+ date: now,
+ description: item.description,
+ hours: item.hours,
+ rate: item.rate,
+ amount: item.hours * item.rate,
+ position: item.position ?? idx,
+ })),
+ );
+ }
+
+ return newInvoice;
+}
+
+export async function generateDueRecurringInvoices(db: typeof DbType): Promise {
+ const now = new Date();
+ const due = await db.query.recurringInvoices.findMany({
+ where: and(
+ eq(recurringInvoices.status, "active"),
+ lte(recurringInvoices.nextDueAt, now),
+ ),
+ with: { items: true },
+ });
+
+ let generated = 0;
+ for (const rec of due) {
+ try {
+ await generateInvoiceFromRecurring(db, rec);
+ await db
+ .update(recurringInvoices)
+ .set({ lastGeneratedAt: now, nextDueAt: nextDueDate(rec.schedule, now) })
+ .where(eq(recurringInvoices.id, rec.id));
+ generated++;
+ } catch {
+ // continue on individual failures
+ }
+ }
+ return generated;
+}
+
+const scheduleEnum = z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]);
+
+const recurringItemSchema = z.object({
+ description: z.string().min(1),
+ hours: z.number().min(0),
+ rate: z.number().min(0),
+ position: z.number().int().default(0),
+});
+
+const recurringInvoiceSchema = z.object({
+ name: z.string().min(1).max(255),
+ clientId: z.string().min(1),
+ businessId: z.string().optional().or(z.literal("")),
+ schedule: scheduleEnum,
+ invoicePrefix: z.string().optional().default("#"),
+ taxRate: z.number().min(0).max(100).default(0),
+ currency: z.string().length(3).default("USD"),
+ notes: z.string().optional().or(z.literal("")),
+ emailMessage: z.string().optional().or(z.literal("")),
+ items: z.array(recurringItemSchema).min(1),
+});
+
+export const recurringInvoicesRouter = createTRPCRouter({
+ getAll: protectedProcedure.query(async ({ ctx }) => {
+ return ctx.db.query.recurringInvoices.findMany({
+ where: eq(recurringInvoices.createdById, ctx.session.user.id),
+ with: { client: true, business: true, items: true },
+ orderBy: (r, { asc }) => [asc(r.nextDueAt)],
+ });
+ }),
+
+ create: protectedProcedure
+ .input(recurringInvoiceSchema)
+ .mutation(async ({ ctx, input }) => {
+ const client = await ctx.db.query.clients.findFirst({
+ where: eq(clients.id, input.clientId),
+ });
+ if (client?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Client not found" });
+ }
+ if (input.businessId) {
+ const biz = await ctx.db.query.businesses.findFirst({
+ where: eq(businesses.id, input.businessId),
+ });
+ if (biz?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Business not found" });
+ }
+ }
+
+ const [rec] = await ctx.db
+ .insert(recurringInvoices)
+ .values({
+ name: input.name,
+ clientId: input.clientId,
+ businessId: input.businessId ?? null,
+ schedule: input.schedule,
+ status: "active",
+ invoicePrefix: input.invoicePrefix,
+ taxRate: input.taxRate,
+ currency: input.currency,
+ notes: input.notes ?? null,
+ emailMessage: input.emailMessage ?? null,
+ nextDueAt: nextDueDate(input.schedule),
+ createdById: ctx.session.user.id,
+ })
+ .returning({ id: recurringInvoices.id });
+
+ if (!rec) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+
+ await ctx.db.insert(recurringInvoiceItems).values(
+ input.items.map((item, idx) => ({
+ recurringInvoiceId: rec.id,
+ description: item.description,
+ hours: item.hours,
+ rate: item.rate,
+ position: item.position ?? idx,
+ })),
+ );
+
+ return rec;
+ }),
+
+ update: protectedProcedure
+ .input(recurringInvoiceSchema.extend({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const existing = await ctx.db.query.recurringInvoices.findFirst({
+ where: eq(recurringInvoices.id, input.id),
+ });
+ if (existing?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ await ctx.db
+ .update(recurringInvoices)
+ .set({
+ name: input.name,
+ clientId: input.clientId,
+ businessId: input.businessId ?? null,
+ schedule: input.schedule,
+ invoicePrefix: input.invoicePrefix,
+ taxRate: input.taxRate,
+ currency: input.currency,
+ notes: input.notes ?? null,
+ emailMessage: input.emailMessage ?? null,
+ })
+ .where(eq(recurringInvoices.id, input.id));
+
+ await ctx.db
+ .delete(recurringInvoiceItems)
+ .where(eq(recurringInvoiceItems.recurringInvoiceId, input.id));
+
+ await ctx.db.insert(recurringInvoiceItems).values(
+ input.items.map((item, idx) => ({
+ recurringInvoiceId: input.id,
+ description: item.description,
+ hours: item.hours,
+ rate: item.rate,
+ position: item.position ?? idx,
+ })),
+ );
+
+ return { success: true };
+ }),
+
+ pause: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const rec = await ctx.db.query.recurringInvoices.findFirst({
+ where: eq(recurringInvoices.id, input.id),
+ });
+ if (rec?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await ctx.db
+ .update(recurringInvoices)
+ .set({ status: "paused" })
+ .where(eq(recurringInvoices.id, input.id));
+ return { success: true };
+ }),
+
+ resume: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const rec = await ctx.db.query.recurringInvoices.findFirst({
+ where: eq(recurringInvoices.id, input.id),
+ });
+ if (rec?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await ctx.db
+ .update(recurringInvoices)
+ .set({ status: "active" })
+ .where(eq(recurringInvoices.id, input.id));
+ return { success: true };
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const rec = await ctx.db.query.recurringInvoices.findFirst({
+ where: eq(recurringInvoices.id, input.id),
+ });
+ if (rec?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await ctx.db
+ .delete(recurringInvoices)
+ .where(eq(recurringInvoices.id, input.id));
+ return { success: true };
+ }),
+
+ generateNow: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const rec = await ctx.db.query.recurringInvoices.findFirst({
+ where: eq(recurringInvoices.id, input.id),
+ with: { items: true },
+ });
+ if (rec?.createdById !== ctx.session.user.id) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec);
+
+ await ctx.db
+ .update(recurringInvoices)
+ .set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) })
+ .where(eq(recurringInvoices.id, input.id));
+
+ return { invoiceId: newInvoice.id };
+ }),
+});
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index c98369f..e96b96c 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -87,6 +87,7 @@ export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
expenses: many(expenses),
invoiceTemplates: many(invoiceTemplates),
+ recurringInvoices: many(recurringInvoices),
}));
export const accounts = createTable(
@@ -326,6 +327,8 @@ export const invoices = createTable(
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
+ publicToken: d.varchar({ length: 255 }).unique(),
+ lastReminderSentAt: d.timestamp(),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
@@ -338,6 +341,7 @@ export const invoices = createTable(
index("invoice_created_by_idx").on(t.createdById),
index("invoice_number_idx").on(t.invoiceNumber),
index("invoice_status_idx").on(t.status),
+ index("invoice_public_token_idx").on(t.publicToken),
],
);
@@ -355,6 +359,7 @@ export const invoicesRelations = relations(invoices, ({ one, many }) => ({
references: [users.id],
}),
items: many(invoiceItems),
+ payments: many(invoicePayments),
}));
export const invoiceItems = createTable(
@@ -491,3 +496,149 @@ export const invoiceTemplatesRelations = relations(
}),
}),
);
+
+// ─── Invoice Payments ────────────────────────────────────────────────────────
+
+export const invoicePayments = createTable(
+ "invoice_payment",
+ (d) => ({
+ id: d
+ .varchar({ length: 255 })
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ invoiceId: d
+ .varchar({ length: 255 })
+ .notNull()
+ .references(() => invoices.id, { onDelete: "cascade" }),
+ amount: d.real().notNull(),
+ currency: d.varchar({ length: 3 }).default("USD").notNull(),
+ date: d.timestamp().notNull(),
+ method: d
+ .varchar({ length: 50 })
+ .notNull()
+ .default("other"), // cash | check | bank_transfer | credit_card | paypal | other
+ notes: d.varchar({ length: 500 }),
+ createdById: d
+ .varchar({ length: 255 })
+ .notNull()
+ .references(() => users.id),
+ createdAt: d
+ .timestamp()
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ }),
+ (t) => [
+ index("invoice_payment_invoice_id_idx").on(t.invoiceId),
+ index("invoice_payment_created_by_idx").on(t.createdById),
+ ],
+);
+
+export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) => ({
+ invoice: one(invoices, {
+ fields: [invoicePayments.invoiceId],
+ references: [invoices.id],
+ }),
+ createdBy: one(users, {
+ fields: [invoicePayments.createdById],
+ references: [users.id],
+ }),
+}));
+
+// ─── Recurring Invoices ───────────────────────────────────────────────────────
+
+export const recurringInvoices = createTable(
+ "recurring_invoice",
+ (d) => ({
+ id: d
+ .varchar({ length: 255 })
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ name: d.varchar({ length: 255 }).notNull(),
+ clientId: d
+ .varchar({ length: 255 })
+ .notNull()
+ .references(() => clients.id),
+ businessId: d.varchar({ length: 255 }).references(() => businesses.id),
+ schedule: d.varchar({ length: 20 }).notNull().default("monthly"), // weekly | biweekly | monthly | quarterly | yearly
+ status: d.varchar({ length: 20 }).notNull().default("active"), // active | paused
+ invoicePrefix: d.varchar({ length: 20 }).default("#"),
+ taxRate: d.real().notNull().default(0),
+ currency: d.varchar({ length: 3 }).default("USD").notNull(),
+ notes: d.varchar({ length: 1000 }),
+ emailMessage: d.varchar({ length: 2000 }),
+ nextDueAt: d.timestamp().notNull(),
+ lastGeneratedAt: d.timestamp(),
+ createdById: d
+ .varchar({ length: 255 })
+ .notNull()
+ .references(() => users.id),
+ createdAt: d
+ .timestamp()
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ }),
+ (t) => [
+ index("recurring_invoice_created_by_idx").on(t.createdById),
+ index("recurring_invoice_client_id_idx").on(t.clientId),
+ index("recurring_invoice_status_idx").on(t.status),
+ index("recurring_invoice_next_due_idx").on(t.nextDueAt),
+ ],
+);
+
+export const recurringInvoicesRelations = relations(
+ recurringInvoices,
+ ({ one, many }) => ({
+ client: one(clients, {
+ fields: [recurringInvoices.clientId],
+ references: [clients.id],
+ }),
+ business: one(businesses, {
+ fields: [recurringInvoices.businessId],
+ references: [businesses.id],
+ }),
+ createdBy: one(users, {
+ fields: [recurringInvoices.createdById],
+ references: [users.id],
+ }),
+ items: many(recurringInvoiceItems),
+ }),
+);
+
+export const recurringInvoiceItems = createTable(
+ "recurring_invoice_item",
+ (d) => ({
+ id: d
+ .varchar({ length: 255 })
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ recurringInvoiceId: d
+ .varchar({ length: 255 })
+ .notNull()
+ .references(() => recurringInvoices.id, { onDelete: "cascade" }),
+ description: d.varchar({ length: 500 }).notNull(),
+ hours: d.real().notNull(),
+ rate: d.real().notNull(),
+ position: d.integer().notNull().default(0),
+ createdAt: d
+ .timestamp()
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ }),
+ (t) => [
+ index("recurring_invoice_item_recurring_id_idx").on(t.recurringInvoiceId),
+ ],
+);
+
+export const recurringInvoiceItemsRelations = relations(
+ recurringInvoiceItems,
+ ({ one }) => ({
+ recurringInvoice: one(recurringInvoices, {
+ fields: [recurringInvoiceItems.recurringInvoiceId],
+ references: [recurringInvoices.id],
+ }),
+ }),
+);