Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
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 } 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).optional(),
|
||||
timeEntryIds: z.array(z.string().uuid()).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<number>`count(*)::int` })
|
||||
.from(invoices)
|
||||
.where(where);
|
||||
|
||||
return { items: rows, total: count?.total ?? 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 number = await nextInvoiceNumber(firmId);
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
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<Promise<unknown>> = [];
|
||||
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<string, unknown> = { 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<Buffer | string>) {
|
||||
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();
|
||||
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', 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user