diff --git a/0 b/0 new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/package.json b/apps/api/package.json index 55c1c9d..cf89cfe 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", + "telnyx": "^6.41.0", "zod": "^4.3.6" }, "devDependencies": { 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/clients.ts b/apps/api/src/routes/clients.ts index fe639c5..cda5bef 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -4,12 +4,35 @@ import { z } from "zod/v3"; import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; +function normalizeE164(phone: string): string | null { + const digits = phone.replace(/\D/g, ""); + if (digits.length === 10) return `+1${digits}`; + if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`; + if (digits.length > 11 && digits.startsWith("1")) return `+${digits.slice(0, 11)}`; + return null; +} + +function e164String() { + return z.string().transform((v, ctx) => { + if (!v) return v as unknown as undefined; + const normalized = normalizeE164(v); + if (!normalized) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid phone number. Must be a valid E.164 number (e.g. +12125551234).", + }); + return z.NEVER; + } + return normalized; + }); +} + export const clientsRouter = new Hono(); const createClientSchema = z.object({ name: z.string().min(1).max(200), email: z.string().email().optional(), - phone: z.string().max(50).optional(), + phone: e164String().optional(), address: z.string().max(500).optional(), notes: z.string().max(2000).optional(), }); 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..89f4a5c 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 })), }))); }); @@ -448,6 +454,144 @@ portalRouter.delete("/waitlist/:id", async (c) => { return c.json({ ok: true }); }); +// ─── Payment routes ─────────────────────────────────────────────────────────── + +import { + createPaymentIntent, + listPaymentMethods, + 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 new file mode 100644 index 0000000..b3a2617 --- /dev/null +++ b/apps/api/src/services/payment.ts @@ -0,0 +1,162 @@ +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; +} + +export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> { + 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/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 442b6c3..795b071 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -18,9 +18,10 @@ import { buildReminderEmail, sendEmail, } from "./email.js"; +import { smsSend } from "./sms.js"; -// How many hours before the appointment to send each reminder. -// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2). +// TCPA-required opt-out text appended to every SMS reminder +const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply."; function getReminderWindows(): { label: string; hours: number }[] { const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); @@ -65,23 +66,39 @@ export async function runReminderCheck(): Promise { ); for (const appt of upcoming) { - // Check if reminder already sent (unique constraint prevents double-send) - const existing = await db + const [emailLog] = await db .select({ id: reminderLogs.id }) .from(reminderLogs) .where( and( eq(reminderLogs.appointmentId, appt.id), - eq(reminderLogs.reminderType, window.label) + eq(reminderLogs.reminderType, window.label), + eq(reminderLogs.channel, "email") ) ) .limit(1); - if (existing.length > 0) continue; // already sent + const [smsLog] = await db + .select({ id: reminderLogs.id }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.appointmentId, appt.id), + eq(reminderLogs.reminderType, window.label), + eq(reminderLogs.channel, "sms") + ) + ) + .limit(1); // Fetch related records for the email const [client] = await db - .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .select({ + name: clients.name, + email: clients.email, + emailOptOut: clients.emailOptOut, + smsOptIn: clients.smsOptIn, + phoneE164: clients.phoneE164, + }) .from(clients) .where(eq(clients.id, appt.clientId)) .limit(1); @@ -112,8 +129,6 @@ export async function runReminderCheck(): Promise { if (!pet || !service) continue; - // Ensure the appointment has a confirmation token before sending the reminder. - // Generate one if it doesn't have one yet (e.g. pre-existing appointments). let confirmationToken = appt.confirmationToken; if (!confirmationToken) { confirmationToken = randomBytes(32).toString("hex"); @@ -123,27 +138,53 @@ export async function runReminderCheck(): Promise { .where(eq(appointments.id, appt.id)); } - const sent = await sendEmail( - buildReminderEmail( - client.email, - { - clientName: client.name, - petName: pet.name, - serviceName: service.name, - groomerName, - startTime: appt.startTime, - }, - window.hours, - confirmationToken - ) - ); + if (!emailLog) { + const sent = await sendEmail( + buildReminderEmail( + client.email, + { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + groomerName, + startTime: appt.startTime, + }, + window.hours, + confirmationToken + ) + ); - if (sent) { - // Record send — ignore conflicts (race condition between instances) - await db - .insert(reminderLogs) - .values({ appointmentId: appt.id, reminderType: window.label }) - .onConflictDoNothing(); + if (sent) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" }) + .onConflictDoNothing(); + } + } + + if (!smsLog && client.smsOptIn && client.phoneE164) { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; + const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; + const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; + const smsBody = [ + `Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`, + `Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`, + `Confirm: ${confirmUrl}`, + `Cancel: ${cancelUrl}`, + TCPA_OPT_OUT, + ].join(". "); + try { + const smsOk = await smsSend(client.phoneE164, smsBody); + if (smsOk) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" }) + .onConflictDoNothing(); + } + } catch (err) { + console.error("[reminders] SMS send failed:", err); + } } } } diff --git a/apps/api/src/services/sms.ts b/apps/api/src/services/sms.ts new file mode 100644 index 0000000..969667c --- /dev/null +++ b/apps/api/src/services/sms.ts @@ -0,0 +1,140 @@ +import { Telnyx } from "telnyx"; + +export interface SmsProvider { + sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>; + validateWebhookSignature(req: Request): boolean; +} + +interface TelnyxSmsResult { + message_id: string; + status: string; +} + +function createTelnyxClient(): Telnyx | null { + const apiKey = process.env.TELNYX_API_KEY; + if (!apiKey) return null; + return new Telnyx(apiKey); +} + +let _client: Telnyx | null | undefined; + +function getClient(): Telnyx | null { + if (_client === undefined) _client = createTelnyxClient(); + return _client; +} + +function getFromNumber(): string | null { + return process.env.TELNYX_FROM_NUMBER ?? null; +} + +function isE164(phone: string): boolean { + return /^\+[1-9]\d{7,14}$/.test(phone); +} + +export async function sendSms( + to: string, + body: string, + mediaUrls?: string[] +): Promise<{ messageId: string; status: string }> { + const client = getClient(); + if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY."); + + const from = getFromNumber(); + if (!from) throw new Error("TELNYX_FROM_NUMBER is not set"); + + if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`); + if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`); + + const payload: Record = { + from, + to, + body, + }; + + if (mediaUrls && mediaUrls.length > 0) { + payload.media_urls = mediaUrls; + } + + const result = await client.messages.create(payload as Record); + const smsResult = result.data as unknown as TelnyxSmsResult; + return { + messageId: smsResult.message_id, + status: smsResult.status, + }; +} + +export class TelnyxProvider implements SmsProvider { + async sendSms( + to: string, + body: string, + mediaUrls?: string[] + ): Promise<{ messageId: string; status: string }> { + return sendSms(to, body, mediaUrls); + } + + validateWebhookSignature(req: Request): boolean { + const secret = process.env.TELNYX_WEBHOOK_SECRET; + if (!secret) return false; + + const signature = req.headers.get("telnyx-signature"); + if (!signature) return false; + + const payload = JSON.stringify(req.body); + + try { + const { createHmac } = await import("crypto"); + const hmac = createHmac("sha256", secret); + const expected = `sha256=${hmac.update(payload).digest("hex")}`; + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + + if (sigBuf.length !== expBuf.length) return false; + + let diff = 0; + for (let i = 0; i < sigBuf.length; i++) { + diff |= sigBuf[i] ^ expBuf[i]; + } + return diff === 0; + } catch { + return false; + } + } +} + +let _provider: SmsProvider | null | undefined; + +export function createSmsProvider(): SmsProvider | null { + if (_provider === undefined) { + if (process.env.SMS_ENABLED !== "true") { + _provider = null; + return null; + } + switch (process.env.SMS_PROVIDER) { + case "telnyx": { + const client = getClient(); + if (!client) { + _provider = null; + return null; + } + _provider = new TelnyxProvider(); + break; + } + default: + _provider = null; + } + } + return _provider; +} + +export async function smsSend( + to: string, + body: string, + mediaUrls?: string[] +): Promise { + const provider = createSmsProvider(); + if (!provider) return false; + + await provider.sendSms(to, body, mediaUrls); + return true; +} \ No newline at end of file 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/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index e0b3b97..0fc2921 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 } 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] = 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,123 @@ 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: { + return_url: `${window.location.origin}/portal/billing`, + ...(saveCard ? { setup_future_usage: "off_session" } : {}), + }, + }); + + 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 +431,7 @@ function PaymentModal({

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

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

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