diff --git a/apps/api/package.json b/apps/api/package.json index 55c1c9d..8db4bbe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "hono": "^4.6.17", "node-cron": "^3.0.3", "nodemailer": "^6.9.16", + "stripe": "^22.0.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6cf62ac..7f49e20 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,6 +28,7 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup import { devRouter } from "./routes/dev.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; +import { webhooksRouter } from "./routes/stripe-webhooks.js"; const app = new Hono(); @@ -50,6 +51,9 @@ app.route("/api/book", bookRouter); // Public portal routes — client-facing, authenticated via impersonation session header app.route("/api/portal", portalRouter); +// Public Stripe webhook endpoint — signature-verified, no auth required +app.route("/api/webhooks/stripe", webhooksRouter); + // Dev/demo routes — config is always public, users endpoint is guarded internally app.route("/api/dev", devRouter); diff --git a/apps/api/src/routes/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts new file mode 100644 index 0000000..de168c5 --- /dev/null +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -0,0 +1,108 @@ +import { Hono } from "hono"; +import Stripe from "stripe"; +import { eq, getDb, invoices } from "@groombook/db"; + +export const webhooksRouter = new Hono(); + +webhooksRouter.post("/stripe", async (c) => { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + return c.json({ error: "Webhook secret not configured" }, 503); + } + + const signature = c.req.header("stripe-signature"); + if (!signature) { + return c.json({ error: "Missing signature" }, 401); + } + + let rawBody: string; + try { + rawBody = await c.req.text(); + } catch { + return c.json({ error: "Could not read body" }, 400); + } + + const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" }); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(rawBody, signature, secret); + } catch (err) { + const message = err instanceof Error ? err.message : "Invalid signature"; + return c.json({ error: message }, 401); + } + + const db = getDb(); + + if (event.type === "payment_intent.succeeded") { + const pi = event.data.object as Stripe.PaymentIntent; + if (pi.metadata?.groombook_invoice_ids) { + const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); + for (const invoiceId of invoiceIds) { + if (!invoiceId) continue; + const [inv] = await db + .select() + .from(invoices) + .where(eq(invoices.id, invoiceId)) + .limit(1); + if (!inv) continue; + if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue; + await db + .update(invoices) + .set({ + status: "paid", + paymentMethod: "card", + paidAt: new Date(), + stripePaymentIntentId: pi.id, + updatedAt: new Date(), + }) + .where(eq(invoices.id, invoiceId)); + } + } + } else if (event.type === "payment_intent.payment_failed") { + const pi = event.data.object as Stripe.PaymentIntent; + if (pi.metadata?.groombook_invoice_ids) { + const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); + for (const invoiceId of invoiceIds) { + if (!invoiceId) continue; + await db + .update(invoices) + .set({ + paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed", + updatedAt: new Date(), + }) + .where(eq(invoices.id, invoiceId)); + } + } + } else if (event.type === "charge.refunded") { + const charge = event.data.object as Stripe.Charge; + if (typeof charge.payment_intent === "string" && charge.payment_intent) { + const [inv] = await db + .select({ id: invoices.id }) + .from(invoices) + .where(eq(invoices.stripePaymentIntentId, charge.payment_intent)) + .limit(1); + if (inv) { + const refundId = + typeof charge.refunded === "boolean" && charge.refunded + ? `ch_${charge.id}_refund` + : null; + await db + .update(invoices) + .set({ + status: "void", + stripeRefundId: refundId, + updatedAt: new Date(), + }) + .where(eq(invoices.id, inv.id)); + } + } + } else if (event.type === "charge.dispute.created") { + const dispute = event.data.object as Stripe.Dispute; + console.error( + `[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}` + ); + } + + return c.json({ received: true }); +}); diff --git a/packages/db/migrations/0026_stripe_identifiers.sql b/packages/db/migrations/0026_stripe_identifiers.sql new file mode 100644 index 0000000..1b77c5a --- /dev/null +++ b/packages/db/migrations/0026_stripe_identifiers.sql @@ -0,0 +1,4 @@ +ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text; +ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text; +ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text; +ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id"); \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9698b52..a2cff66 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -251,6 +251,9 @@ export const invoices = pgTable( status: invoiceStatusEnum("status").notNull().default("draft"), paymentMethod: paymentMethodEnum("payment_method"), paidAt: timestamp("paid_at"), + stripePaymentIntentId: text("stripe_payment_intent_id"), + stripeRefundId: text("stripe_refund_id"), + paymentFailureReason: text("payment_failure_reason"), notes: text("notes"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), @@ -259,6 +262,7 @@ export const invoices = pgTable( index("idx_invoices_client_id").on(t.clientId), index("idx_invoices_status").on(t.status), index("idx_invoices_created_at").on(t.createdAt), + unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId), ] ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faa203f..26a74fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: nodemailer: specifier: ^6.9.16 version: 6.10.1 + stripe: + specifier: ^22.0.0 + version: 22.0.1(@types/node@22.19.15) zod: specifier: ^4.3.6 version: 4.3.6 @@ -4124,6 +4127,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@22.0.1: + resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@2.2.1: resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} @@ -8774,6 +8786,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@22.0.1(@types/node@22.19.15): + optionalDependencies: + '@types/node': 22.19.15 + strnum@2.2.1: {} supports-color@7.2.0: