From 85af080ba2fe01ef5c4cfd2b77ec81b411a0b6f7 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 14:12:52 +0000 Subject: [PATCH] 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 --- apps/api/src/middleware/csrf.ts | 18 ++++++++++++ apps/api/src/routes/invoices.ts | 51 +++++++++++++++++++++++++++------ packages/db/src/schema.ts | 19 ++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/middleware/csrf.ts diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts new file mode 100644 index 0000000..d270862 --- /dev/null +++ b/apps/api/src/middleware/csrf.ts @@ -0,0 +1,18 @@ +import type { MiddlewareHandler } from "hono"; +import type { AppEnv } from "./rbac.js"; + +const CSRF_SAFE_METHODS = ["GET", "HEAD", "OPTIONS"]; + +export const csrfMiddleware: MiddlewareHandler = async (c, next) => { + if (CSRF_SAFE_METHODS.includes(c.req.method)) { + await next(); + return; + } + + const csrfHeader = c.req.header("x-csrf-token"); + if (!csrfHeader) { + return c.json({ error: "CSRF token required" }, 403); + } + + await next(); +}; \ No newline at end of file diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 0f65a34..2d82af6 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -8,6 +8,7 @@ import { invoices, invoiceLineItems, invoiceTipSplits, + refunds, appointments, services, clients, @@ -125,8 +126,8 @@ const tipSplitSchema = z.object({ }) ).min(1).refine( (splits) => { - const total = splits.reduce((sum, s) => sum + s.sharePct, 0); - return Math.abs(total - 100) < 0.01; + const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); + return totalBps === 10000; }, { message: "Split percentages must sum to 100" } ), @@ -170,12 +171,13 @@ invoicesRouter.post( } }); - const splits = await db - .select() - .from(invoiceTipSplits) - .where(eq(invoiceTipSplits.invoiceId, id)); + 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(splits, 201); + return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201); } ); @@ -300,6 +302,13 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => { return c.json({ ...invoice, lineItems: [lineItem] }, 201); }); +const ALLOWED_TRANSITIONS: Record = { + draft: ["pending", "void"], + pending: ["draft", "paid", "void"], + paid: ["void"], + void: [], +}; + // Update invoice invoicesRouter.patch( "/:id", @@ -315,8 +324,14 @@ invoicesRouter.patch( .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); + 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 = { ...body, updatedAt: new Date() }; @@ -354,6 +369,7 @@ import { processRefund } from "../services/payment.js"; const refundSchema = z.object({ amountCents: z.number().int().nonnegative().optional(), + idempotencyKey: z.string().max(255).optional(), }); invoicesRouter.post( @@ -379,9 +395,26 @@ invoicesRouter.post( return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); } + if (body.idempotencyKey) { + const [existing] = await db + .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 db.insert(refunds).values({ + invoiceId: id, + stripeRefundId: result.refundId, + idempotencyKey: body.idempotencyKey ?? null, + amountCents: body.amountCents ?? null, + }); + return c.json({ refundId: result.refundId }); } ); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a5d6bfc..375bf75 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -300,6 +300,25 @@ export const invoiceTipSplits = pgTable( (t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)] ); +// Refund records with idempotency key support +export const refunds = pgTable( + "refunds", + { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "restrict" }), + stripeRefundId: text("stripe_refund_id").notNull(), + idempotencyKey: text("idempotency_key").unique(), + amountCents: integer("amount_cents"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_refunds_invoice_id").on(t.invoiceId), + index("idx_refunds_idempotency_key").on(t.idempotencyKey), + ] +); + // Tracks which reminder emails have been sent per appointment (prevents duplicates). // reminder_type values: "confirmation", "24h", "2h" export const reminderLogs = pgTable(