Files
Kanishk Dudeja 1f03c0dc0e fix(billing): trim Tax ID inputs to trigger required validation (#47311)
### Summary

This PR trims the Tax ID value on the frontend so that whitespace-only
entries (e.g. " ") are correctly treated as "not set" instead of a valid
value.

Previously the "is the Tax ID set?" checks relied on string
length/truthiness, so whitespace-only input slipped through as a real
value - bypassing validation and getting persisted.

### Testing

- Subscription upgrade & top-up forms: verified the form no longer
submits when only whitespace is entered for the Tax ID. The validation
message now shows.
- Billing address form: no inline message is shown, but the Tax ID is
submitted as null, which clears it. This is unchanged from current
behaviour.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Tax ID values are now trimmed before validation and submission,
reducing errors caused by accidental leading or trailing spaces.
* Billing and payment flows now consistently use the cleaned Tax ID
value when deciding whether to save or send it.
* Customer tax ID details passed to payment setup now reflect the
trimmed value, helping avoid mismatches with external payment
processing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-29 16:23:21 +05:30

485 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, HelpCircle } 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,
Tooltip,
TooltipContent,
TooltipTrigger,
} 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().trim().min(2, {
message: 'Tax ID needs to be set.',
}),
tax_id_name: z.string(),
})
type BillingCustomerDataFormValues = z.infer<typeof BillingCustomerDataSchema>
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<BillingCustomerDataFormValues>({
resolver: zodResolver(BillingCustomerDataSchema),
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: rawTaxIdValue } = form.watch()
const taxIdValue = rawTaxIdValue?.trim() ?? ''
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 && taxIdValue) {
onTaxIdChange({
country: getEffectiveTaxCountry(selectedTaxId),
type: selectedTaxId.type,
value: taxIdValue,
})
} else {
onTaxIdChange(null)
}
}, [purchasingAsBusiness, selectedTaxId, taxIdValue, 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').trim(),
}
: null
}
const confirmSetup = async (): ReturnType<PaymentMethodElementRef['confirmSetup']> => {
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<PaymentMethodElementRef['getFormValues']> => {
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 (
<div className="space-y-2">
<p className="text-sm text-foreground-lighter">
Please ensure CVC and postal codes match what’s on file for your card.
</p>
<PaymentElement
options={{
layout: 'tabs',
defaultValues: { billingDetails: { email: email ?? undefined } },
readOnly,
}}
/>
{fullyLoaded && (
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="business"
checked={purchasingAsBusiness}
onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
/>
<label htmlFor="business" className="text-foreground text-sm leading-none">
I’m purchasing as a business
</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="text-foreground-lighter hover:text-foreground transition"
/>
</TooltipTrigger>
<TooltipContent side="top" className="w-72">
Check this only if you need a tax ID (e.g. US EIN, VAT, GST) on your invoice. You’ll
be asked to enter it, and it’ll appear on a compliant business invoice. If you don’t
have a tax ID, or don’t need one shown, leave this unchecked. You’ll still receive a
receipt.
</TooltipContent>
</Tooltip>
</div>
)}
<AddressElement
options={addressOptions}
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
key={`address-elements-${purchasingAsBusiness}`}
onChange={(evt) => {
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 && (
<Form {...form}>
<div className="grid grid-cols-2 gap-x-2 w-full">
<FormField
name="tax_id_name"
control={form.control}
render={() => (
<FormItemLayout hideMessage layout="vertical">
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="default"
role="combobox"
size="medium"
aria-expanded={showTaxIDsPopover}
aria-controls={taxIdListboxId}
className={cn(
'w-full justify-between h-[34px]',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
id={taxIdListboxId}
sameWidthAsTrigger
className="p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
{selectedTaxId && (
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder={selectedTaxId?.placeholder} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</Form>
)}
</div>
)
}
)
NewPaymentMethodElement.displayName = 'NewPaymentMethodElement'