2026-04-26 02:42:42 -04:00
|
|
|
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 };
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 03:07:37 -04:00
|
|
|
// 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' };
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 02:42:42 -04:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
}
|