Files
elegalsoftware/apps/api/src/routes/invoices.ts
T
Leon SerfatyandClaude Fable 5 304f7f30c3 Security hardening: deps, tenancy quotas, auth, deploy, webhooks
Addresses the findings from the platform security audit. Verified green:
all-workspace typecheck, web build, 16 API unit tests, 23 e2e auth tests,
and 0 high/critical production dependency vulnerabilities.

Dependencies (High):
- Bump drizzle-orm 0.36→0.45.2 (GHSA-gpj5-g38j-94v9 SQLi-via-identifier)
  and drizzle-kit→0.31.10; npm audit fix cleared fast-uri path-traversal
  and the react-router open-redirect. Remaining audit items are dev-only
  build tooling (esbuild/vite), not shipped at runtime.

AI cost control + storage quota (new ai_usage table, migration 0002):
- Per-firm monthly AI token budget enforced before each completion (429),
  with every completion recorded to an ai_usage ledger (lib/ai-usage.ts).
- Enforce per-plan storage quota on upload (402) and maintain
  storage_bytes_used on upload/delete (lib/storage-quota.ts); widen the
  column int→bigint so 8GB/50GB plans don't overflow.

Auth (defense-in-depth):
- Constant-time login: verify against a dummy argon2 hash when the account
  doesn't exist, closing the timing/enumeration oracle (verifyPasswordSafe).
- Enforce suspension on requireSuperadmin, /auth/me, /auth/resend-verification.

Web:
- Validate the post-login ?next= redirect to same-origin paths only
  (open-redirect / phishing).

Deploy hardening:
- docker-compose: memory/CPU limits so a spike can't OOM the Dokploy host.
- .dockerignore: keep destructive one-off scripts (seed-demo, create-admin,
  migrate-storage) out of the runtime image; retain the cron scripts.
- seed-demo.ts: hard-refuse NODE_ENV=production and the prod DB host.

Webhooks / config:
- Stripe idempotency via a stripe_events ledger (skip already-processed
  events; record only after successful processing so a transient failure
  still retries); make the plan-upgraded email non-blocking.
- Rate-limit account export and invoice PDF; cap invoice item arrays at 200.
- Require TURNSTILE_SECRET_KEY in production (bot protection no longer fails
  open on a forgotten key); don't load .env under NODE_ENV=test so the suite
  is hermetic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:34:33 -04:00

604 lines
20 KiB
TypeScript

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<number>`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<string>`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<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();
// 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);
});
}