Compare commits

...

6 Commits

Author SHA1 Message Date
Paperclip d87f0f9608 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 <noreply@paperclip.ing>
2026-04-14 08:12:05 +00:00
Paperclip 6f9e6e7153 fix(GRO-607): remove unused eslint-disable directive in CustomerPortal
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 07:51:38 +00:00
Paperclip dc947874ca fix(GRO-607): Stripe Elements payment UI - lint/type fixes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 20:00:18 +00:00
Paperclip 78b71cca58 GRO-607: Replace mock payment flow with real Stripe Elements
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:55:49 +00:00
Paperclip 5456637705 GRO-607: Add /portal/config endpoint + rename date field
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:55:24 +00:00
Paperclip 3037b77fe8 GRO-607: Install Stripe frontend packages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:55:13 +00:00
8 changed files with 274 additions and 152 deletions
+20 -46
View File
@@ -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 })),
})));
});
@@ -456,45 +462,9 @@ import {
detachPaymentMethod,
createSetupIntent,
getOrCreateStripeCustomer,
getStripeClient,
} 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),
});
@@ -574,19 +544,23 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
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 });
});
// ─── 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.
+8 -4
View File
@@ -1,12 +1,13 @@
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 secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return c.json({ error: "Webhook secret not configured" }, 503);
}
@@ -22,11 +23,14 @@ webhooksRouter.post("/stripe", async (c) => {
return c.json({ error: "Could not read body" }, 400);
}
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
const stripe = getStripeClient();
if (!stripe) {
return c.json({ error: "Stripe not configured" }, 503);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return c.json({ error: message }, 401);
+4 -4
View File
@@ -1,9 +1,9 @@
import Stripe from "stripe";
import { getDb, clients, eq, invoices } from "@groombook/db";
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
let _stripe: Stripe | null | undefined;
function getStripeClient(): Stripe | null {
export function getStripeClient(): Stripe | null {
if (_stripe === undefined) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) return null;
@@ -59,8 +59,8 @@ export async function createPaymentIntent(
const allInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(eq(invoices.id, firstInvoiceId));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
.where(inArray(invoices.id, invoiceIds));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
}
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
+2
View File
@@ -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",
-1
View File
@@ -226,7 +226,6 @@ export function CustomerPortal() {
)}
{showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
+185 -96
View File
@@ -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<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [publishableKey, setPublishableKey] = useState<string>("");
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 (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded w-1/3" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
</div>
</div>
);
@@ -100,7 +112,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalPending > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
@@ -110,16 +121,15 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
</p>
</div>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
@@ -141,7 +151,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
))}
</div>
{/* Invoices */}
{tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
@@ -152,7 +161,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
<th className="px-5 py-3 font-medium">Description</th>
<th className="px-5 py-3 font-medium">Amount</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium"></th>
<th className="px-5 py-3 font-medium" />
</tr>
</thead>
<tbody>
@@ -160,9 +169,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
month: "short", day: "numeric", year: "numeric",
})}
</td>
<td className="px-5 py-3 text-stone-600">
@@ -201,7 +208,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Payment Methods */}
{tab === "payment" && (
<div className="space-y-4">
{paymentMethods.length === 0 ? (
@@ -210,7 +216,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
<div className="space-y-3">
{paymentMethods.map((method) => (
<div
key={`${method.brand}-${method.last4}`}
key={method.id}
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
>
<div className="flex items-center gap-3">
@@ -223,7 +229,18 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</span>
</div>
{!readOnly && (
<button className="text-sm text-blue-600 hover:underline">
<button
onClick={async () => {
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
});
if (res.ok) {
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
}
}}
className="text-sm text-blue-600 hover:underline"
>
Remove
</button>
)}
@@ -232,7 +249,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Autopay */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -241,9 +257,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
<div>
<p className="text-sm font-medium text-stone-800">Autopay</p>
<p className="text-xs text-stone-500">
Automatically charge after each appointment
</p>
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
</div>
</div>
{!readOnly ? (
@@ -269,17 +283,13 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Packages */}
{tab === "packages" && (
<div className="space-y-4">
{packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p>
) : (
packages.map((pkg, index) => (
<div
key={index}
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
>
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-stone-800">{pkg.name}</span>
<span className="text-stone-600">{pkg.remaining} remaining</span>
@@ -290,59 +300,123 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Payment Modal */}
{showPaymentModal && (
<PaymentModal
{showPaymentModal && publishableKey && (
<PaymentModalWrapper
key={Date.now()}
sessionId={sessionId ?? ""}
publishableKey={publishableKey}
pending={pending}
totalPending={totalPending}
onClose={() => setShowPaymentModal(false)}
onSuccess={() => {
setInvoices((prev) =>
prev.map((inv) =>
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
)
);
setShowPaymentModal(false);
}}
/>
)}
</div>
);
}
function PaymentModal({
pending,
totalPending: _totalPending,
onClose,
}: {
interface PaymentModalWrapperProps {
sessionId: string;
publishableKey: string;
pending: Invoice[];
totalPending: number;
onClose: () => void;
}) {
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
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 (
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
</Elements>
);
}
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<Set<string>>(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<string | null>(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({
<p className="text-stone-500 text-sm mb-6">
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
</p>
<button
onClick={onClose}
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
>
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
Done
</button>
</div>
@@ -408,22 +479,36 @@ function PaymentModal({
</p>
</div>
</div>
<span className="text-sm font-medium text-stone-800">
{formatCents(inv.totalCents)}
</span>
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
</label>
))}
</div>
<div className="border-t border-stone-200 pt-4 mb-6">
<div className="flex justify-between items-center">
<div className="flex justify-between items-center mb-4">
<span className="text-sm text-stone-600">Total</span>
<span className="text-lg font-bold text-stone-800">
{formatCents(selectedTotal)}
</span>
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
</div>
<PaymentElement />
</div>
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={saveCard}
onChange={(e) => setSaveCard(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
/>
<span className="text-sm text-stone-600">Save card for future payments</span>
</label>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-3">
<button
onClick={onClose}
@@ -433,7 +518,7 @@ function PaymentModal({
</button>
<button
onClick={handlePay}
disabled={selectedInvoices.size === 0 || isProcessing}
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? "Processing..." : "Pay Now"}
@@ -444,4 +529,8 @@ function PaymentModal({
);
}
export function BillingPayments(props: BillingPaymentsProps) {
return <BillingPaymentsInner {...props} />;
}
export default BillingPayments;
+1 -1
Submodule infra updated: d6c0d13d02...b667a3f005
+54
View File
@@ -86,6 +86,12 @@ importers:
'@groombook/types':
specifier: workspace:*
version: link:../../packages/types
'@stripe/react-stripe-js':
specifier: ^6.1.0
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@stripe/stripe-js':
specifier: ^9.1.0
version: 9.1.0
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
@@ -2112,6 +2118,17 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/react-stripe-js@6.1.0':
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
peerDependencies:
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.1.0':
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
engines: {node: '>=12.16'}
'@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -3611,6 +3628,10 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3702,6 +3723,10 @@ packages:
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -3819,6 +3844,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3831,6 +3859,9 @@ packages:
peerDependencies:
react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -6683,6 +6714,15 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.1.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.1.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies:
ejs: 3.1.10
@@ -8237,6 +8277,10 @@ snapshots:
lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@10.4.3: {}
@@ -8311,6 +8355,8 @@ snapshots:
nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -8415,6 +8461,12 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
punycode@2.3.1: {}
randombytes@2.1.0:
@@ -8426,6 +8478,8 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-is@16.13.1: {}
react-is@17.0.2: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):