From 00619a07e0a12488fde24b7ebfa9307122d40f6c Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sun, 12 Apr 2026 23:46:09 +0000 Subject: [PATCH] GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds) Portal routes (client-facing): - POST /api/portal/invoices/:id/pay - create PaymentIntent for single invoice - POST /api/portal/invoices/pay-multiple - create PaymentIntent for multiple invoices - GET /api/portal/payment-methods - list saved payment methods - POST /api/portal/payment-methods - create SetupIntent for saving new card - DELETE /api/portal/payment-methods/:id - detach payment method - GET /api/portal/config - return Stripe publishable key Admin routes: - POST /api/invoices/:id/refund - manager-only refund endpoint Validation: - Cannot pay draft, void, or already-paid invoices - Multi-invoice: all must belong to same client and be pending - Refund requires invoice to be paid with stripePaymentIntentId Co-Authored-By: Paperclip --- apps/api/src/routes/admin/sms.ts | 51 +++++++ 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 + packages/db/migrations/0028_sms_enabled.sql | 1 + .../db/migrations/meta/0027_snapshot.json | 103 +++++++++++++ 8 files changed, 487 insertions(+) create mode 100644 apps/api/src/routes/admin/sms.ts 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/0028_sms_enabled.sql create mode 100644 packages/db/migrations/meta/0027_snapshot.json diff --git a/apps/api/src/routes/admin/sms.ts b/apps/api/src/routes/admin/sms.ts new file mode 100644 index 0000000..e79fc30 --- /dev/null +++ b/apps/api/src/routes/admin/sms.ts @@ -0,0 +1,51 @@ +import { Hono } from "hono"; +import { getDb, businessSettings, reminderLogs, eq, sql, and, gte, lt } from "@groombook/db"; +import { requireRole } from "../middleware/rbac.js"; +import { createSmsProvider } from "../services/sms.js"; + +export const adminSmsRouter = new Hono(); + +adminSmsRouter.get("/status", requireManager(), async (c) => { + const db = getDb(); + + const [settings] = await db.select().from(businessSettings).limit(1); + + const provider = createSmsProvider(); + const smsEnabled = process.env.SMS_ENABLED === "true"; + const providerName = process.env.SMS_PROVIDER ?? "none"; + const fromNumber = process.env.TELNYX_FROM_NUMBER ?? null; + const connectionStatus = provider ? "connected" : "disconnected"; + + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const statsRows = await db + .select({ + status: reminderLogs.deliveryStatus, + count: sql`count(*)::int`, + }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.channel, "sms"), + gte(reminderLogs.sentAt, startOfMonth) + ) + ) + .groupBy(reminderLogs.deliveryStatus); + + const totals = { sent: 0, delivered: 0, failed: 0 }; + for (const row of statsRows) { + if (row.status === "delivered") totals.delivered = row.count; + else if (row.status === "failed") totals.failed = row.count; + else totals.sent += row.count; + } + + return c.json({ + providerName, + fromNumber, + connectionStatus, + smsEnabled, + businessSmsEnabled: settings?.smsEnabled ?? false, + stats: totals, + }); +}); \ No newline at end of file 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/0028_sms_enabled.sql b/packages/db/migrations/0028_sms_enabled.sql new file mode 100644 index 0000000..3bc37ad --- /dev/null +++ b/packages/db/migrations/0028_sms_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE "business_settings" ADD COLUMN "sms_enabled" boolean NOT NULL DEFAULT false; \ 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