/** * Set up as a separate component, as we need any component using stripe/elements to be wrapped in Elements. * * If Elements is on a higher level, we risk losing all form state in case a payment fails. */ import { zodResolver } from '@hookform/resolvers/zod' import { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' import type { PaymentMethod } from '@stripe/stripe-js' import { StripeAddressElementChangeEvent, StripeAddressElementOptions, type SetupIntent, } from '@stripe/stripe-js' import { Form } from '@ui/components/shadcn/ui/form' import { Check, ChevronsUpDown } from 'lucide-react' import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Checkbox, cn, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, FormControl, FormField, FormItem, FormMessage, Input, Popover, PopoverContent, PopoverTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' import { TAX_IDS } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants' import { getEffectiveTaxCountry, resolveStoredTaxId, } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils' import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' import { getURL } from '@/lib/helpers' export const BillingCustomerDataSchema = z.object({ tax_id_type: z.string(), tax_id_value: z.string().min(2, { message: 'Tax ID needs to be set.', }), tax_id_name: z.string(), }) type BillingCustomerDataFormValues = z.infer export type PaymentMethodElementRef = { confirmSetup: () => Promise< | { setupIntent: SetupIntent address: CustomerAddress customerName: string taxId: CustomerTaxId | null } | undefined > createPaymentMethod: () => Promise< | { paymentMethod: PaymentMethod address: CustomerAddress | null customerName: string | null taxId: CustomerTaxId | null } | undefined > getFormValues: () => Promise< | { address: CustomerAddress customerName: string taxId: CustomerTaxId | null } | undefined > } export const NewPaymentMethodElement = forwardRef( ( { email, readOnly, currentAddress, currentTaxId, customerName, onAddressChange, onAddressIncomplete, onTaxIdChange, }: { email?: string | null | undefined readOnly: boolean currentAddress?: CustomerAddress | null currentTaxId?: CustomerTaxId | null customerName?: string | undefined onAddressChange?: (address: CustomerAddress) => void onAddressIncomplete?: () => void onTaxIdChange?: (taxId: CustomerTaxId | null) => void }, ref ) => { const stripe = useStripe() const elements = useElements() const form = useForm({ resolver: zodResolver(BillingCustomerDataSchema as any), defaultValues: { tax_id_name: currentTaxId ? (resolveStoredTaxId(currentTaxId.type, currentTaxId.country, currentAddress?.country) ?.name ?? '') : '', tax_id_type: currentTaxId ? currentTaxId.type : '', tax_id_value: currentTaxId ? currentTaxId.value : '', }, }) // To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded const [fullyLoaded, setFullyLoaded] = useState(false) const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false) const taxIdListboxId = useId() const onSelectTaxIdType = (name: string) => { const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name) if (!selectedTaxIdOption) return form.setValue('tax_id_type', selectedTaxIdOption.type) form.setValue('tax_id_value', '') form.setValue('tax_id_name', name) } const { tax_id_name, tax_id_value } = form.watch() const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null) const [stripeAddress, setStripeAddress] = useState< StripeAddressElementChangeEvent['value'] | undefined >(undefined) useEffect(() => { if (!onTaxIdChange) return if (purchasingAsBusiness && selectedTaxId && tax_id_value) { onTaxIdChange({ country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, value: tax_id_value, }) } else { onTaxIdChange(null) } }, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange]) const addressCountry = stripeAddress?.address.country const availableTaxIds = useMemo(() => { const country = addressCountry || null return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort( (a, b) => a.country.localeCompare(b.country) ) }, [addressCountry]) const createPaymentMethod = async (): ReturnType< PaymentMethodElementRef['createPaymentMethod'] > => { if (!stripe || !elements) return const isValid = await form.trigger() if ( purchasingAsBusiness && availableTaxIds.length > 0 && (!isValid || !form.getValues('tax_id_value')) ) { return } await elements.submit() // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment const { error, paymentMethod } = await stripe.createPaymentMethod({ elements, }) if (error || paymentMethod == null) { toast.error(error?.message ?? ' Failed to process card details') return } const addressElement = await elements.getElement('address')!.getValue() return { paymentMethod, address: { ...addressElement.value.address, line2: addressElement.value.address.line2 || undefined, }, customerName: addressElement.value.name, taxId: getConfiguredTaxId(), } } function getConfiguredTaxId(): CustomerTaxId | null { const isValidForCountry = selectedTaxId && availableTaxIds.includes(selectedTaxId) return purchasingAsBusiness && isValidForCountry ? { country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, value: form.getValues('tax_id_value'), } : null } const confirmSetup = async (): ReturnType => { if (!stripe || !elements) return await elements.submit() const { error, setupIntent } = await stripe.confirmSetup({ elements, redirect: 'if_required', confirmParams: { return_url: `${getURL()}/org/_/billing` }, }) if (error || setupIntent == null) { toast.error(error?.message ?? ' Failed to process card details') return } const addressElement = await elements.getElement('address')!.getValue() return { setupIntent, address: { ...addressElement.value.address, line2: addressElement.value.address.line2 || undefined, }, customerName: addressElement.value.name, taxId: getConfiguredTaxId(), } } const getFormValues = async (): ReturnType => { if (!elements) return const isValid = await form.trigger() if ( purchasingAsBusiness && availableTaxIds.length > 0 && (!isValid || !form.getValues('tax_id_value')) ) { return } const { error: submitError } = await elements.submit() if (submitError) return const addressElement = await elements.getElement('address')!.getValue() return { address: { ...addressElement.value.address, line2: addressElement.value.address.line2 || undefined, }, customerName: addressElement.value.name, taxId: getConfiguredTaxId(), } } useImperativeHandle(ref, () => ({ createPaymentMethod, confirmSetup, getFormValues, })) const addressOptions: StripeAddressElementOptions = useMemo( () => ({ mode: 'billing', autocomplete: { apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!, mode: 'google_maps_api', }, display: { name: purchasingAsBusiness ? 'organization' : 'full' }, // Use live form state (stripeAddress) so the address survives remounts triggered // by the purchasingAsBusiness toggle (which changes the key prop). Without this, // the element resets to the original currentAddress prop, causing the country to // revert and the tax ID selector to fall out of sync. defaultValues: { address: stripeAddress?.address ?? currentAddress ?? undefined, name: stripeAddress?.name ?? customerName, }, }), [purchasingAsBusiness] ) // Reset tax ID fields when the billing country changes and preselect the // first available tax ID for the new country. const prevCountryRef = useRef(addressCountry) useEffect(() => { if (!addressCountry) return const isCountryChange = prevCountryRef.current !== undefined && prevCountryRef.current !== addressCountry prevCountryRef.current = addressCountry // On country change: always reset to the new country's default // On initial load: only preselect if there's no existing tax id if (isCountryChange || !currentTaxId) { if (availableTaxIds.length) { const taxIdOption = availableTaxIds[0] form.setValue('tax_id_type', taxIdOption.type) form.setValue('tax_id_value', '') form.setValue('tax_id_name', taxIdOption.name) } else { form.setValue('tax_id_type', '') form.setValue('tax_id_value', '') form.setValue('tax_id_name', '') } } }, [availableTaxIds, addressCountry, currentTaxId, form]) return (

Please ensure CVC and postal codes match what’s on file for your card.

{fullyLoaded && (
setPurchasingAsBusiness(!purchasingAsBusiness)} />
)} { setStripeAddress(evt.value) if (evt.complete) { onAddressChange?.({ ...evt.value.address, line2: evt.value.address.line2 || undefined, }) } else { onAddressIncomplete?.() } }} onReady={() => setFullyLoaded(true)} /> {purchasingAsBusiness && availableTaxIds.length > 0 && (
( No tax ID found. {availableTaxIds.map((option) => ( { onSelectTaxIdType(option.name) setShowTaxIDsPopover(false) }} > {option.country} - {option.name} ))} )} /> {selectedTaxId && ( ( )} /> )}
)}
) } ) NewPaymentMethodElement.displayName = 'NewPaymentMethodElement'