import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { getDb, invoices, invoiceItems, clients, cases, timeEntries, firms, } from '@lawdesk/db'; import { loadFirm } from '../lib/firm'; import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits'; import { nextInvoiceNumber } from '../lib/invoice-numbering'; import { renderInvoicePdf } from '../lib/invoice-pdf'; import { sendEmail, invoiceEmail, invoicePaidEmail } from '../lib/email'; const STATUSES = ['draft', 'sent', 'paid', 'overdue', 'void'] as const; const itemBody = z.object({ description: z.string().min(1).max(500), quantity: z.coerce.number().positive().default(1), rate: z.coerce.number().nonnegative(), }); const createBody = z.object({ clientId: z.string().uuid(), caseId: z.string().uuid().nullable().optional(), notes: z.string().max(5000).nullable().optional(), taxRate: z.coerce.number().min(0).max(100).default(0), dueAt: z.string().datetime().nullable().optional(), // Either provide explicit items or supply timeEntryIds to generate items from time entries. items: z.array(itemBody).max(200).optional(), timeEntryIds: z.array(z.string().uuid()).max(200).optional(), }); const updateBody = z.object({ notes: z.string().max(5000).nullable().optional(), taxRate: z.coerce.number().min(0).max(100).optional(), dueAt: z.string().datetime().nullable().optional(), }); const listQuery = z.object({ status: z.enum(STATUSES).optional(), clientId: z.string().uuid().optional(), caseId: z.string().uuid().optional(), limit: z.coerce.number().int().positive().max(200).default(50), offset: z.coerce.number().int().min(0).default(0), }); interface ItemAccumulator { description: string; quantity: string; rate: string; amount: string; sortOrder: number; timeEntryId?: string; } function round2(n: number): number { return Math.round(n * 100) / 100; } function computeTotals(items: { quantity: string; rate: string; amount: string }[], taxRate: number) { const subtotal = items.reduce((acc, it) => acc + Number(it.amount), 0); const total = round2(subtotal * (1 + taxRate / 100)); return { subtotal: round2(subtotal), total }; } export async function invoicesRoutes(app: FastifyInstance) { app.addHook('preHandler', app.requireFirm); // List app.get('/api/invoices', async (req) => { const firmId = req.user!.firmId!; const { status, clientId, caseId, limit, offset } = listQuery.parse(req.query); const db = getDb(); const filters = [eq(invoices.firmId, firmId)]; if (status) filters.push(eq(invoices.status, status)); if (clientId) filters.push(eq(invoices.clientId, clientId)); if (caseId) filters.push(eq(invoices.caseId, caseId)); const where = and(...filters); const rows = await db .select({ id: invoices.id, number: invoices.number, status: invoices.status, total: invoices.total, subtotal: invoices.subtotal, issuedAt: invoices.issuedAt, dueAt: invoices.dueAt, paidAt: invoices.paidAt, createdAt: invoices.createdAt, clientId: invoices.clientId, clientName: clients.name, caseId: invoices.caseId, caseTitle: cases.title, }) .from(invoices) .innerJoin(clients, eq(clients.id, invoices.clientId)) .leftJoin(cases, eq(cases.id, invoices.caseId)) .where(where) .orderBy(desc(invoices.createdAt)) .limit(limit) .offset(offset); const [count] = await db .select({ total: sql`count(*)::int` }) .from(invoices) .where(where); return { items: rows, total: count?.total ?? 0 }; }); // Summary — outstanding total across sent + overdue invoices app.get('/api/invoices/summary', async (req) => { const firmId = req.user!.firmId!; const [row] = await getDb() .select({ outstanding: sql`COALESCE(SUM(${invoices.total}), 0)` }) .from(invoices) .where(and(eq(invoices.firmId, firmId), inArray(invoices.status, ['sent', 'overdue']))); return { outstanding: row?.outstanding ?? '0' }; }); // Get with items app.get('/api/invoices/:id', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); const [inv] = await db .select({ id: invoices.id, number: invoices.number, status: invoices.status, subtotal: invoices.subtotal, taxRate: invoices.taxRate, total: invoices.total, notes: invoices.notes, issuedAt: invoices.issuedAt, dueAt: invoices.dueAt, paidAt: invoices.paidAt, createdAt: invoices.createdAt, clientId: invoices.clientId, clientName: clients.name, clientEmail: clients.email, caseId: invoices.caseId, caseTitle: cases.title, }) .from(invoices) .innerJoin(clients, eq(clients.id, invoices.clientId)) .leftJoin(cases, eq(cases.id, invoices.caseId)) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!inv) return reply.code(404).send({ error: 'not_found' }); const items = await db .select() .from(invoiceItems) .where(eq(invoiceItems.invoiceId, inv.id)) .orderBy(asc(invoiceItems.sortOrder)); return { ...inv, items }; }); // Create app.post('/api/invoices', async (req, reply) => { const firmId = req.user!.firmId!; const firm = await loadFirm(firmId); if (!firm) return reply.code(403).send({ error: 'firm_missing' }); try { await assertCanCreateInvoice(firmId, firm.plan); } catch (e) { if (e instanceof PlanLimitError) return reply.code(402).send({ error: e.message, plan: firm.plan }); throw e; } const body = createBody.parse(req.body); const db = getDb(); // Validate client belongs to firm const [client] = await db .select({ id: clients.id }) .from(clients) .where(and(eq(clients.id, body.clientId), eq(clients.firmId, firmId))) .limit(1); if (!client) return reply.code(400).send({ error: 'invalid_client' }); // Validate case belongs to firm (and to client) if provided if (body.caseId) { const [c] = await db .select({ id: cases.id }) .from(cases) .where(and(eq(cases.id, body.caseId), eq(cases.firmId, firmId), eq(cases.clientId, body.clientId))) .limit(1); if (!c) return reply.code(400).send({ error: 'invalid_case' }); } // Build line items const accumulated: ItemAccumulator[] = []; if (body.items && body.items.length) { body.items.forEach((it, i) => { accumulated.push({ description: it.description, quantity: String(it.quantity), rate: String(it.rate), amount: String(round2(it.quantity * it.rate)), sortOrder: i, }); }); } if (body.timeEntryIds && body.timeEntryIds.length) { const entries = await db .select({ id: timeEntries.id, description: timeEntries.description, minutes: timeEntries.minutes, rate: timeEntries.rate, billable: timeEntries.billable, invoiceItemId: timeEntries.invoiceItemId, caseId: timeEntries.caseId, }) .from(timeEntries) .where(and(eq(timeEntries.firmId, firmId), inArray(timeEntries.id, body.timeEntryIds))); if (entries.length !== body.timeEntryIds.length) { return reply.code(400).send({ error: 'invalid_time_entries' }); } for (const e of entries) { if (e.invoiceItemId) return reply.code(409).send({ error: 'time_entry_already_invoiced' }); if (!e.billable) return reply.code(400).send({ error: 'time_entry_not_billable' }); if (body.caseId && e.caseId !== body.caseId) { return reply.code(400).send({ error: 'time_entry_case_mismatch' }); } } const startSort = accumulated.length; entries.forEach((e, i) => { const hours = round2(e.minutes / 60); const rate = Number(e.rate); accumulated.push({ description: e.description, quantity: String(hours), rate: String(rate), amount: String(round2(hours * rate)), sortOrder: startSort + i, timeEntryId: e.id, }); }); } if (!accumulated.length) { return reply.code(400).send({ error: 'no_items' }); } const taxRate = body.taxRate; const totals = computeTotals(accumulated, taxRate); const created = await db.transaction(async (tx) => { // Generate the number inside the transaction: the advisory lock it takes must be held // until this insert commits, so concurrent creates serialise and never collide. const number = await nextInvoiceNumber(tx, firmId); const [inv] = await tx .insert(invoices) .values({ firmId, clientId: body.clientId, caseId: body.caseId ?? null, number, status: 'draft', subtotal: String(totals.subtotal), taxRate: String(taxRate), total: String(totals.total), notes: body.notes ?? null, dueAt: body.dueAt ? new Date(body.dueAt) : null, }) .returning(); if (!inv) throw new Error('invoice_insert_failed'); const insertedItems = await tx .insert(invoiceItems) .values( accumulated.map((a) => ({ invoiceId: inv.id, description: a.description, quantity: a.quantity, rate: a.rate, amount: a.amount, sortOrder: a.sortOrder, })), ) .returning(); // Link the time entries (when generated from time) to their new invoice items const updates: Array> = []; accumulated.forEach((a, i) => { if (!a.timeEntryId) return; const item = insertedItems[i]; if (!item) return; updates.push( tx .update(timeEntries) .set({ invoiceItemId: item.id, updatedAt: new Date() }) .where(eq(timeEntries.id, a.timeEntryId)), ); }); await Promise.all(updates); return inv; }); return reply.code(201).send(created); }); // Update (notes, dueAt, taxRate; only on drafts) app.patch('/api/invoices/:id', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const body = updateBody.parse(req.body); const db = getDb(); const [existing] = await db .select() .from(invoices) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!existing) return reply.code(404).send({ error: 'not_found' }); if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' }); const patch: Record = { updatedAt: new Date() }; if (body.notes !== undefined) patch.notes = body.notes; if (body.dueAt !== undefined) patch.dueAt = body.dueAt ? new Date(body.dueAt) : null; if (body.taxRate !== undefined) { patch.taxRate = String(body.taxRate); const items = await db.select().from(invoiceItems).where(eq(invoiceItems.invoiceId, id)); const totals = computeTotals(items, body.taxRate); patch.subtotal = String(totals.subtotal); patch.total = String(totals.total); } const [row] = await db.update(invoices).set(patch).where(eq(invoices.id, id)).returning(); return row; }); // Send (draft → sent, set issuedAt). Emails the client with the PDF attached if we have // their email on file. Email failure does not block the status change. app.post('/api/invoices/:id/send', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); const [existing] = await db .select() .from(invoices) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!existing) return reply.code(404).send({ error: 'not_found' }); if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' }); const now = new Date(); const [row] = await db .update(invoices) .set({ status: 'sent', issuedAt: now, updatedAt: now }) .where(eq(invoices.id, id)) .returning(); if (!row) return reply.code(500).send({ error: 'update_failed' }); // Render PDF + email the client (best-effort). try { const [client] = await db.select().from(clients).where(eq(clients.id, row.clientId)).limit(1); const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1); if (!client?.email || !firm) { app.log.info({ invoiceId: id }, 'invoice sent, skipped email (no client email or firm missing)'); return row; } const items = await db .select() .from(invoiceItems) .where(eq(invoiceItems.invoiceId, id)) .orderBy(asc(invoiceItems.sortOrder)); const pdfStream = renderInvoicePdf({ number: row.number, status: row.status, issuedAt: row.issuedAt, dueAt: row.dueAt, notes: row.notes, subtotal: row.subtotal, taxRate: row.taxRate, total: row.total, firm: { name: firm.name }, client: { name: client.name, email: client.email, address: client.address }, items: items.map((it) => ({ description: it.description, quantity: it.quantity, rate: it.rate, amount: it.amount, })), watermark: firm.watermarkEnabled, }); // Collect the PDF stream into a buffer. const chunks: Buffer[] = []; for await (const chunk of pdfStream as AsyncIterable) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } const pdfBuffer = Buffer.concat(chunks); const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format( Number(row.total), ); const dueDate = row.dueAt ? row.dueAt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : null; const tpl = invoiceEmail({ clientName: client.name, firmName: firm.name, invoiceNumber: row.number, total: totalFmt, dueDate, notes: row.notes, }); sendEmail({ to: client.email, ...tpl, attachments: [{ filename: `${row.number}.pdf`, content: pdfBuffer }], }).catch((err) => app.log.warn({ err, invoiceId: id }, 'invoice email failed')); } catch (err) { app.log.warn({ err, invoiceId: id }, 'failed to render/send invoice email'); } return row; }); // Mark paid app.post('/api/invoices/:id/mark-paid', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); const [existing] = await db .select() .from(invoices) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!existing) return reply.code(404).send({ error: 'not_found' }); if (!['sent', 'overdue'].includes(existing.status)) { return reply.code(409).send({ error: 'invoice_not_sent' }); } const now = new Date(); const [row] = await db .update(invoices) .set({ status: 'paid', paidAt: now, updatedAt: now }) .where(eq(invoices.id, id)) .returning(); // Payment receipt to the client (best-effort). if (row) { try { const [client] = await db.select().from(clients).where(eq(clients.id, row.clientId)).limit(1); const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1); if (client?.email && firm) { const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(Number(row.total)); const tpl = invoicePaidEmail({ clientName: client.name, firmName: firm.name, invoiceNumber: row.number, total: totalFmt, }); sendEmail({ to: client.email, ...tpl }).catch((err) => app.log.warn({ err, invoiceId: id }, 'invoice paid email failed'), ); } } catch (err) { app.log.warn({ err, invoiceId: id }, 'failed to send invoice paid email'); } } return row; }); // Void app.post('/api/invoices/:id/void', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); const [existing] = await db .select() .from(invoices) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!existing) return reply.code(404).send({ error: 'not_found' }); if (existing.status === 'paid') return reply.code(409).send({ error: 'invoice_already_paid' }); const [row] = await db .update(invoices) .set({ status: 'void', updatedAt: new Date() }) .where(eq(invoices.id, id)) .returning(); return row; }); // Delete (drafts only) — also unlinks time entries app.delete('/api/invoices/:id', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); const [existing] = await db .select() .from(invoices) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!existing) return reply.code(404).send({ error: 'not_found' }); if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' }); await db.transaction(async (tx) => { const items = await tx .select({ id: invoiceItems.id }) .from(invoiceItems) .where(eq(invoiceItems.invoiceId, id)); const itemIds = items.map((i) => i.id); if (itemIds.length) { await tx .update(timeEntries) .set({ invoiceItemId: null, updatedAt: new Date() }) .where(inArray(timeEntries.invoiceItemId, itemIds)); } await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id)); await tx.delete(invoices).where(eq(invoices.id, id)); }); return { ok: true }; }); // PDF download app.get( '/api/invoices/:id/pdf', { config: { rateLimit: { max: 60, timeWindow: '1 hour' } } }, async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const db = getDb(); const [inv] = await db .select({ invoice: invoices, client: clients, firm: firms, }) .from(invoices) .innerJoin(clients, eq(clients.id, invoices.clientId)) .innerJoin(firms, eq(firms.id, invoices.firmId)) .where(and(eq(invoices.id, id), eq(invoices.firmId, firmId))) .limit(1); if (!inv) return reply.code(404).send({ error: 'not_found' }); const items = await db .select() .from(invoiceItems) .where(eq(invoiceItems.invoiceId, id)) .orderBy(asc(invoiceItems.sortOrder)); const stream = renderInvoicePdf({ number: inv.invoice.number, status: inv.invoice.status, issuedAt: inv.invoice.issuedAt, dueAt: inv.invoice.dueAt, notes: inv.invoice.notes, subtotal: inv.invoice.subtotal, taxRate: inv.invoice.taxRate, total: inv.invoice.total, firm: { name: inv.firm.name }, client: { name: inv.client.name, email: inv.client.email, address: inv.client.address }, items: items.map((it) => ({ description: it.description, quantity: it.quantity, rate: it.rate, amount: it.amount, })), watermark: inv.firm.watermarkEnabled, }); reply .header('Content-Type', 'application/pdf') .header('Content-Disposition', `inline; filename="${inv.invoice.number}.pdf"`); return reply.send(stream); }); }