import HCaptcha from '@hcaptcha/react-hcaptcha' import { Elements } from '@stripe/react-stripe-js' import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' import { useParams } from 'common' import { Loader, Plus } from 'lucide-react' import { useTheme } from 'next-themes' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react' import { toast } from 'sonner' import { Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils' import { NewPaymentMethodElement, type PaymentMethodElementRef, } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement' import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query' import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation' import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation' import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query' import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query' import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { BASE_PATH, STRIPE_PUBLIC_KEY } from '@/lib/constants' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) export interface PaymentMethodSelectionProps { selectedPaymentMethod?: string onSelectPaymentMethod: (id: string) => void layout?: 'vertical' | 'horizontal' readOnly: boolean onAddressChange?: (address: CustomerAddress) => void onTaxIdChange?: (taxId: CustomerTaxId | null) => void useAsDefaultBillingAddress: boolean onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void } const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( { selectedPaymentMethod, onSelectPaymentMethod, layout = 'vertical', readOnly, onAddressChange, onTaxIdChange, useAsDefaultBillingAddress, onUseAsDefaultBillingAddressChange, }: PaymentMethodSelectionProps, ref ) { const { slug } = useParams() const { data: selectedOrganization } = useSelectedOrganizationQuery() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) const [setupIntent, setSetupIntent] = useState(undefined) const { resolvedTheme } = useTheme() const paymentRef = useRef(null) const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(null) const { data: customerProfile, isPending: isCustomerProfileLoading } = useOrganizationCustomerProfileQuery({ slug, }) const { data: taxId, isPending: isCustomerTaxIdLoading, isError: isTaxIdError, } = useOrganizationTaxIdQuery({ slug }) const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({ onError: () => {}, }) const { data: allPaymentMethods, isPending: isLoading } = useOrganizationPaymentMethodsQuery({ slug, }) const paymentMethods = useMemo(() => { if (!allPaymentMethods) return { data: [], defaultPaymentMethodId: null, } return { // force customer to put down address via payment method creation flow if they don't have an address set data: customerProfile?.address == null ? [] : allPaymentMethods.data, defaultPaymentMethodId: allPaymentMethods.data.some( (pm) => pm.id === allPaymentMethods.defaultPaymentMethodId ) ? allPaymentMethods.defaultPaymentMethodId : null, } }, [allPaymentMethods, customerProfile]) const captchaRefCallback = useCallback((node: any) => { setCaptchaRef(node) }, []) const { mutate: initSetupIntent, isPending: setupIntentLoading } = useOrganizationPaymentMethodSetupIntent({ onSuccess: (intent) => { setSetupIntent(intent) }, onError: (error) => { toast.error(`Failed to setup intent: ${error.message}`) }, }) useEffect(() => { if (paymentMethods?.data && paymentMethods.data.length === 0 && setupNewPaymentMethod == null) { setSetupNewPaymentMethod(true) } }, [paymentMethods]) useEffect(() => { const loadSetupIntent = async (hcaptchaToken: string | undefined) => { const slug = selectedOrganization?.slug if (!slug) return console.error('Slug is required') if (!hcaptchaToken) return console.error('HCaptcha token required') setSetupIntent(undefined) initSetupIntent({ slug: slug!, hcaptchaToken }) } const loadPaymentForm = async () => { if (setupNewPaymentMethod && captchaRef) { let token = captchaToken try { if (!token) { const captchaResponse = await captchaRef.execute({ async: true }) token = captchaResponse?.response ?? null } } catch (error) { return } await loadSetupIntent(token ?? undefined) resetCaptcha() } } loadPaymentForm() }, [captchaRef, setupNewPaymentMethod]) const resetCaptcha = () => { setCaptchaToken(null) captchaRef?.resetCaptcha() } const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( () => ({ clientSecret: setupIntent ? setupIntent.client_secret! : '', appearance: getStripeElementsAppearanceOptions(resolvedTheme), paymentMethodCreation: 'manual', }) as const, [setupIntent, resolvedTheme] ) useEffect(() => { if (paymentMethods?.data && paymentMethods.data.length > 0) { const selectedPaymentMethodExists = paymentMethods.data.some( (it) => it.id === selectedPaymentMethod ) if (!selectedPaymentMethod || !selectedPaymentMethodExists) { const defaultPaymentMethod = paymentMethods.data.find((method) => method.is_default) if (defaultPaymentMethod !== undefined) { onSelectPaymentMethod(defaultPaymentMethod.id) } else { onSelectPaymentMethod(paymentMethods.data[0].id) } } } }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod]) const getFormValues = async (): ReturnType => { if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { return paymentRef.current?.getFormValues() } else { return { address: customerProfile?.address ?? ({} as CustomerAddress), customerName: customerProfile?.billing_name || '', taxId: taxId ?? null, } } } // Validate address/tax ID with a dry run before proceeding with Stripe, // so validation errors (e.g. invalid tax ID) block the flow early. const validateBillingProfile = async (): Promise => { if (!useAsDefaultBillingAddress) return true if (isTaxIdError || isCustomerTaxIdLoading) { toast.error( isTaxIdError ? 'Unable to load current tax ID. Please try again.' : 'Tax ID is still loading. Please wait and try again.' ) return false } const formValues = await getFormValues() if (!formValues) return false try { await updateCustomerProfile({ slug, address: formValues.address, billing_name: formValues.customerName, tax_id: formValues.taxId, dry_run: true, }) } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to validate billing profile') return false } return true } // If createPaymentMethod already exists, use it. Otherwise, define it here. const createPaymentMethod = async (): ReturnType< PaymentMethodElementRef['createPaymentMethod'] > => { if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { const paymentResult = await paymentRef.current?.createPaymentMethod() if (!paymentResult) return paymentResult return { paymentMethod: paymentResult.paymentMethod, customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null, address: useAsDefaultBillingAddress ? paymentResult.address : null, taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null, } } else { return { paymentMethod: { id: selectedPaymentMethod } as PaymentMethod, customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null, address: useAsDefaultBillingAddress ? (customerProfile?.address ?? null) : null, taxId: useAsDefaultBillingAddress ? (taxId ?? null) : null, } } } useImperativeHandle(ref, () => ({ createPaymentMethod, validateBillingProfile, })) return ( <> { // [Joshen] This is to ensure that hCaptcha popup remains clickable if (document !== undefined) document.body.classList.add('pointer-events-auto!') }} onClose={() => { setSetupIntent(undefined) if (document !== undefined) document.body.classList.remove('pointer-events-auto!') }} onVerify={(token) => { setCaptchaToken(token) if (document !== undefined) document.body.classList.remove('pointer-events-auto!') }} onExpire={() => { setCaptchaToken(null) }} />
{isLoading || isCustomerProfileLoading ? (

Retrieving payment methods

) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? ( ) : null} {stripePromise && setupIntent && customerProfile && ( <> {/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */} {customerProfile?.address != null && (
{ onUseAsDefaultBillingAddressChange(!useAsDefaultBillingAddress) }} />
)} )} {(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
)}
) }) PaymentMethodSelection.displayName = 'PaymentMethodSelection' export default PaymentMethodSelection