From 7ed8049aa2fefae0709ae09506c93b392a651925 Mon Sep 17 00:00:00 2001 From: Graeme Date: Thu, 2 Apr 2026 18:56:58 +0200 Subject: [PATCH] Add per-user invoice numbers (schema, API, UI, PDF) and sequence tracking --- app/api/invoices/route.ts | 16 +++++++++--- app/invoices/page.tsx | 16 +++++++++--- .../migration.sql | 26 +++++++++++++++++++ prisma/schema.prisma | 15 +++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260402160748_add_invoice_number_and_sequence/migration.sql diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index ed79f69..4b9fed6 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -126,9 +126,19 @@ export async function POST(req: Request) { status: 'DRAFT', }; - const invoice = await prisma.invoice.create({ - data: invoiceData, - include: { client: true }, + const invoice = await prisma.$transaction(async (tx) => { + const seq = await tx.invoiceSequence.upsert({ + where: { userId: user.id }, + update: { lastNumber: { increment: 1 } }, + create: { userId: user.id, lastNumber: 1 }, + }); + + const invoiceNumber = `INV-${String(seq.lastNumber).padStart(3, '0')}`; + + return tx.invoice.create({ + data: { ...invoiceData, invoiceNumber }, + include: { client: true }, + }); }); return Response.json({ invoice }); diff --git a/app/invoices/page.tsx b/app/invoices/page.tsx index 46cdc5d..9b60074 100644 --- a/app/invoices/page.tsx +++ b/app/invoices/page.tsx @@ -14,6 +14,7 @@ interface Client { interface Invoice { id: string; + invoiceNumber?: string | null; totalHours: number; totalAmount: number; createdAt: string; @@ -42,6 +43,7 @@ interface InvoiceDetailsResponse { const buildInvoicePdf = (invoice: Invoice, entries: InvoiceEntry[]) => { const doc = new jsPDF(); const createdAt = new Date(invoice.createdAt).toLocaleDateString(); + const invoiceNumber = invoice.invoiceNumber || invoice.id; const periodStart = invoice.periodStart ? new Date(invoice.periodStart).toLocaleDateString() : 'N/A'; @@ -49,10 +51,12 @@ const buildInvoicePdf = (invoice: Invoice, entries: InvoiceEntry[]) => { const dueDate = invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'; doc.setFontSize(18); - doc.text('Worklog Invoice', 14, 20); + doc.text('Worklog Invoice', 14, 16); + doc.setFontSize(14); + doc.text(invoiceNumber, 14, 26); doc.setFontSize(12); - doc.text(`Client: ${invoice.client.name}`, 14, 32); + doc.text(`Client: ${invoice.client.name}`, 14, 36); doc.text(`Period: ${periodStart} - ${periodEnd}`, 14, 40); doc.text(`Due: ${dueDate}`, 14, 48); doc.text(`Created: ${createdAt}`, 14, 56); @@ -164,7 +168,8 @@ export default function InvoicesPage() { const details = await fetchInvoiceDetails(invoiceId); if (!details) return; const doc = buildInvoicePdf(details.invoice, details.entries); - doc.save(`invoice-${details.invoice.id}.pdf`); + const fileName = `invoice-${details.invoice.invoiceNumber || details.invoice.id}.pdf`; + doc.save(fileName); }; const handleView = async (invoiceId: string) => { @@ -310,7 +315,10 @@ export default function InvoicesPage() { {invoicesData?.invoices?.length > 0 ? ( invoicesData?.invoices?.map((invoice: Invoice) => ( - {invoice.client.name} + +
{invoice.invoiceNumber || invoice.id}
+
{invoice.client.name}
+ {new Date(invoice.createdAt).toLocaleDateString()} diff --git a/prisma/migrations/20260402160748_add_invoice_number_and_sequence/migration.sql b/prisma/migrations/20260402160748_add_invoice_number_and_sequence/migration.sql new file mode 100644 index 0000000..327cd57 --- /dev/null +++ b/prisma/migrations/20260402160748_add_invoice_number_and_sequence/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,invoiceNumber]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "invoiceNumber" TEXT; + +-- CreateTable +CREATE TABLE "InvoiceSequence" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "lastNumber" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "InvoiceSequence_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InvoiceSequence_userId_key" ON "InvoiceSequence"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice_userId_invoiceNumber_key" ON "Invoice"("userId", "invoiceNumber"); + +-- AddForeignKey +ALTER TABLE "InvoiceSequence" ADD CONSTRAINT "InvoiceSequence_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56c152b..247b6ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model User { clients Client[] timeEntries TimeEntry[] invoices Invoice[] + invoiceSequence InvoiceSequence? } model Client { @@ -52,6 +53,8 @@ model TimeEntry { model Invoice { id String @id @default(cuid()) + // Human-readable invoice number, e.g. INV-001 (scoped per user) + invoiceNumber String? totalHours Float totalAmount Float // Hourly rate used when the invoice was generated (persisted for historical accuracy) @@ -70,6 +73,18 @@ model Invoice { isPaid Boolean @default(false) // Optional due date for the invoice; if not provided, default applied in server logic dueDate DateTime? + + @@unique([userId, invoiceNumber]) +} + +// Tracks the last invoice sequence number for each user so invoice numbers +// can auto-increment per user in a concurrency-safe manner. +model InvoiceSequence { + id String @id @default(cuid()) + userId String @unique + lastNumber Int @default(0) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum InvoiceStatus {