diff --git a/apps/api/src/__tests__/clients.test.ts b/apps/api/src/__tests__/clients.test.ts index 38a2886..7484e59 100644 --- a/apps/api/src/__tests__/clients.test.ts +++ b/apps/api/src/__tests__/clients.test.ts @@ -27,12 +27,14 @@ const DISABLED_CLIENT = { // ─── Queue-based mock DB ────────────────────────────────────────────────────── let selectRows: Record[] = []; +let appointmentRows: Record[] = []; let insertedValues: Record[] = []; let updatedValues: Record[] = []; let deletedId: string | null = null; function resetMock() { selectRows = []; + appointmentRows = []; insertedValues = []; updatedValues = []; deletedId = null; @@ -58,10 +60,19 @@ vi.mock("@groombook/db", () => { { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } ); + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + return { getDb: () => ({ select: () => ({ - from: () => makeChainable(selectRows), + from: (table: unknown) => { + const tableName = (table as { _name?: string })._name; + const rows = tableName === "appointments" ? appointmentRows : selectRows; + return makeChainable(rows); + }, }), insert: () => ({ values: (vals: Record) => { @@ -95,8 +106,10 @@ vi.mock("@groombook/db", () => { }), }), clients, + appointments, eq: vi.fn(), and: vi.fn(), + or: vi.fn(), }; }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index aa5e1db..a3d97fd 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -202,9 +202,24 @@ api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); console.log(`API server listening on port ${port}`); -serve({ fetch: app.fetch, port }); +const server = serve({ fetch: app.fetch, port }); // Start background reminder scheduler (runs every minute to check for upcoming appointments) startReminderScheduler(); +function shutdown() { + console.log("Shutting down gracefully..."); + server.close(() => { + console.log("HTTP server closed"); + process.exit(0); + }); + setTimeout(() => { + console.error("Forced shutdown after timeout"); + process.exit(1); + }, 10_000); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + export default app; diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 2f5acdd..1277b2c 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { eq, getDb, staff } from "@groombook/db"; +import { and, eq, getDb, sql, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -89,14 +89,31 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .select() .from(staff) .where(eq(staff.oidcSub, jwt.sub)); - if (!fallbackRow) { - return c.json( - { error: "Forbidden: no staff record found for authenticated user" }, - 403 - ); + if (fallbackRow) { + c.set("staff", fallbackRow); + await next(); + return; } - c.set("staff", fallbackRow); - await next(); + // Auto-link by email: staff record exists with matching email but no userId + if (jwt.email) { + const [byEmail] = await db + .select() + .from(staff) + .where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`)); + if (byEmail) { + await db + .update(staff) + .set({ userId: jwt.sub, updatedAt: new Date() }) + .where(eq(staff.id, byEmail.id)); + c.set("staff", { ...byEmail, userId: jwt.sub }); + await next(); + return; + } + } + return c.json( + { error: "Forbidden: no staff record found for authenticated user" }, + 403 + ); }; /** diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 24d6b3c..95ea705 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -23,6 +23,27 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import type { AppEnv } from "../middleware/rbac.js"; +async function withRetry( + fn: () => Promise, + maxRetries: number, + delayMs: number, + context: string +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await fn(); + return; + } catch (err) { + lastError = err; + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + } + console.error(`[appointments] ${context}: ${lastError}`); +} + export const appointmentsRouter = new Hono(); const createAppointmentSchema = z.object({ @@ -41,6 +62,10 @@ const createAppointmentSchema = z.object({ frequencyWeeks: z.number().int().min(1).max(52), count: z.number().int().min(2).max(52), }) + .refine( + (r) => r.frequencyWeeks * r.count <= 52, + { message: "Recurrence series must not exceed 1 year" } + ) .optional(), }); @@ -208,11 +233,54 @@ appointmentsRouter.post( recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000; let first: typeof appointments.$inferSelect | undefined; + const conflictingInstances: number[] = []; for (let i = 0; i < recurrence.count; i++) { const instanceStart = new Date(start.getTime() + i * intervalMs); const instanceEnd = new Date( instanceStart.getTime() + durationMs ); + + if (apptFields.staffId) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, apptFields.staffId), + lt(appointments.startTime, instanceEnd), + gte(appointments.endTime, instanceStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (conflicts.length > 0) { + conflictingInstances.push(i); + } + } + + if (apptFields.batherStaffId) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, apptFields.batherStaffId), + eq(appointments.batherStaffId, apptFields.batherStaffId) + ), + lt(appointments.startTime, instanceEnd), + gte(appointments.endTime, instanceStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + if (conflicts.length > 0) { + conflictingInstances.push(i); + } + } + const [inserted] = await tx .insert(appointments) .values({ @@ -223,9 +291,19 @@ appointmentsRouter.post( seriesIndex: i, }) .returning(); + if (!inserted) throw new Error(`Insert failed for occurrence ${i}`); if (i === 0) first = inserted; } + if (conflictingInstances.length > 0) { + throw Object.assign( + new Error( + `Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}` + ), + { statusCode: 409 } + ); + } + if (!first) throw new Error("No appointments created"); return first; }); @@ -243,9 +321,12 @@ appointmentsRouter.post( } // Send confirmation email (fire-and-forget — never fails the request) - sendConfirmationEmail(db, firstRow).catch((err) => { - console.error("[appointments] Failed to send confirmation email:", err); - }); + withRetry( + () => sendConfirmationEmail(db, firstRow), + 2, + 1000, + `Failed to send confirmation email for appointment ${firstRow.id}` + ); return c.json(firstRow, 201); } @@ -374,6 +455,76 @@ appointmentsRouter.patch( let firstUpdated: typeof appointments.$inferSelect | undefined; for (const appt of affected) { + const newStart = + startDeltaMs !== 0 + ? new Date(appt.startTime.getTime() + startDeltaMs) + : appt.startTime; + const newEnd = + endDeltaMs !== 0 + ? new Date(appt.endTime.getTime() + endDeltaMs) + : appt.endTime; + const newStaffId = + updateFields.staffId !== undefined + ? updateFields.staffId + : appt.staffId; + const newBatherStaffId = + updateFields.batherStaffId !== undefined + ? updateFields.batherStaffId + : appt.batherStaffId; + + if ( + newStaffId && + (startDeltaMs !== 0 || + endDeltaMs !== 0 || + updateFields.staffId !== undefined) + ) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, newStaffId), + lt(appointments.startTime, newEnd), + gte(appointments.endTime, newStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, appt.id), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + + if ( + newBatherStaffId && + (startDeltaMs !== 0 || + endDeltaMs !== 0 || + updateFields.batherStaffId !== undefined) + ) { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + or( + eq(appointments.staffId, newBatherStaffId), + eq(appointments.batherStaffId, newBatherStaffId) + ), + lt(appointments.startTime, newEnd), + gte(appointments.endTime, newStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, appt.id), + ) + ) + .limit(1); + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + const apptUpdate: Record = { updatedAt: new Date(), }; @@ -409,6 +560,13 @@ appointmentsRouter.patch( if (statusCode === 404) return c.json({ error: "Not found" }, 404); if (statusCode === 422) return c.json({ error: "endTime must be after startTime" }, 422); + if (statusCode === 409) + return c.json( + { + error: "Staff member has a conflicting appointment at this time", + }, + 409 + ); throw err; } @@ -586,9 +744,12 @@ appointmentsRouter.delete("/:id", async (c) => { const apptDate = current.startTime.toISOString().slice(0, 10); const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); - notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => { - console.error("[appointments] Failed to notify waitlist:", err); - }); + withRetry( + () => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), + 2, + 1000, + `Failed to notify waitlist for appointment ${id}` + ); return c.json({ ok: true }); } @@ -611,9 +772,12 @@ appointmentsRouter.delete("/:id", async (c) => { .returning(); if (!row) return c.json({ error: "Not found" }, 404); - notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => { - console.error("[appointments] Failed to notify waitlist:", err); - }); + withRetry( + () => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), + 2, + 1000, + `Failed to notify waitlist for appointment ${id}` + ); return c.json({ ok: true }); }); diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index 26b7cae..69f61e5 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => { const bookingSchema = z.object({ serviceId: z.string().uuid(), - startTime: z.string().datetime(), + startTime: z.string().datetime().refine( + (dt) => new Date(dt) > new Date(), + { message: "Appointment must be in the future" } + ), clientName: z.string().min(1).max(200), clientEmail: z.string().email(), clientPhone: z.string().max(50).optional(), diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index fe639c5..053b975 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -135,9 +135,24 @@ clientsRouter.delete("/:id", async (c) => { } const db = getDb(); + const clientId = c.req.param("id"); + + const [existingAppt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where(eq(appointments.clientId, clientId)) + .limit(1); + + if (existingAppt) { + return c.json( + { error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." }, + 409 + ); + } + const [row] = await db .delete(clients) - .where(eq(clients.id, c.req.param("id"))) + .where(eq(clients.id, clientId)) .returning(); if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 96c3e0e..2714be4 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, @@ -44,53 +45,61 @@ const updateInvoiceSchema = z.object({ }); // 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 }); +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`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(); @@ -117,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" } ), @@ -162,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); } ); @@ -292,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", @@ -307,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() }; @@ -346,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( @@ -371,9 +395,28 @@ invoicesRouter.post( 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 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 }); + } + } - return c.json({ refundId: result.refundId }); + 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 }); + }); } ); diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 3849d4c..c249862 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => { ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); + const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20)); + const offset = (page - 1) * limit; + const churnRisk = await db .select({ clientId: clients.id, @@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => { .having( sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` ) - .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`); + .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`) + .limit(limit) + .offset(offset); + + const [churnCountRow] = await db + .select({ total: sql`count(*)::int` }) + .from( + db + .select({ id: clients.id }) + .from(clients) + .leftJoin(appointments, eq(appointments.clientId, clients.id)) + .groupBy(clients.id) + .having( + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` + ) + .as("churn_count") + ); + const churnRiskTotal = churnCountRow?.total ?? 0; return c.json({ from: from.toISOString(), to: to.toISOString(), newClients, activeInPeriodCount: activeInPeriod.length, - churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients - churnRiskTotal: churnRisk.length, + churnRisk, + churnRiskTotal, + page, + limit, }); }); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index e9ccc44..659dee2 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -9,7 +9,7 @@ const createServiceSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(2000).optional(), basePriceCents: z.number().int().positive(), - durationMinutes: z.number().int().positive(), + durationMinutes: z.number().int().positive().max(480), active: z.boolean().default(true), }); diff --git a/apps/api/src/routes/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts index 4a948a1..fa7c8ef 100644 --- a/apps/api/src/routes/stripe-webhooks.ts +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import Stripe from "stripe"; +import { z } from "zod/v3"; import { eq, getDb, invoices } from "@groombook/db"; import { getStripeClient } from "../services/payment.js"; @@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => { const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); for (const invoiceId of invoiceIds) { if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); const [inv] = await db .select() .from(invoices) - .where(eq(invoices.id, invoiceId)) + .where(eq(invoices.id, invoiceIdTrimmed)) .limit(1); if (!inv) continue; if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue; @@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => { stripePaymentIntentId: pi.id, updatedAt: new Date(), }) - .where(eq(invoices.id, invoiceId)); + .where(eq(invoices.id, invoiceIdTrimmed)); } } } else if (event.type === "payment_intent.payment_failed") { @@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => { const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); for (const invoiceId of invoiceIds) { if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); await db .update(invoices) .set({ paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed", updatedAt: new Date(), }) - .where(eq(invoices.id, invoiceId)); + .where(eq(invoices.id, invoiceIdTrimmed)); } } } else if (event.type === "charge.refunded") { diff --git a/packages/db/migrations/0027_refunds.sql b/packages/db/migrations/0027_refunds.sql new file mode 100644 index 0000000..ba8d6ea --- /dev/null +++ b/packages/db/migrations/0027_refunds.sql @@ -0,0 +1,11 @@ +CREATE TABLE "refunds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT, + "stripe_refund_id" text NOT NULL, + "idempotency_key" text UNIQUE, + "amount_cents" integer, + "created_at" timestamp NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id"); +CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 96da64a..c5ad96e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1775568867192, "tag": "0026_stripe_payment", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1775655267192, + "tag": "0027_refunds", + "breakpoints": true } ] } \ No newline at end of file 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( diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index f18f5f7..ebb84a9 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -567,7 +567,7 @@ async function seed() { // ── Staff ── const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => - ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false }) + ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) ); const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })