From 9ea4c84b5bd6cd58b97c38f96bfb92765aa7a379 Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Mon, 17 Mar 2025 12:53:33 -0700 Subject: [PATCH] add Stripe payment methods management for teams --- apps/dashboard/package.json | 2 + .../dashboard/src/@/actions/stripe-actions.ts | 179 +++++++++ apps/dashboard/src/@/lib/payment-methods.ts | 81 ++++ .../add-payment-method-form.client.tsx | 365 ++++++++++++++++++ .../payment-methods/payment-method-icon.tsx | 131 +++++++ .../payment-methods.client.tsx | 332 ++++++++++++++++ .../(team)/~/settings/billing/page.tsx | 13 +- .../settings/Account/Billing/index.tsx | 11 +- pnpm-lock.yaml | 58 ++- 9 files changed, 1153 insertions(+), 19 deletions(-) create mode 100644 apps/dashboard/src/@/lib/payment-methods.ts create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/add-payment-method-form.client.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-method-icon.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-methods.client.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6cc4658b675..3c55e861cda 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -47,6 +47,8 @@ "@radix-ui/react-tooltip": "1.1.8", "@sentry/nextjs": "9.5.0", "@shazow/whatsabi": "0.20.0", + "@stripe/react-stripe-js": "3.4.0", + "@stripe/stripe-js": "6.1.0", "@tanstack/react-query": "5.67.3", "@tanstack/react-table": "^8.21.2", "@thirdweb-dev/service-utils": "workspace:*", diff --git a/apps/dashboard/src/@/actions/stripe-actions.ts b/apps/dashboard/src/@/actions/stripe-actions.ts index ed88ae191b5..bafc5f3064e 100644 --- a/apps/dashboard/src/@/actions/stripe-actions.ts +++ b/apps/dashboard/src/@/actions/stripe-actions.ts @@ -1,3 +1,4 @@ +"use server"; import "server-only"; import Stripe from "stripe"; @@ -58,3 +59,181 @@ export async function getTeamInvoices( throw new Error("Failed to fetch billing history"); } } + +export async function getTeamPaymentMethods(team: Team) { + try { + const customerId = team.stripeCustomerId; + + if (!customerId) { + throw new Error("No customer ID found"); + } + + const [paymentMethods, customer] = await Promise.all([ + // Get all payment methods, not just cards + getStripe().paymentMethods.list({ + customer: customerId, + }), + // Get the customer to determine the default payment method + getStripe().customers.retrieve(customerId), + ]); + + const defaultPaymentMethodId = customer.deleted + ? null + : customer.invoice_settings?.default_payment_method; + + // Add isDefault flag to each payment method + return paymentMethods.data.map((method) => ({ + ...method, + isDefault: method.id === defaultPaymentMethodId, + })); + } catch (error) { + console.error("Error fetching payment methods:", error); + throw new Error("Failed to fetch payment methods"); + } +} + +export async function createSetupIntent(team: Team) { + try { + const customerId = team.stripeCustomerId; + + if (!customerId) { + throw new Error("No customer ID found"); + } + + const setupIntent = await getStripe().setupIntents.create({ + customer: customerId, + payment_method_types: ["card"], + }); + + return { + clientSecret: setupIntent.client_secret, + }; + } catch (error) { + console.error("Error creating setup intent:", error); + + throw new Error("Failed to create setup intent"); + } +} + +export async function addPaymentMethod( + team: Team, + paymentMethodId: string, + setAsDefault = false, +) { + try { + const customerId = team.stripeCustomerId; + + if (!customerId) { + throw new Error("No customer ID found"); + } + + // Attach the payment method to the customer + await getStripe().paymentMethods.attach(paymentMethodId, { + customer: customerId, + }); + + // Create a $5 payment intent to validate the card + const paymentIntent = await getStripe().paymentIntents.create({ + amount: 500, // $5.00 in cents + currency: "usd", + customer: customerId, + payment_method: paymentMethodId, + capture_method: "manual", // Authorize only, don't capture + confirm: true, // Confirm the payment immediately + description: "Card validation - temporary hold", + metadata: { + purpose: "card_validation", + }, + off_session: true, // Since this is a server-side operation + }); + + // If the payment intent succeeded, cancel it to release the hold + if (paymentIntent.status === "requires_capture") { + await getStripe().paymentIntents.cancel(paymentIntent.id, { + cancellation_reason: "requested_by_customer", + }); + console.log( + `Successfully validated card ${paymentMethodId} with temporary hold`, + ); + } else { + // If the payment intent didn't succeed, detach the payment method + await getStripe().paymentMethods.detach(paymentMethodId); + throw new Error(`Card validation failed: ${paymentIntent.status}`); + } + + // If setAsDefault is true, update the customer's default payment method + if (setAsDefault) { + await getStripe().customers.update(customerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + } + + return { success: true }; + } catch (error) { + console.error("Error adding payment method:", error); + + // Try to detach the payment method if it was attached + try { + if (paymentMethodId) { + await getStripe().paymentMethods.detach(paymentMethodId); + } + } catch (detachError) { + console.error( + "Error detaching payment method after validation failure:", + detachError, + ); + } + + // Determine the error message to return + let errorMessage = "Failed to add payment method"; + + if (error instanceof Stripe.errors.StripeCardError) { + errorMessage = error.message || "Your card was declined"; + } else if (error instanceof Stripe.errors.StripeInvalidRequestError) { + errorMessage = "Invalid card information"; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + throw new Error(errorMessage); + } +} + +export async function deletePaymentMethod(paymentMethodId: string) { + try { + // Detach the payment method from the customer + await getStripe().paymentMethods.detach(paymentMethodId); + + return { success: true }; + } catch (error) { + console.error("Error deleting payment method:", error); + throw new Error("Failed to delete payment method"); + } +} + +export async function setDefaultPaymentMethod( + team: Team, + paymentMethodId: string, +) { + try { + const customerId = team.stripeCustomerId; + + if (!customerId) { + throw new Error("No customer ID found"); + } + + // Update the customer's default payment method + await getStripe().customers.update(customerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + + return { success: true }; + } catch (error) { + console.error("Error setting default payment method:", error); + throw new Error("Failed to set default payment method"); + } +} diff --git a/apps/dashboard/src/@/lib/payment-methods.ts b/apps/dashboard/src/@/lib/payment-methods.ts new file mode 100644 index 00000000000..c6f31821fda --- /dev/null +++ b/apps/dashboard/src/@/lib/payment-methods.ts @@ -0,0 +1,81 @@ +import type Stripe from "stripe"; + +export type ExtendedPaymentMethod = Stripe.PaymentMethod & { + isDefault: boolean; +}; + +function formatExpiryDate(month: number, year: number): string { + // Format as "Valid until MM/YYYY" + return `Valid until ${month}/${year}`; +} + +function isExpiringSoon(month: number, year: number): boolean { + const today = new Date(); + const expiryDate = new Date(year, month - 1, 1); // First day of expiry month + const monthsDifference = + (expiryDate.getFullYear() - today.getFullYear()) * 12 + + (expiryDate.getMonth() - today.getMonth()); + return monthsDifference >= 0 && monthsDifference <= 2; // Within next 3 months +} + +function isExpired(month: number, year: number): boolean { + const today = new Date(); + const currentMonth = today.getMonth() + 1; // JavaScript months are 0-indexed + const currentYear = today.getFullYear(); + + if (year < currentYear || (year === currentYear && month < currentMonth)) { + return true; + } + + return false; +} + +export function formatPaymentMethodDetails(method: Stripe.PaymentMethod): { + label: string; + expiryInfo?: string; + isExpiringSoon?: boolean; + isExpired?: boolean; +} { + switch (method.type) { + case "card": { + if (!method.card) { + return { label: "Unknown card" }; + } + return { + label: `${method.card.brand} ${method.card.funding || ""} •••• ${method.card.last4}`, + expiryInfo: formatExpiryDate( + method.card.exp_month, + method.card.exp_year, + ), + isExpiringSoon: isExpiringSoon( + method.card.exp_month, + method.card.exp_year, + ), + isExpired: isExpired(method.card.exp_month, method.card.exp_year), + }; + } + + case "us_bank_account": { + if (!method.us_bank_account) { + return { label: "Unknown bank account" }; + } + return { + label: `${method.us_bank_account.bank_name} ${method.us_bank_account.account_type} •••• ${method.us_bank_account.last4}`, + }; + } + + case "sepa_debit": { + if (!method.sepa_debit) { + return { label: "Unknown SEPA account" }; + } + return { + label: `SEPA Direct Debit •••• ${method.sepa_debit.last4}`, + }; + } + + default: + return { + label: `${method.type.replace("_", " ")}`, + }; + } +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/add-payment-method-form.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/add-payment-method-form.client.tsx new file mode 100644 index 00000000000..8d6bce8a712 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/add-payment-method-form.client.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { addPaymentMethod } from "@/actions/stripe-actions"; +import type { Team } from "@/api/team"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AddressElement, + Elements, + PaymentElement, + useElements, + useStripe, +} from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; +import { AlertCircle, CheckCircle, CreditCard } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useState } from "react"; + +// Initialize Stripe +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "", +); + +interface PaymentMethodFormProps { + team: Team; + returnUrl?: string; + onSuccess?: (paymentMethodId: string) => void; + onError?: (error: Error) => void; + showBillingName?: boolean; + showBillingAddressOption?: boolean; + showDefaultOption?: boolean; + defaultIsDefault?: boolean; + buttonText?: string; + successMessage?: string; + showAuthorizationMessage?: boolean; + authorizationMessage?: string; + redirectOnSuccess?: boolean; + redirectDelay?: number; +} + +function PaymentMethodForm({ + team, + returnUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/billing`, + onSuccess, + onError, + showBillingName = true, + showBillingAddressOption = true, + showDefaultOption = true, + defaultIsDefault = false, + buttonText = "Add Payment Method", + successMessage = "Payment method successfully added!", + showAuthorizationMessage = true, + authorizationMessage = "A temporary $5 authorization hold will be placed on your card to verify it. This hold will be released immediately and you won't be charged.", + redirectOnSuccess = true, + redirectDelay = 1500, +}: PaymentMethodFormProps) { + const stripe = useStripe(); + const elements = useElements(); + const [error, setError] = useState(null); + const [cardComplete, setCardComplete] = useState(false); + const [processing, setProcessing] = useState(false); + const [succeeded, setSucceeded] = useState(false); + const [makeDefault, setMakeDefault] = useState(defaultIsDefault); + const [billingName, setBillingName] = useState(""); + const [showBillingAddress, setShowBillingAddress] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js has not loaded yet + return; + } + + if (!cardComplete) { + setError("Please complete your card details."); + return; + } + + setProcessing(true); + + try { + const { error: submitError, setupIntent } = await stripe.confirmSetup({ + elements, + redirect: "if_required", + confirmParams: { + return_url: returnUrl, + payment_method_data: { + billing_details: { name: billingName || undefined }, + }, + }, + }); + + if (submitError) { + const errorMessage = + submitError.message || + "An error occurred while processing your payment method."; + setError(errorMessage); + if (onError) onError(new Error(errorMessage)); + setProcessing(false); + return; + } + + if (setupIntent?.payment_method) { + try { + await addPaymentMethod( + team, + setupIntent.payment_method as string, + makeDefault, + ); + setSucceeded(true); + setError(null); + + if (onSuccess) onSuccess(setupIntent.payment_method as string); + + // Redirect or reload after success if configured + if (redirectOnSuccess) { + setTimeout(() => { + window.location.href = returnUrl; + }, redirectDelay); + } + } catch (err) { + let errorMessage = + "Failed to validate card. Please try a different card."; + if (err instanceof Error) { + errorMessage = err.message; + } + setError(errorMessage); + if (onError) onError(new Error(errorMessage)); + } + } + } catch (err) { + console.error("Error:", err); + const errorMessage = "An unexpected error occurred. Please try again."; + setError(errorMessage); + if (onError) onError(new Error(errorMessage)); + } finally { + setProcessing(false); + } + }; + + return ( +
+ {error && ( + + + {error} + + )} + + {succeeded && ( + + + + {successMessage} + + + )} + +
+ {showBillingName && ( +
+ + setBillingName(e.target.value)} + disabled={processing || succeeded} + /> +
+ )} + +
+ +
+ setCardComplete(e.complete)} + options={{ + defaultValues: { + billingDetails: { + name: billingName || undefined, + }, + }, + layout: { + type: "tabs", + defaultCollapsed: false, + }, + }} + /> +
+
+ + {showBillingAddressOption && ( +
+ + setShowBillingAddress(checked as boolean) + } + disabled={processing || succeeded} + /> + +
+ )} + + {showBillingAddress && ( +
+ +
+ +
+
+ )} + + {showDefaultOption && ( +
+ setMakeDefault(checked as boolean)} + disabled={processing || succeeded} + /> + +
+ )} + + + + {showAuthorizationMessage && ( +

+ {authorizationMessage} +

+ )} +
+
+ ); +} + +interface AddPaymentMethodFormProps { + team: Team; + clientSecret: string | null; + returnUrl?: string; + onSuccess?: (paymentMethodId: string) => void; + onError?: (error: Error) => void; + showBillingName?: boolean; + showBillingAddressOption?: boolean; + showDefaultOption?: boolean; + defaultIsDefault?: boolean; + buttonText?: string; + successMessage?: string; + showAuthorizationMessage?: boolean; + authorizationMessage?: string; + redirectOnSuccess?: boolean; + redirectDelay?: number; + className?: string; +} + +export function AddPaymentMethodForm({ + team, + clientSecret, + returnUrl, + onSuccess, + onError, + showBillingName, + showBillingAddressOption, + showDefaultOption, + defaultIsDefault, + buttonText, + successMessage, + showAuthorizationMessage, + authorizationMessage, + redirectOnSuccess, + redirectDelay, + className, +}: AddPaymentMethodFormProps) { + const { resolvedTheme } = useTheme(); + + // If no clientSecret is provided, show an error + if (!clientSecret) { + return ( + + + + Failed to initialize payment form. Please try again. + + + ); + } + + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-method-icon.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-method-icon.tsx new file mode 100644 index 00000000000..2ce12b8a6b8 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-method-icon.tsx @@ -0,0 +1,131 @@ +import { + BuildingIcon, + CreditCardIcon, + DollarSignIcon, + EuroIcon, + WalletIcon, +} from "lucide-react"; + +interface PaymentMethodIconProps { + type: string; + brand?: string; + className?: string; +} + +export function PaymentMethodIcon({ + type, + brand, + className = "h-6 w-6", +}: PaymentMethodIconProps) { + if (type === "card" && brand) { + // Return appropriate card brand icon + switch (brand.toLowerCase()) { + case "visa": + return ( + + Visa + + + + + ); + case "mastercard": + return ( + + Mastercard + + + + + + + ); + case "amex": + return ( + + American Express + + + + ); + case "discover": + return ( + + Discover + + + + + ); + default: + return ; + } + } + + // Handle other payment method types + switch (type) { + case "us_bank_account": + return ( +
+ +
+ ); + case "sepa_debit": + return ( +
+ +
+ ); + case "au_becs_debit": + return ( +
+ +
+ ); + default: + return ( +
+ +
+ ); + } +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-methods.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-methods.client.tsx new file mode 100644 index 00000000000..ee402d0447c --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-methods.client.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { + createSetupIntent, + deletePaymentMethod, + setDefaultPaymentMethod, +} from "@/actions/stripe-actions"; +import type { Team } from "@/api/team"; +import { SettingsCard } from "@/components/blocks/SettingsCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { + type ExtendedPaymentMethod, + formatPaymentMethodDetails, +} from "@/lib/payment-methods"; +import { useMutation } from "@tanstack/react-query"; +import { CheckCircle, CreditCard, MoreVertical, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { AddPaymentMethodForm } from "./add-payment-method-form.client"; +import { PaymentMethodIcon } from "./payment-method-icon"; + +interface PaymentMethodsClientProps { + team: Team; + paymentMethods: ExtendedPaymentMethod[]; + isEmpty: boolean; +} + +export function PaymentMethodsClient({ + team, + paymentMethods, + isEmpty, +}: PaymentMethodsClientProps) { + const router = useDashboardRouter(); + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(null); + const [isSettingDefault, setIsSettingDefault] = useState(null); + const [deleteConfirmation, setDeleteConfirmation] = useState( + null, + ); + + const handleDelete = async (id: string) => { + try { + setIsDeleting(id); + await deletePaymentMethod(id); + setDeleteConfirmation(null); + router.refresh(); // Refresh the page to get updated data + } catch (err) { + setError("Failed to delete payment method. Please try again."); + console.error(err); + } finally { + setIsDeleting(null); + } + }; + + const handleSetDefault = async (id: string) => { + try { + setIsSettingDefault(id); + await setDefaultPaymentMethod(team, id); + router.refresh(); // Refresh the page to get updated data + } catch (err) { + setError("Failed to set default payment method. Please try again."); + console.error(err); + } finally { + setIsSettingDefault(null); + } + }; + + const SETIMutation = useMutation({ + mutationFn: createSetupIntent, + onError: (error) => { + setError(error.message); + }, + }); + + const handleAddCardSuccess = () => { + SETIMutation.reset(); + router.refresh(); // Refresh the page to get updated data + }; + + const maxCards = 3; + const cardPaymentMethods = paymentMethods.filter( + (method) => method.type === "card", + ); + const canAddMoreCards = cardPaymentMethods.length < maxCards; + + return ( + SETIMutation.mutate(team), + disabled: SETIMutation.isPending, + isPending: SETIMutation.isPending, + }} + > + {isEmpty ? ( +
+ +

