import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, getDb, invoices, invoiceLineItems, invoiceTipSplits, appointments, services, clients, sql, } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const invoicesRouter = new Hono(); const createInvoiceSchema = z.object({ appointmentId: z.string().uuid().optional(), clientId: z.string().uuid(), lineItems: z .array( z.object({ description: z.string().min(1).max(500), quantity: z.number().int().positive().default(1), unitPriceCents: z.number().int().nonnegative(), }) ) .min(1), taxCents: z.number().int().nonnegative().default(0), tipCents: z.number().int().nonnegative().default(0), notes: z.string().max(2000).optional(), }); const updateInvoiceSchema = z.object({ status: z.enum(["draft", "pending", "paid", "void"]).optional(), paymentMethod: z.enum(["cash", "card", "check", "other"]).nullable().optional(), paidAt: z.string().datetime().nullable().optional(), taxCents: z.number().int().nonnegative().optional(), tipCents: z.number().int().nonnegative().optional(), notes: z.string().max(2000).nullable().optional(), }); // List invoices invoicesRouter.get("/", async (c) => { const db = getDb(); const clientId = c.req.query("clientId"); const appointmentId = c.req.query("appointmentId"); const status = c.req.query("status"); const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200); const offset = parseInt(c.req.query("offset") || "0", 10); const conditions = []; if (clientId) conditions.push(eq(invoices.clientId, clientId)); if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const [totalResult] = await db .select({ count: sql`count(*)` }) .from(invoices) .where(whereClause); const rows = await db .select({ id: invoices.id, appointmentId: invoices.appointmentId, clientId: invoices.clientId, clientName: clients.name, subtotalCents: invoices.subtotalCents, taxCents: invoices.taxCents, tipCents: invoices.tipCents, totalCents: invoices.totalCents, status: invoices.status, paymentMethod: invoices.paymentMethod, paidAt: invoices.paidAt, notes: invoices.notes, createdAt: invoices.createdAt, updatedAt: invoices.updatedAt, }) .from(invoices) .leftJoin(clients, eq(invoices.clientId, clients.id)) .where(whereClause) .orderBy(invoices.createdAt) .limit(limit) .offset(offset); return c.json({ data: rows, total: totalResult?.count ?? 0 }); }); // Get single invoice with line items and tip splits invoicesRouter.get("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); if (!invoice) return c.json({ error: "Not found" }, 404); const [lineItems, tipSplits] = await Promise.all([ db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), ]); return c.json({ ...invoice, lineItems, tipSplits }); }); // Save tip splits for an invoice (replaces existing splits) const tipSplitSchema = z.object({ splits: z.array( z.object({ staffId: z.string().uuid().nullable(), staffName: z.string().min(1).max(200), sharePct: z.number().min(0).max(100), }) ).min(1).refine( (splits) => { const total = splits.reduce((sum, s) => sum + s.sharePct, 0); return Math.abs(total - 100) < 0.01; }, { message: "Split percentages must sum to 100" } ), }); invoicesRouter.post( "/:id/tip-splits", zValidator("json", tipSplitSchema), async (c) => { const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); if (!invoice) return c.json({ error: "Not found" }, 404); if (invoice.status === "void") return c.json({ error: "Cannot modify a voided invoice" }, 422); const tipCents = invoice.tipCents; await db.transaction(async (tx) => { // Remove existing splits await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); // Insert new splits, distributing tipCents proportionally let remaining = tipCents; const rows = body.splits.map((s, i) => { const isLast = i === body.splits.length - 1; const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents); if (!isLast) remaining -= shareCents; return { invoiceId: id, staffId: s.staffId, staffName: s.staffName, sharePct: s.sharePct.toFixed(2), shareCents, }; }); if (rows.length > 0) { await tx.insert(invoiceTipSplits).values(rows); } }); const splits = await db .select() .from(invoiceTipSplits) .where(eq(invoiceTipSplits.invoiceId, id)); return c.json(splits, 201); } ); // Create invoice (optionally pre-populated from an appointment) invoicesRouter.post( "/", zValidator("json", createInvoiceSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); // If appointmentId provided, verify it exists if (body.appointmentId) { const [appt] = await db .select() .from(appointments) .where(eq(appointments.id, body.appointmentId)); if (!appt) return c.json({ error: "Appointment not found" }, 404); } const subtotalCents = body.lineItems.reduce( (sum, item) => sum + item.quantity * item.unitPriceCents, 0 ); const totalCents = subtotalCents + body.taxCents + body.tipCents; const [invoice] = await db .insert(invoices) .values({ appointmentId: body.appointmentId ?? null, clientId: body.clientId, subtotalCents, taxCents: body.taxCents, tipCents: body.tipCents, totalCents, notes: body.notes ?? null, }) .returning(); if (!invoice) return c.json({ error: "Failed to create invoice" }, 500); const items = await db .insert(invoiceLineItems) .values( body.lineItems.map((item) => ({ invoiceId: invoice.id, description: item.description, quantity: item.quantity, unitPriceCents: item.unitPriceCents, totalCents: item.quantity * item.unitPriceCents, })) ) .returning(); return c.json({ ...invoice, lineItems: items }, 201); } ); // Create invoice from appointment (convenience endpoint) invoicesRouter.post("/from-appointment/:appointmentId", async (c) => { const db = getDb(); const appointmentId = c.req.param("appointmentId"); const [appt] = await db .select({ id: appointments.id, clientId: appointments.clientId, serviceId: appointments.serviceId, priceCents: appointments.priceCents, serviceName: services.name, serviceBasePriceCents: services.basePriceCents, }) .from(appointments) .innerJoin(services, eq(appointments.serviceId, services.id)) .where(eq(appointments.id, appointmentId)); if (!appt) return c.json({ error: "Appointment not found" }, 404); // Check if invoice already exists for this appointment const [existing] = await db .select({ id: invoices.id }) .from(invoices) .where(eq(invoices.appointmentId, appointmentId)) .limit(1); if (existing) { return c.json( { error: "Invoice already exists for this appointment", invoiceId: existing.id }, 409 ); } const unitPriceCents = appt.priceCents ?? appt.serviceBasePriceCents; const subtotalCents = unitPriceCents; const totalCents = subtotalCents; const [invoice] = await db .insert(invoices) .values({ appointmentId, clientId: appt.clientId, subtotalCents, taxCents: 0, tipCents: 0, totalCents, }) .returning(); if (!invoice) return c.json({ error: "Failed to create invoice" }, 500); const [lineItem] = await db .insert(invoiceLineItems) .values({ invoiceId: invoice.id, description: appt.serviceName, quantity: 1, unitPriceCents, totalCents: unitPriceCents, }) .returning(); return c.json({ ...invoice, lineItems: [lineItem] }, 201); }); // Update invoice invoicesRouter.patch( "/:id", zValidator("json", updateInvoiceSchema), async (c) => { const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); const [current] = await db .select() .from(invoices) .where(eq(invoices.id, id)); if (!current) return c.json({ error: "Not found" }, 404); if (current.status === "void") { return c.json({ error: "Cannot modify a voided invoice" }, 422); } const update: Record = { ...body, updatedAt: new Date() }; // Auto-set paidAt when marking as paid if (body.status === "paid" && !body.paidAt && !current.paidAt) { update.paidAt = new Date(); } // Recalculate total if tax or tip changed const newTaxCents = body.taxCents ?? current.taxCents; const newTipCents = body.tipCents ?? current.tipCents; if (body.taxCents !== undefined || body.tipCents !== undefined) { update.totalCents = current.subtotalCents + newTaxCents + newTipCents; } const [updated] = await db .update(invoices) .set(update) .where(eq(invoices.id, id)) .returning(); const lineItems = await db .select() .from(invoiceLineItems) .where(eq(invoiceLineItems.invoiceId, id)); return c.json({ ...updated, lineItems }); } ); // ─── Refund ─────────────────────────────────────────────────────────────────── import { processRefund } from "../services/payment.js"; const refundSchema = z.object({ amountCents: z.number().int().nonnegative().optional(), }); invoicesRouter.post( "/:id/refund", zValidator("json", refundSchema), async (c) => { const db = getDb(); const staff = c.get("staff"); if (!staff) return c.json({ error: "Forbidden" }, 403); if (staff.role !== "manager" && !staff.isSuperUser) { return c.json({ error: "Manager role required" }, 403); } const id = c.req.param("id"); const body = c.req.valid("json"); const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); if (!invoice) return c.json({ error: "Not found" }, 404); if (invoice.status !== "paid") { return c.json({ error: "Refund only allowed on paid invoices" }, 422); } if (!invoice.stripePaymentIntentId) { return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); } const result = await processRefund(id, body.amountCents); if (!result) return c.json({ error: "Refund failed" }, 500); return c.json({ refundId: result.refundId }); } );