This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/api/src/routes/invoices.ts
T
groombook-cto[bot] d433c902b4 fix(GRO-637): invoice status transitions, tip-split validation, refund idempotency, and tip-split response format
* Fix invoice status transitions, tip-split validation, refund idempotency, and tip-split response format

- Add ALLOWED_TRANSITIONS state machine for invoice status changes (GRO-637)
- Replace floating-point tip-split validation with integer basis-points math
- Add idempotency key support to refund endpoint with new refunds table
- Return full invoice shape from POST /:id/tip-splits matching GET response
- All existing tests pass

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(invoices): wrap refund flow in transaction for idempotency safety

- Wrap idempotency check + processRefund() + db.insert() in db.transaction()
- This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund
- Add migration 0027_refunds for the refunds table (was missing)
- Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks)

Fixes GRO-637 per CTO review

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(api): wire up CSRF middleware for protected routes

Register csrfMiddleware in the protected API routes after authMiddleware
and resolveStaffMiddleware to protect against CSRF attacks on state-
changing operations (POST, PUT, PATCH, DELETE).

Addresses CTO review feedback on PR #278.

* fix(api): remove CSRF middleware that breaks POST/PUT/PATCH/DELETE

The CSRF middleware requires x-csrf-token header but the frontend never
sends it, which would break all mutating operations with 403 errors.

CSRF protection should be implemented in a separate coordinated PR with
frontend changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
2026-04-15 06:04:38 +00:00

423 lines
13 KiB
TypeScript

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
and,
eq,
getDb,
invoices,
invoiceLineItems,
invoiceTipSplits,
refunds,
appointments,
services,
clients,
sql,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
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
const listInvoicesQuerySchema = z.object({
clientId: z.string().uuid().optional(),
appointmentId: z.string().uuid().optional(),
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
invoicesRouter.get(
"/",
zValidator("query", listInvoicesQuerySchema),
async (c) => {
const db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
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<number>`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 totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
return totalBps === 10000;
},
{ 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 [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
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({ ...updatedInvoice, lineItems, tipSplits }, 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);
});
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
draft: ["pending", "void"],
pending: ["draft", "paid", "void"],
paid: ["void"],
void: [],
};
// 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 (body.status !== undefined) {
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
if (!allowed.includes(body.status)) {
return c.json(
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
422
);
}
}
const update: Record<string, unknown> = { ...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(),
idempotencyKey: z.string().max(255).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);
}
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
const [existing] = await tx
.select()
.from(refunds)
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
if (existing) {
return c.json({ refundId: existing.stripeRefundId });
}
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId: result.refundId });
});
}
);