No payment methods

+

+ Add a payment method to get started. +

+
+ ) : ( + + + {paymentMethods.map((method) => { + const details = formatPaymentMethodDetails(method); + const isCard = method.type === "card"; + + return ( + + +
+ +
+ + {details.label} + + {method.isDefault && ( + + + Default + + )} +
+
+
+ +
+ {details.expiryInfo && ( + + {details.isExpiringSoon && ( + + Expiring soon + + )} + {details.isExpired && ( + + Expired + + )} + {details.expiryInfo} + + )} + + + + + + + {/* Only show "Set as default" for card payment methods */} + {isCard && !method.isDefault && ( + handleSetDefault(method.id)} + disabled={isSettingDefault === method.id} + > + + Set as default + + )} + setDeleteConfirmation(method.id)} + disabled={method.isDefault} + className={ + method.isDefault + ? "cursor-not-allowed opacity-50" + : "text-red-600" + } + > + + Remove + + + +
+
+
+ ); + })} +
+
+ )} + + {/* Add Card Dialog */} + + open ? SETIMutation.mutate(team) : SETIMutation.reset() + } + > + + + Add Payment Method + + Add a new credit card or debit card to your account. + + +
+ +
+
+
+ + {/* Delete Confirmation Dialog */} + { + if (!open) setDeleteConfirmation(null); + }} + > + + + Remove Payment Method + + Are you sure you want to remove this payment method? This action + cannot be undone. + + + {deleteConfirmation && ( +
+
+ {(() => { + const method = paymentMethods.find( + (m) => m.id === deleteConfirmation, + ); + if (!method) return null; + return ( + + ); + })()} +
+
+ {(() => { + const method = paymentMethods.find( + (m) => m.id === deleteConfirmation, + ); + if (!method) return null; + + const details = formatPaymentMethodDetails(method); + return ( + <> +

{details.label}

+ {details.expiryInfo && ( +

+ {details.expiryInfo} +

+ )} + + ); + })()} +
+
+ )} + + + + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx index c2db3923da9..18801541015 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/page.tsx @@ -1,3 +1,4 @@ +import { getTeamPaymentMethods } from "@/actions/stripe-actions"; import { getTeamBySlug } from "@/api/team"; import { getTeamSubscriptions } from "@/api/team-subscription"; import { redirect } from "next/navigation"; @@ -21,7 +22,10 @@ export default async function Page(props: { redirect("/team"); } - const subscriptions = await getTeamSubscriptions(team.slug); + const [subscriptions, paymentMethods] = await Promise.all([ + getTeamSubscriptions(team.slug), + getTeamPaymentMethods(team), + ]); if (!subscriptions) { return ( @@ -32,6 +36,11 @@ export default async function Page(props: { } return ( - + ); } diff --git a/apps/dashboard/src/components/settings/Account/Billing/index.tsx b/apps/dashboard/src/components/settings/Account/Billing/index.tsx index cd5f60aa289..0e7758c1612 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/index.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/index.tsx @@ -2,26 +2,29 @@ import { getBillingCheckoutUrl, getBillingPortalUrl } from "@/actions/billing"; import type { Team } from "@/api/team"; import type { TeamSubscription } from "@/api/team-subscription"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import type { ExtendedPaymentMethod } from "@/lib/payment-methods"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { AlertCircleIcon } from "lucide-react"; import Link from "next/link"; import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard"; +import { PaymentMethodsClient } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/payment-methods/payment-methods.client"; import { CouponSection } from "./CouponCard"; import { CreditsInfoCard } from "./PlanCard"; import { BillingPricing } from "./Pricing"; - // TODO - move this in app router folder in other pr interface BillingProps { team: Team; subscriptions: TeamSubscription[]; twAccount: Account; + paymentMethods: ExtendedPaymentMethod[]; } export const Billing: React.FC = ({ team, subscriptions, twAccount, + paymentMethods, }) => { const validPayment = team.billingStatus === "validPayment" || team.billingStatus === "pastDue"; @@ -67,6 +70,12 @@ export const Billing: React.FC = ({ getBillingCheckoutUrl={getBillingCheckoutUrl} /> + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4efec5e2f3..d2f98462ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,12 @@ importers: '@shazow/whatsabi': specifier: 0.20.0 version: 0.20.0(@noble/hashes@1.7.1)(typescript@5.8.2)(zod@3.24.2) + '@stripe/react-stripe-js': + specifier: 3.4.0 + version: 3.4.0(@stripe/stripe-js@6.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@stripe/stripe-js': + specifier: 6.1.0 + version: 6.1.0 '@tanstack/react-query': specifier: 5.67.3 version: 5.67.3(react@19.0.0) @@ -4306,7 +4312,7 @@ packages: '@radix-ui/react-context@1.1.1': resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} peerDependencies: - '@types/react': npm:types-react@19.0.0-rc.1 + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -4363,7 +4369,7 @@ packages: '@radix-ui/react-focus-guards@1.1.1': resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} peerDependencies: - '@types/react': npm:types-react@19.0.0-rc.1 + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -4638,7 +4644,7 @@ packages: '@radix-ui/react-use-controllable-state@1.1.0': resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} peerDependencies: - '@types/react': npm:types-react@19.0.0-rc.1 + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -4683,7 +4689,7 @@ packages: '@radix-ui/react-use-size@1.1.0': resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} peerDependencies: - '@types/react': npm:types-react@19.0.0-rc.1 + '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -5967,6 +5973,17 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + '@stripe/react-stripe-js@3.4.0': + resolution: {integrity: sha512-5m0vProlV2qyB7qXHSn25Ao79BjgJW/oiv2ynJ645dpdjeR7fyeb+KSrA4Esk7jqy+aKmdyn70TAIN0BVgh0MA==} + peerDependencies: + '@stripe/stripe-js': '>=1.44.1 <7.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@6.1.0': + resolution: {integrity: sha512-/5zxRol+MU4I7fjZXPxP2M6E1nuHOxAzoc0tOEC/TLnC31Gzc+5EE93mIjoAnu28O1Sqpl7/BkceDHwnGmn75A==} + engines: {node: '>=12.16'} + '@swagger-api/apidom-ast@1.0.0-beta.28': resolution: {integrity: sha512-IWamrCbjAgP6750GJUA4YWiciIDzV6efv2c2WDA6jGGUa4Vnua8/Slz2o3375OhbrDExuDPAWRXYD4WazQP9Zw==} @@ -22449,6 +22466,15 @@ snapshots: dependencies: storybook: 8.6.4(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@5.0.10) + '@stripe/react-stripe-js@3.4.0(@stripe/stripe-js@6.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@stripe/stripe-js': 6.1.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + + '@stripe/stripe-js@6.1.0': {} + '@swagger-api/apidom-ast@1.0.0-beta.28': dependencies: '@babel/runtime-corejs3': 7.26.10 @@ -26581,8 +26607,8 @@ snapshots: '@typescript-eslint/parser': 7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2) eslint: 9.22.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@9.22.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.22.0(jiti@2.4.2)) eslint-plugin-react-hooks: 5.1.0(eslint@9.22.0(jiti@2.4.2)) @@ -26601,33 +26627,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.0): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.18.1 - eslint: 8.57.0 + eslint: 9.22.0(jiti@2.4.2) get-tsconfig: 4.10.0 is-bun-module: 1.3.0 stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.18.1 - eslint: 9.22.0(jiti@2.4.2) + eslint: 8.57.0 get-tsconfig: 4.10.0 is-bun-module: 1.3.0 stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@9.22.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -26663,14 +26689,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2) eslint: 9.22.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -26703,7 +26729,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@9.22.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -26714,7 +26740,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.22.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3