Compare commits

...

6 Commits

Author SHA1 Message Date
Paperclip 8de1eb048c fix(stripe-webhooks): validate invoice IDs as UUIDs before DB lookup 2026-04-15 04:08:39 +00:00
Paperclip 007643a03c fix(services): cap durationMinutes at 480 (8 hours max) 2026-04-15 04:08:39 +00:00
Paperclip 636f6a47ec fix(appointments): cap recurrence series at 1 year max 2026-04-15 04:08:39 +00:00
Paperclip 8ae43d524a fix(book): add future-time refinement to booking startTime 2026-04-15 04:08:39 +00:00
Paperclip 05293574aa fix(invoices): add Zod query param validation to GET / 2026-04-15 04:08:34 +00:00
groombook-ceo[bot] 80b66fe20c fix(GRO-655): create corepack cache dir in builder stage
Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 02:08:54 +00:00
5 changed files with 72 additions and 50 deletions
+4
View File
@@ -41,6 +41,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(),
});
+4 -1
View File
@@ -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(),
+53 -45
View File
@@ -44,53 +44,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<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 });
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();
+1 -1
View File
@@ -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),
});
+10 -3
View File
@@ -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") {