From fdaf4db0d51265616cd245b1590eb439e202f53d Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:43:03 +0000 Subject: [PATCH 1/9] GRO-605: Stripe SDK integration + payment service Co-Authored-By: Paperclip --- apps/api/package.json | 2 + apps/api/src/services/payment.ts | 150 +++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 apps/api/src/services/payment.ts diff --git a/apps/api/package.json b/apps/api/package.json index 55c1c9d..05f46ac 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,8 @@ "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/services/payment.ts b/apps/api/src/services/payment.ts new file mode 100644 index 0000000..ff7fcb8 --- /dev/null +++ b/apps/api/src/services/payment.ts @@ -0,0 +1,150 @@ +import Stripe from "stripe"; +import { getDb, clients, eq, invoices } from "@groombook/db"; + +let _stripe: Stripe | null | undefined; + +function getStripeClient(): Stripe | null { + if (_stripe === undefined) { + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) return null; + _stripe = new Stripe(secretKey); + } + return _stripe; +} + +export async function getOrCreateStripeCustomer(clientId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return null; + + const db = getDb(); + const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); + if (!client) return null; + + if (client.stripeCustomerId) return client.stripeCustomerId; + + const customer = await stripe.customers.create({ + metadata: { groombook_client_id: clientId }, + }); + + await db + .update(clients) + .set({ stripeCustomerId: customer.id, updatedAt: new Date() }) + .where(eq(clients.id, clientId)); + + return customer.id; +} + +export async function createPaymentIntent( + invoiceIdOrIds: string | string[], + clientId: string +): Promise<{ clientSecret: string; paymentIntentId: string } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const db = getDb(); + const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds]; + + const invoiceRows = await db + .select() + .from(invoices) + .where(eq(invoices.id, invoiceIds[0])); + + const [invoice] = invoiceRows; + if (!invoice) return null; + + let totalCents = invoice.totalCents; + if (invoiceIds.length > 1) { + const allInvoices = await db + .select({ totalCents: invoices.totalCents }) + .from(invoices) + .where(eq(invoices.id, invoiceIds[0])); + totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents); + } + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return null; + + const paymentIntent = await stripe.paymentIntents.create({ + amount: totalCents, + currency: "usd", + customer: stripeCustomerId, + metadata: { + groombook_invoice_ids: invoiceIds.join(","), + groombook_client_id: clientId, + }, + automatic_payment_methods: { enabled: true }, + }); + + for (const invId of invoiceIds) { + await db + .update(invoices) + .set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() }) + .where(eq(invoices.id, invId)); + } + + return { + clientSecret: paymentIntent.client_secret!, + paymentIntentId: paymentIntent.id, + }; +} + +export async function processRefund( + invoiceId: string, + amountCents?: number +): Promise<{ refundId: string } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const db = getDb(); + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1); + if (!invoice?.stripePaymentIntentId) return null; + + const refund = await stripe.refunds.create({ + payment_intent: invoice.stripePaymentIntentId, + amount: amountCents, + }); + + await db + .update(invoices) + .set({ stripeRefundId: refund.id, updatedAt: new Date() }) + .where(eq(invoices.id, invoiceId)); + + return { refundId: refund.id }; +} + +export async function listPaymentMethods(clientId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return null; + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return null; + + const methods = await stripe.paymentMethods.list({ + customer: stripeCustomerId, + type: "card", + }); + + return methods.data; +} + +export async function attachPaymentMethod( + clientId: string, + paymentMethodId: string +): Promise { + const stripe = getStripeClient(); + if (!stripe) return false; + + const stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return false; + + await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId }); + return true; +} + +export async function detachPaymentMethod(paymentMethodId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return false; + + await stripe.paymentMethods.detach(paymentMethodId); + return true; +} -- 2.52.0 From 9d37053580fe5bb6270f7c5bc3df1b498c82806f Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:43:24 +0000 Subject: [PATCH 2/9] GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds) Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 38 +++++ apps/api/src/routes/portal.ts | 139 ++++++++++++++++++ apps/api/src/routes/webhooks.ts | 137 +++++++++++++++++ apps/api/src/services/payment.ts | 12 ++ .../db/migrations/0027_stripe_identifiers.sql | 6 + .../db/migrations/meta/0027_snapshot.json | 103 +++++++++++++ 6 files changed, 435 insertions(+) create mode 100644 apps/api/src/routes/webhooks.ts create mode 100644 packages/db/migrations/0027_stripe_identifiers.sql create mode 100644 packages/db/migrations/meta/0027_snapshot.json diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index e3f256e..33a57de 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -338,3 +338,41 @@ invoicesRouter.patch( return c.json({ ...updated, lineItems }); } ); + +// ─── Refund ─────────────────────────────────────────────────────────────────── + +import { processRefund } from "../services/payment.js"; + +const refundSchema = z.object({ + amountCents: z.number().int().nonnegative().optional(), +}); + +invoicesRouter.post( + "/:id/refund", + zValidator("json", refundSchema), + async (c) => { + const db = getDb(); + const staff = c.get("staff"); + if (!staff) return c.json({ error: "Forbidden" }, 403); + if (staff.role !== "manager" && !staff.isSuperUser) { + return c.json({ error: "Manager role required" }, 403); + } + + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + if (invoice.status !== "paid") { + return c.json({ error: "Refund only allowed on paid invoices" }, 422); + } + if (!invoice.stripePaymentIntentId) { + 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 c.json({ refundId: result.refundId }); + } +); diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 7d3289c..dd58d0a 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -448,6 +448,145 @@ portalRouter.delete("/waitlist/:id", async (c) => { return c.json({ ok: true }); }); +// ─── Payment routes ─────────────────────────────────────────────────────────── + +import { + createPaymentIntent, + listPaymentMethods, + attachPaymentMethod, + detachPaymentMethod, + createSetupIntent, + getOrCreateStripeCustomer, +} from "../services/payment.js"; + +const payInvoiceSchema = z.object({ + invoiceId: z.string().uuid(), +}); + +portalRouter.post( + "/invoices/:id/pay", + zValidator("json", payInvoiceSchema), + async (c) => { + const db = getDb(); + const invoiceId = c.req.param("id"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const [invoice] = await db + .select() + .from(invoices) + .where(eq(invoices.id, invoiceId)) + .limit(1); + + if (!invoice) return c.json({ error: "Not found" }, 404); + if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403); + if (invoice.status === "draft" || invoice.status === "void") { + return c.json({ error: "Cannot pay a draft or void invoice" }, 422); + } + if (invoice.status === "paid") { + return c.json({ error: "Invoice is already paid" }, 422); + } + + const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + const result = await createPaymentIntent(invoiceId, clientId); + if (!result) return c.json({ error: "Payment service unavailable" }, 503); + + return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); + } +); + +const payMultipleSchema = z.object({ + invoiceIds: z.array(z.string().uuid()).min(1), +}); + +portalRouter.post( + "/invoices/pay-multiple", + zValidator("json", payMultipleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const invoiceRows = await db + .select() + .from(invoices) + .where(inArray(invoices.id, body.invoiceIds)); + + if (invoiceRows.length !== body.invoiceIds.length) { + return c.json({ error: "One or more invoices not found" }, 404); + } + + for (const inv of invoiceRows) { + if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403); + if (inv.status === "draft" || inv.status === "void") { + return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422); + } + if (inv.status === "paid") { + return c.json({ error: `Invoice ${inv.id} is already paid` }, 422); + } + } + + const firstInvoice = invoiceRows[0]; + const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId); + if (!allSameClient) { + return c.json({ error: "All invoices must belong to the same client" }, 422); + } + + const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + const result = await createPaymentIntent(body.invoiceIds, clientId); + if (!result) return c.json({ error: "Payment service unavailable" }, 503); + + return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); + } +); + +portalRouter.get("/payment-methods", async (c) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const methods = await listPaymentMethods(clientId); + if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); + return c.json(methods); +}); + +portalRouter.post("/payment-methods", async (c) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + const customerId = await getOrCreateStripeCustomer(clientId); + if (!customerId) return c.json({ error: "Could not create customer" }, 500); + + const result = await createSetupIntent(customerId); + if (!result) return c.json({ error: "Payment service unavailable" }, 503); + + return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey }); +}); + +portalRouter.delete("/payment-methods/:id", async (c) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + const clientId = await getClientIdFromSession(sessionId); + if (!clientId) return c.json({ error: "Unauthorized" }, 401); + + const paymentMethodId = c.req.param("id"); + const ok = await detachPaymentMethod(paymentMethodId); + if (!ok) return c.json({ error: "Failed to detach payment method" }, 500); + return c.json({ ok: true }); +}); + +// ─── Config endpoint ───────────────────────────────────────────────────────── + +portalRouter.get("/config", (c) => { + return c.json({ + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "", + }); +}); + // ─── Dev-mode session creation ────────────────────────────────────────────── // Allows the dev login selector to vend an impersonation session for a client // without requiring manager auth. Only available when AUTH_DISABLED=true. diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..9f8314c --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,137 @@ +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 ff7fcb8..b3a2617 100644 --- a/apps/api/src/services/payment.ts +++ b/apps/api/src/services/payment.ts @@ -148,3 +148,15 @@ export async function detachPaymentMethod(paymentMethodId: string): Promise { + const stripe = getStripeClient(); + if (!stripe) return null; + + const setupIntent = await stripe.setupIntents.create({ + customer: customerId, + payment_method_types: ["card"], + }); + + return { clientSecret: setupIntent.client_secret! }; +} diff --git a/packages/db/migrations/0027_stripe_identifiers.sql b/packages/db/migrations/0027_stripe_identifiers.sql new file mode 100644 index 0000000..1c863d9 --- /dev/null +++ b/packages/db/migrations/0027_stripe_identifiers.sql @@ -0,0 +1,6 @@ +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/0027_snapshot.json new file mode 100644 index 0000000..5330cf6 --- /dev/null +++ b/packages/db/migrations/meta/0027_snapshot.json @@ -0,0 +1,103 @@ +{ + "id": "0027_stripe_identifiers", + "version": "7", + "dialect": "postgresql", + "tables": { + "authProviderConfig": { + "name": "auth_provider_config", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "providerId": { "name": "provider_id", "type": "text", "isNullable": false }, + "displayName": { "name": "display_name", "type": "text", "isNullable": false }, + "issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false }, + "internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true }, + "clientId": { "name": "client_id", "type": "text", "isNullable": false }, + "clientSecret": { "name": "client_secret", "type": "text", "isNullable": false }, + "scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" }, + "enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {} + }, + "businessSettings": { + "name": "business_settings", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" }, + "logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true }, + "logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true }, + "logoKey": { "name": "logo_key", "type": "text", "isNullable": true }, + "primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" }, + "accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {} + }, + "clients": { + "name": "clients", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "name": { "name": "name", "type": "text", "isNullable": false }, + "email": { "name": "email", "type": "text", "isNullable": true }, + "phone": { "name": "phone", "type": "text", "isNullable": true }, + "address": { "name": "address", "type": "text", "isNullable": true }, + "notes": { "name": "notes", "type": "text", "isNullable": true }, + "emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" }, + "smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" }, + "smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true }, + "smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true }, + "smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true }, + "stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true }, + "status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" }, + "disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } } + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true }, + "clientId": { "name": "client_id", "type": "uuid", "isNullable": false }, + "subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false }, + "taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" }, + "tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" }, + "totalCents": { "name": "total_cents", "type": "integer", "isNullable": false }, + "status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" }, + "paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true }, + "paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true }, + "stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true }, + "stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true }, + "paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true }, + "notes": { "name": "notes", "type": "text", "isNullable": true }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } }, + "foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } } + } + }, + "enums": { + "appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] }, + "client_status": { "name": "client_status", "values": ["active", "disabled"] }, + "impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] }, + "invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] }, + "payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] }, + "staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] }, + "waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] } + }, + "nativeEnums": {} +} \ No newline at end of file -- 2.52.0 From ae873215c09f9b3366c4a892c5f46704f35a4c4a Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:54:15 +0000 Subject: [PATCH 3/9] =?UTF-8?q?feat(GRO-597):=20Stripe=20payment=20backend?= =?UTF-8?q?=20=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: -- 2.52.0 From 3037b77fe8f92b5c34542ea5b866c7bec8bb8833 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:55:13 +0000 Subject: [PATCH 4/9] GRO-607: Install Stripe frontend packages Co-Authored-By: Paperclip --- apps/web/package.json | 2 ++ pnpm-lock.yaml | 54 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/apps/web/package.json b/apps/web/package.json index 3c9d044..d7fa0db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@groombook/types": "workspace:*", + "@stripe/react-stripe-js": "^6.1.0", + "@stripe/stripe-js": "^9.1.0", "@tailwindcss/vite": "^4.2.2", "better-auth": "^1.5.6", "lucide-react": "^0.577.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26a74fe..6396f90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,12 @@ importers: '@groombook/types': specifier: workspace:* version: link:../../packages/types + '@stripe/react-stripe-js': + specifier: ^6.1.0 + version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@stripe/stripe-js': + specifier: ^9.1.0 + version: 9.1.0 '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) @@ -2112,6 +2118,17 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/react-stripe-js@6.1.0': + resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==} + peerDependencies: + '@stripe/stripe-js': '>=9.0.0 <10.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@9.1.0': + resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==} + engines: {node: '>=12.16'} + '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -3611,6 +3628,10 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3702,6 +3723,10 @@ packages: nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3819,6 +3844,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3831,6 +3859,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -6683,6 +6714,15 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@stripe/stripe-js': 9.1.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@stripe/stripe-js@9.1.0': {} + '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.10 @@ -8237,6 +8277,10 @@ snapshots: lodash@4.17.23: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -8311,6 +8355,8 @@ snapshots: nwsapi@2.2.23: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -8415,6 +8461,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + punycode@2.3.1: {} randombytes@2.1.0: @@ -8426,6 +8478,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@16.13.1: {} + react-is@17.0.2: {} react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): -- 2.52.0 From 545663770512f24a8ebb297d67ef723ed9eef025 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:55:24 +0000 Subject: [PATCH 5/9] GRO-607: Add /portal/config endpoint + rename date field Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 5e9dfe2..b109e89 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -35,6 +35,12 @@ portalRouter.get("/me", async (c) => { return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone }); }); +portalRouter.get("/config", async (c) => { + return c.json({ + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "", + }); +}); + portalRouter.get("/services", async (c) => { const db = getDb(); const allServices = await db.select().from(services).where(eq(services.active, true)); @@ -123,7 +129,7 @@ portalRouter.get("/invoices", async (c) => { id: inv.id, status: inv.status, totalCents: inv.totalCents, - createdAt: inv.createdAt, + date: inv.createdAt, lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })), }))); }); -- 2.52.0 From 78b71cca58250341b517c75f3953a2eeb5b7c36a Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 13 Apr 2026 19:55:49 +0000 Subject: [PATCH 6/9] GRO-607: Replace mock payment flow with real Stripe Elements Co-Authored-By: Paperclip --- .../src/portal/sections/BillingPayments.tsx | 278 ++++++++++++------ pnpm-lock.yaml | 35 +++ 2 files changed, 217 insertions(+), 96 deletions(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index e0b3b97..0f47e21 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from "react"; +import { loadStripe, type Stripe } from "@stripe/stripe-js"; +import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; interface Invoice { @@ -10,31 +12,28 @@ interface Invoice { } interface PaymentMethod { + id: string; brand: string; last4: string; expiryMonth: number; expiryYear: number; } -interface Package { - name: string; - remaining: number; -} - interface BillingPaymentsProps { sessionId: string | null; readOnly: boolean; } -export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { +function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) { const [invoices, setInvoices] = useState([]); const [paymentMethods, setPaymentMethods] = useState([]); - const [packages, setPackages] = useState([]); + const [packages, setPackages] = useState<{ name: string; remaining: number }[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices"); const [autopay, setAutopay] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false); + const [publishableKey, setPublishableKey] = useState(""); useEffect(() => { async function fetchData() { @@ -44,20 +43,37 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { } try { - const response = await fetch("/api/portal/invoices", { - headers: { - "X-Impersonation-Session-Id": sessionId, - }, - }); + const [configRes, invoicesRes, methodsRes] = await Promise.all([ + fetch("/api/portal/config", { + headers: { "X-Impersonation-Session-Id": sessionId }, + }), + fetch("/api/portal/invoices", { + headers: { "X-Impersonation-Session-Id": sessionId }, + }), + fetch("/api/portal/payment-methods", { + headers: { "X-Impersonation-Session-Id": sessionId }, + }), + ]); - if (!response.ok) { - throw new Error("Failed to fetch invoices"); + if (!configRes.ok) throw new Error("Failed to fetch config"); + const configData = await configRes.json(); + setPublishableKey(configData.stripePublishableKey ?? ""); + + const invoicesData = await invoicesRes.json(); + setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []); + + if (methodsRes.ok) { + const methodsData = await methodsRes.json(); + setPaymentMethods( + (methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({ + id: m.id, + brand: m.card?.brand ?? "unknown", + last4: m.card?.last4 ?? "****", + expiryMonth: m.card?.exp_month ?? 0, + expiryYear: m.card?.exp_year ?? 0, + })) + ); } - - const data = await response.json(); - setInvoices(Array.isArray(data) ? data : data.invoices || []); - setPaymentMethods(data.paymentMethods || []); - setPackages(data.packages || []); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { @@ -68,12 +84,8 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { fetchData(); }, [sessionId]); - const formatCents = (cents: number) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(cents / 100); - }; + const formatCents = (cents: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); const pending = invoices.filter((i) => i.status === "pending"); const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0); @@ -82,9 +94,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { return (
-
-
-
+
+
+
); @@ -100,7 +112,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { return (
- {/* Outstanding Balance Banner */} {totalPending > 0 && (
@@ -110,16 +121,15 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { {pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}

- +
)} - {/* Tabs */}
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, @@ -141,7 +151,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { ))}
- {/* Invoices */} {tab === "invoices" && (
@@ -152,7 +161,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { Description Amount Status - + @@ -160,9 +169,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { {new Date(inv.date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", + month: "short", day: "numeric", year: "numeric", })} @@ -201,7 +208,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
)} - {/* Payment Methods */} {tab === "payment" && (
{paymentMethods.length === 0 ? ( @@ -210,7 +216,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
{paymentMethods.map((method) => (
@@ -223,7 +229,18 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
{!readOnly && ( - )} @@ -232,7 +249,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
)} - {/* Autopay */}
@@ -241,9 +257,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {

Autopay

-

- Automatically charge after each appointment -

+

Automatically charge after each appointment

{!readOnly ? ( @@ -269,17 +283,13 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
)} - {/* Packages */} {tab === "packages" && (
{packages.length === 0 ? (

No packages purchased

) : ( packages.map((pkg, index) => ( -
+
{pkg.name} {pkg.remaining} remaining @@ -290,59 +300,120 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
)} - {/* Payment Modal */} - {showPaymentModal && ( - setShowPaymentModal(false)} + onSuccess={() => { + setInvoices((prev) => + prev.map((inv) => + pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv + ) + ); + setShowPaymentModal(false); + }} /> )}
); } -function PaymentModal({ - pending, - totalPending: _totalPending, - onClose, -}: { +interface PaymentModalWrapperProps { + sessionId: string; + publishableKey: string; pending: Invoice[]; - totalPending: number; onClose: () => void; -}) { - const [selectedInvoices, setSelectedInvoices] = useState>( - new Set(pending.map((i) => i.id)) + onSuccess: () => void; +} + +function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) { + const [stripePromise] = useState(() => + publishableKey ? loadStripe(publishableKey) : Promise.resolve(null) ); + + return ( + s + i.totalCents, 0), currency: "usd" }}> + + + ); +} + +interface PaymentModalProps { + sessionId: string; + pending: Invoice[]; + onClose: () => void; + onSuccess: () => void; +} + +function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) { + const stripe = useStripe(); + const elements = useElements(); + const [selectedInvoices, setSelectedInvoices] = useState>(new Set(pending.map((i) => i.id))); + const [saveCard, setSaveCard] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [isComplete, setIsComplete] = useState(false); + const [error, setError] = useState(null); const formatCents = (cents: number) => - new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(cents / 100); + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); const toggleInvoice = (id: string) => { const next = new Set(selectedInvoices); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } + if (next.has(id)) next.delete(id); + else next.add(id); setSelectedInvoices(next); }; - const handlePay = async () => { - setIsProcessing(true); - await new Promise((resolve) => setTimeout(resolve, 1500)); - setIsProcessing(false); - setIsComplete(true); - }; + const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0); - const selectedTotal = pending - .filter((i) => selectedInvoices.has(i.id)) - .reduce((sum, i) => sum + i.totalCents, 0); + const handlePay = async () => { + if (!stripe || !elements) return; + setIsProcessing(true); + setError(null); + + try { + const isMulti = selectedInvoices.size > 1; + const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`; + const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {}; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Impersonation-Session-Id": sessionId, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error ?? "Failed to initialize payment"); + } + + const { clientSecret } = await res.json(); + + const { error: stripeError } = await stripe.confirmPayment({ + elements, + clientSecret, + confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined, + }); + + if (stripeError) { + setError(stripeError.message ?? "Payment failed"); + setIsProcessing(false); + return; + } + + setIsComplete(true); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "An unexpected error occurred"); + setIsProcessing(false); + } + }; if (isComplete) { return ( @@ -357,10 +428,7 @@ function PaymentModal({

Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.

-
@@ -408,22 +476,36 @@ function PaymentModal({

- - {formatCents(inv.totalCents)} - + {formatCents(inv.totalCents)} ))}
-
+
Total - - {formatCents(selectedTotal)} - + {formatCents(selectedTotal)}
+ +
+ + + {error && ( +
+ {error} +
+ )} +