From ae873215c09f9b3366c4a892c5f46704f35a4c4a Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:54:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(GRO-597):=20Stripe=20payment=20backend=20?= =?UTF-8?q?=E2=80=94=20schema,=20service,=20API,=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates GRO-605, GRO-606, GRO-608 into a single clean PR: - GRO-605: Stripe SDK integration + payment service - GRO-606: Payment API endpoints (pay invoice, payment methods, refunds) - GRO-608: Stripe webhook handler Migration consolidation: - Single 0026_stripe_payment.sql migration adds stripeCustomerId to clients and stripe_payment_intent_id, stripe_refund_id, payment_failure_reason to invoices - Removed duplicate 0027_stripe_identifiers.sql Co-Authored-By: Paperclip --- apps/api/src/index.ts | 4 + apps/api/src/routes/invoices.ts | 3 +- apps/api/src/routes/portal.ts | 2 +- apps/api/src/routes/stripe-webhooks.ts | 108 ++++++++++++++ apps/api/src/routes/webhooks.ts | 137 ------------------ apps/api/src/services/payment.ts | 14 +- .../db/migrations/0026_stripe_payment.sql | 6 + .../db/migrations/0027_stripe_identifiers.sql | 6 - ...{0027_snapshot.json => 0026_snapshot.json} | 2 +- packages/db/migrations/meta/_journal.json | 7 + packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 6 +- pnpm-lock.yaml | 16 ++ 13 files changed, 159 insertions(+), 153 deletions(-) create mode 100644 apps/api/src/routes/stripe-webhooks.ts delete mode 100644 apps/api/src/routes/webhooks.ts create mode 100644 packages/db/migrations/0026_stripe_payment.sql delete mode 100644 packages/db/migrations/0027_stripe_identifiers.sql rename packages/db/migrations/meta/{0027_snapshot.json => 0026_snapshot.json} (99%) 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/invoices.ts b/apps/api/src/routes/invoices.ts index 33a57de..96c3e0e 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -13,8 +13,9 @@ import { clients, sql, } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; -export const invoicesRouter = new Hono(); +export const invoicesRouter = new Hono(); const createInvoiceSchema = z.object({ appointmentId: z.string().uuid().optional(), diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index dd58d0a..5e9dfe2 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -453,7 +453,6 @@ portalRouter.delete("/waitlist/:id", async (c) => { import { createPaymentIntent, listPaymentMethods, - attachPaymentMethod, detachPaymentMethod, createSetupIntent, getOrCreateStripeCustomer, @@ -530,6 +529,7 @@ portalRouter.post( } const firstInvoice = invoiceRows[0]; + if (!firstInvoice) return c.json({ error: "No invoices found" }, 400); const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId); if (!allSameClient) { return c.json({ error: "All invoices must belong to the same client" }, 422); 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/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts deleted file mode 100644 index 9f8314c..0000000 --- a/apps/api/src/routes/webhooks.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Hono } from "hono"; -import { - and, - eq, - getDb, - clients, - reminderLogs, - smsSend, -} from "@groombook/db"; -import { TelnyxProvider } from "../services/sms.js"; - -export const webhooksRouter = new Hono(); - -const telnyxProvider = new TelnyxProvider(); - -const STOP_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]); -const START_KEYWORDS = new Set(["START", "YES", "UNSTOP"]); - -webhooksRouter.post("/sms/inbound", async (c) => { - if (!telnyxProvider.validateWebhookSignature(c.req.raw)) { - return c.json({ error: "Invalid signature" }, 401); - } - - let body: Record; - try { - body = await c.req.json(); - } catch { - return c.json({ error: "Invalid JSON" }, 400); - } - - const event = (body.data as Record)?.event_type ?? body.event_type; - const payload = (body.data as Record) ?? body; - - if (event === "message.received") { - const fromField = payload.from; - const from = typeof fromField === "object" && fromField !== null - ? (fromField as Record).phone_number as string ?? (fromField as Record).toString() - : String(fromField ?? ""); - const text = String(payload.text ?? payload.body ?? "").trim().toUpperCase(); - - if (!from || !text) { - return c.json({ error: "Missing from or text" }, 400); - } - - const db = getDb(); - - const [client] = await db - .select({ id: clients.id, smsOptIn: clients.smsOptIn }) - .from(clients) - .where(eq(clients.phone, from)) - .limit(1); - - if (!client) { - return c.json({ received: true }); - } - - if (STOP_KEYWORDS.has(text)) { - await db - .update(clients) - .set({ - smsOptIn: false, - smsOptOutDate: new Date(), - updatedAt: new Date(), - }) - .where(eq(clients.id, client.id)); - return c.json({ received: true }); - } - - if (START_KEYWORDS.has(text)) { - await db - .update(clients) - .set({ - smsOptIn: true, - smsConsentDate: new Date(), - updatedAt: new Date(), - }) - .where(eq(clients.id, client.id)); - return c.json({ received: true }); - } - - if (text === "HELP") { - const supportUrl = process.env.SUPPORT_URL ?? "https://groombook.app/support"; - await smsSend(from, `GroomBook appointment reminders. Reply STOP to opt out. For help, visit ${supportUrl}.`); - return c.json({ received: true }); - } - - return c.json({ received: true }); - } - - if (event === "message.finalized" || event === "message.status") { - const status = String(payload.status ?? ""); - const toField = payload.to; - const toNumber = typeof toField === "object" && toField !== null - ? (toField as Record).phone_number as string ?? (toField as Record).toString() - : String(toField ?? ""); - - if (!status || !toNumber) { - return c.json({ received: true }); - } - - const validDelivery = ["delivered", "sent", "failed", "sending", "queued"]; - if (!validDelivery.includes(status)) { - return c.json({ received: true }); - } - - const db = getDb(); - - const [client] = await db - .select({ id: clients.id }) - .from(clients) - .where(eq(clients.phone, toNumber)) - .limit(1); - - if (client) { - const [log] = await db - .select({ id: reminderLogs.id }) - .from(reminderLogs) - .where( - and( - eq(reminderLogs.channel, "sms") - ) - ) - .limit(1); - - if (log) { - await db - .update(reminderLogs) - .set({ deliveryStatus: status }) - .where(eq(reminderLogs.id, log.id)); - } - } - - return c.json({ received: true }); - } - - return c.json({ received: true }); -}); \ No newline at end of file diff --git a/apps/api/src/services/payment.ts b/apps/api/src/services/payment.ts index b3a2617..1ef8feb 100644 --- a/apps/api/src/services/payment.ts +++ b/apps/api/src/services/payment.ts @@ -43,11 +43,13 @@ export async function createPaymentIntent( const db = getDb(); const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds]; + const firstInvoiceId = invoiceIds[0]; + if (!firstInvoiceId) return null; const invoiceRows = await db .select() .from(invoices) - .where(eq(invoices.id, invoiceIds[0])); + .where(eq(invoices.id, firstInvoiceId)); const [invoice] = invoiceRows; if (!invoice) return null; @@ -57,7 +59,7 @@ export async function createPaymentIntent( const allInvoices = await db .select({ totalCents: invoices.totalCents }) .from(invoices) - .where(eq(invoices.id, invoiceIds[0])); + .where(eq(invoices.id, firstInvoiceId)); totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents); } @@ -82,10 +84,10 @@ export async function createPaymentIntent( .where(eq(invoices.id, invId)); } - return { - clientSecret: paymentIntent.client_secret!, - paymentIntentId: paymentIntent.id, - }; + const clientSecret = paymentIntent.client_secret; + if (!clientSecret) return null; + + return { clientSecret, paymentIntentId: paymentIntent.id }; } export async function processRefund( diff --git a/packages/db/migrations/0026_stripe_payment.sql b/packages/db/migrations/0026_stripe_payment.sql new file mode 100644 index 0000000..8f48557 --- /dev/null +++ b/packages/db/migrations/0026_stripe_payment.sql @@ -0,0 +1,6 @@ +ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text; +ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id"); +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"); diff --git a/packages/db/migrations/0027_stripe_identifiers.sql b/packages/db/migrations/0027_stripe_identifiers.sql deleted file mode 100644 index 1c863d9..0000000 --- a/packages/db/migrations/0027_stripe_identifiers.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint -ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");--> statement-breakpoint -ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint -ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;--> statement-breakpoint -ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;--> statement-breakpoint -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/migrations/meta/0027_snapshot.json b/packages/db/migrations/meta/0026_snapshot.json similarity index 99% rename from packages/db/migrations/meta/0027_snapshot.json rename to packages/db/migrations/meta/0026_snapshot.json index 5330cf6..6e0ad37 100644 --- a/packages/db/migrations/meta/0027_snapshot.json +++ b/packages/db/migrations/meta/0026_snapshot.json @@ -1,5 +1,5 @@ { - "id": "0027_stripe_identifiers", + "id": "0026_stripe_payment", "version": "7", "dialect": "postgresql", "tables": { diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 7ca1dc5..96da64a 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1775482467192, "tag": "0025_rate_limit", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1775568867192, + "tag": "0026_stripe_payment", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index ca7cf4b..530baf8 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -71,6 +71,7 @@ export function buildClient(overrides: Partial = {}): ClientRow { address: "1 Main St, Springfield, CA 90000", notes: null, emailOptOut: false, + stripeCustomerId: null, status: "active", disabledAt: null, createdAt: new Date("2025-01-01T00:00:00Z"), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9698b52..a5d6bfc 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -109,8 +109,8 @@ export const clients = pgTable("clients", { phone: text("phone"), address: text("address"), notes: text("notes"), - // Set to true if the client has opted out of email reminders/notifications emailOptOut: boolean("email_opt_out").notNull().default(false), + stripeCustomerId: text("stripe_customer_id"), status: clientStatusEnum("status").notNull().default("active"), disabledAt: timestamp("disabled_at"), createdAt: timestamp("created_at").notNull().defaultNow(), @@ -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), + index("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: