From c438f5772c30049fb114acec684c49bb08aa4362 Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" <269737991+groombook-cto[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:27:03 +0000 Subject: [PATCH] feat(GRO-607): Stripe Elements payment UI replacing mock flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GRO-605: Stripe SDK integration + payment service Co-Authored-By: Paperclip * GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds) Co-Authored-By: Paperclip * feat(GRO-597): Stripe payment backend — schema, service, API, webhooks 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 * GRO-607: Install Stripe frontend packages Co-Authored-By: Paperclip * GRO-607: Add /portal/config endpoint + rename date field Co-Authored-By: Paperclip * GRO-607: Replace mock payment flow with real Stripe Elements Co-Authored-By: Paperclip * fix(GRO-607): Stripe Elements payment UI - lint/type fixes Co-Authored-By: Paperclip * fix(GRO-607): remove unused eslint-disable directive in CustomerPortal Co-Authored-By: Paperclip * fix(GRO-607): CTO review fixes — payment security and correctness - Fix multi-invoice total calculation: use inArray() instead of eq() on single ID, sum all invoices not just first - Add ownership check to payment method deletion: verify the payment method belongs to the authenticated Stripe customer before detaching - Remove duplicate /config endpoint in portal.ts - Fix webhook Stripe client: use getStripeClient() from payment service instead of constructing with WEBHOOK_SECRET - Remove unnecessary body validator on /invoices/:id/pay route - Export getStripeClient() for use by stripe-webhooks.ts - Add inArray import to payment.ts Co-Authored-By: Paperclip --------- Co-authored-by: Paperclip --- apps/api/package.json | 2 + apps/api/src/index.ts | 4 + apps/api/src/routes/invoices.ts | 41 ++- apps/api/src/routes/portal.ts | 115 ++++++- apps/api/src/routes/stripe-webhooks.ts | 112 +++++++ apps/api/src/services/payment.ts | 164 ++++++++++ apps/web/package.json | 2 + apps/web/src/portal/CustomerPortal.tsx | 1 - .../src/portal/sections/BillingPayments.tsx | 281 ++++++++++++------ infra | 2 +- .../db/migrations/0026_stripe_payment.sql | 6 + .../db/migrations/meta/0026_snapshot.json | 103 +++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 6 +- pnpm-lock.yaml | 70 +++++ 16 files changed, 816 insertions(+), 101 deletions(-) create mode 100644 apps/api/src/routes/stripe-webhooks.ts create mode 100644 apps/api/src/services/payment.ts create mode 100644 packages/db/migrations/0026_stripe_payment.sql create mode 100644 packages/db/migrations/meta/0026_snapshot.json 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/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 e3f256e..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(), @@ -338,3 +339,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..8b10b56 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,113 @@ portalRouter.delete("/waitlist/:id", async (c) => { return c.json({ ok: true }); }); +// ─── Payment routes ─────────────────────────────────────────────────────────── + +import { + createPaymentIntent, + listPaymentMethods, + detachPaymentMethod, + createSetupIntent, + getOrCreateStripeCustomer, + getStripeClient, +} from "../services/payment.js"; + +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]; + 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); + } + + 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 stripeCustomerId = await getOrCreateStripeCustomer(clientId); + if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404); + + const stripe = getStripeClient(); + if (!stripe) return c.json({ error: "Payment service unavailable" }, 503); + + const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); + if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) { + return c.json({ error: "Payment method not found" }, 404); + } + + const ok = await detachPaymentMethod(paymentMethodId); + if (!ok) return c.json({ error: "Failed to detach payment method" }, 500); + return c.json({ ok: true }); +}); + // ─── 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/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts new file mode 100644 index 0000000..4a948a1 --- /dev/null +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -0,0 +1,112 @@ +import { Hono } from "hono"; +import Stripe from "stripe"; +import { eq, getDb, invoices } from "@groombook/db"; +import { getStripeClient } from "../services/payment.js"; + +export const webhooksRouter = new Hono(); + +webhooksRouter.post("/stripe", async (c) => { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + 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 = getStripeClient(); + if (!stripe) { + return c.json({ error: "Stripe not configured" }, 503); + } + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); + } 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/services/payment.ts b/apps/api/src/services/payment.ts new file mode 100644 index 0000000..d09d1db --- /dev/null +++ b/apps/api/src/services/payment.ts @@ -0,0 +1,164 @@ +import Stripe from "stripe"; +import { getDb, clients, eq, inArray, invoices } from "@groombook/db"; + +let _stripe: Stripe | null | undefined; + +export 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 firstInvoiceId = invoiceIds[0]; + if (!firstInvoiceId) return null; + + const invoiceRows = await db + .select() + .from(invoices) + .where(eq(invoices.id, firstInvoiceId)); + + 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(inArray(invoices.id, invoiceIds)); + totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0); + } + + 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)); + } + + const clientSecret = paymentIntent.client_secret; + if (!clientSecret) return null; + + return { clientSecret, 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/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/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 5bc454e..a33f51f 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -226,7 +226,6 @@ export function CustomerPortal() { )} {showReschedule && rescheduleAppointment && ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any { setShowReschedule(false); setRescheduleAppointment(null); }} diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index e0b3b97..6bcfb17 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: saveCard + ? { setup_future_usage: "off_session" } + : undefined, + redirect: "if_required", + }); + + 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} +
+ )} +