From 0d73532054c6cd7434992a65a5c530fdfa14da7e Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sun, 12 Apr 2026 23:48:23 +0000 Subject: [PATCH] GRO-607: Replace mock payment flow with real Stripe Elements - Install @stripe/stripe-js and @stripe/react-stripe-js - Replace BillingPayments mock delay with real Stripe Elements: - Fetch publishableKey from GET /api/portal/config - Lazy load Stripe via loadStripe() - Wrap payment modal in with PaymentElement - Use stripe.confirmPayment() with clientSecret from pay/pay-multiple endpoints - Support multi-invoice selection and single invoice payment - Add "Save card for future payments" checkbox (setup_future_usage) - Add payment method management: list saved cards, delete via DELETE endpoint - Proper error handling for payment failures - Autopay toggle (UI-only, Phase 2 backend pending) Co-Authored-By: Paperclip --- apps/web/package.json | 2 + .../src/portal/sections/BillingPayments.tsx | 278 ++++++++++++------ pnpm-lock.yaml | 70 +++++ 3 files changed, 254 insertions(+), 96 deletions(-) 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..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} +
+ )} +