NewPaymentMethodElement.tsx466 lines · main
1/**
2 * Set up as a separate component, as we need any component using stripe/elements to be wrapped in Elements.
3 *
4 * If Elements is on a higher level, we risk losing all form state in case a payment fails.
5 */
6import { zodResolver } from '@hookform/resolvers/zod'
7import { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
8import type { PaymentMethod } from '@stripe/stripe-js'
9import {
10 StripeAddressElementChangeEvent,
11 StripeAddressElementOptions,
12 type SetupIntent,
13} from '@stripe/stripe-js'
14import { Form } from '@ui/components/shadcn/ui/form'
15import { Check, ChevronsUpDown } from 'lucide-react'
16import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'
17import { useForm } from 'react-hook-form'
18import { toast } from 'sonner'
19import {
20 Button,
21 Checkbox,
22 cn,
23 Command,
24 CommandEmpty,
25 CommandGroup,
26 CommandInput,
27 CommandItem,
28 CommandList,
29 FormControl,
30 FormField,
31 FormItem,
32 FormMessage,
33 Input,
34 Popover,
35 PopoverContent,
36 PopoverTrigger,
37} from 'ui'
38import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
39import { z } from 'zod'
40
41import { TAX_IDS } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants'
42import {
43 getEffectiveTaxCountry,
44 resolveStoredTaxId,
45} from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
46import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
47import { getURL } from '@/lib/helpers'
48
49export const BillingCustomerDataSchema = z.object({
50 tax_id_type: z.string(),
51 tax_id_value: z.string().min(2, {
52 message: 'Tax ID needs to be set.',
53 }),
54 tax_id_name: z.string(),
55})
56
57type BillingCustomerDataFormValues = z.infer<typeof BillingCustomerDataSchema>
58
59export type PaymentMethodElementRef = {
60 confirmSetup: () => Promise<
61 | {
62 setupIntent: SetupIntent
63 address: CustomerAddress
64 customerName: string
65 taxId: CustomerTaxId | null
66 }
67 | undefined
68 >
69 createPaymentMethod: () => Promise<
70 | {
71 paymentMethod: PaymentMethod
72 address: CustomerAddress | null
73 customerName: string | null
74 taxId: CustomerTaxId | null
75 }
76 | undefined
77 >
78 getFormValues: () => Promise<
79 | {
80 address: CustomerAddress
81 customerName: string
82 taxId: CustomerTaxId | null
83 }
84 | undefined
85 >
86}
87
88export const NewPaymentMethodElement = forwardRef(
89 (
90 {
91 email,
92 readOnly,
93 currentAddress,
94 currentTaxId,
95 customerName,
96 onAddressChange,
97 onAddressIncomplete,
98 onTaxIdChange,
99 }: {
100 email?: string | null | undefined
101 readOnly: boolean
102 currentAddress?: CustomerAddress | null
103 currentTaxId?: CustomerTaxId | null
104 customerName?: string | undefined
105 onAddressChange?: (address: CustomerAddress) => void
106 onAddressIncomplete?: () => void
107 onTaxIdChange?: (taxId: CustomerTaxId | null) => void
108 },
109 ref
110 ) => {
111 const stripe = useStripe()
112 const elements = useElements()
113
114 const form = useForm<BillingCustomerDataFormValues>({
115 resolver: zodResolver(BillingCustomerDataSchema as any),
116 defaultValues: {
117 tax_id_name: currentTaxId
118 ? (resolveStoredTaxId(currentTaxId.type, currentTaxId.country, currentAddress?.country)
119 ?.name ?? '')
120 : '',
121 tax_id_type: currentTaxId ? currentTaxId.type : '',
122 tax_id_value: currentTaxId ? currentTaxId.value : '',
123 },
124 })
125
126 // To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded
127 const [fullyLoaded, setFullyLoaded] = useState(false)
128
129 const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
130 const taxIdListboxId = useId()
131
132 const onSelectTaxIdType = (name: string) => {
133 const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name)
134 if (!selectedTaxIdOption) return
135 form.setValue('tax_id_type', selectedTaxIdOption.type)
136 form.setValue('tax_id_value', '')
137 form.setValue('tax_id_name', name)
138 }
139
140 const { tax_id_name, tax_id_value } = form.watch()
141 const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
142
143 const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
144 const [stripeAddress, setStripeAddress] = useState<
145 StripeAddressElementChangeEvent['value'] | undefined
146 >(undefined)
147 useEffect(() => {
148 if (!onTaxIdChange) return
149 if (purchasingAsBusiness && selectedTaxId && tax_id_value) {
150 onTaxIdChange({
151 country: getEffectiveTaxCountry(selectedTaxId),
152 type: selectedTaxId.type,
153 value: tax_id_value,
154 })
155 } else {
156 onTaxIdChange(null)
157 }
158 }, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange])
159
160 const addressCountry = stripeAddress?.address.country
161 const availableTaxIds = useMemo(() => {
162 const country = addressCountry || null
163
164 return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort(
165 (a, b) => a.country.localeCompare(b.country)
166 )
167 }, [addressCountry])
168
169 const createPaymentMethod = async (): ReturnType<
170 PaymentMethodElementRef['createPaymentMethod']
171 > => {
172 if (!stripe || !elements) return
173 const isValid = await form.trigger()
174
175 if (
176 purchasingAsBusiness &&
177 availableTaxIds.length > 0 &&
178 (!isValid || !form.getValues('tax_id_value'))
179 ) {
180 return
181 }
182
183 await elements.submit()
184
185 // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
186 const { error, paymentMethod } = await stripe.createPaymentMethod({
187 elements,
188 })
189 if (error || paymentMethod == null) {
190 toast.error(error?.message ?? ' Failed to process card details')
191 return
192 }
193
194 const addressElement = await elements.getElement('address')!.getValue()
195 return {
196 paymentMethod,
197 address: {
198 ...addressElement.value.address,
199 line2: addressElement.value.address.line2 || undefined,
200 },
201 customerName: addressElement.value.name,
202 taxId: getConfiguredTaxId(),
203 }
204 }
205
206 function getConfiguredTaxId(): CustomerTaxId | null {
207 const isValidForCountry = selectedTaxId && availableTaxIds.includes(selectedTaxId)
208 return purchasingAsBusiness && isValidForCountry
209 ? {
210 country: getEffectiveTaxCountry(selectedTaxId),
211 type: selectedTaxId.type,
212 value: form.getValues('tax_id_value'),
213 }
214 : null
215 }
216
217 const confirmSetup = async (): ReturnType<PaymentMethodElementRef['confirmSetup']> => {
218 if (!stripe || !elements) return
219
220 await elements.submit()
221
222 const { error, setupIntent } = await stripe.confirmSetup({
223 elements,
224 redirect: 'if_required',
225 confirmParams: { return_url: `${getURL()}/org/_/billing` },
226 })
227
228 if (error || setupIntent == null) {
229 toast.error(error?.message ?? ' Failed to process card details')
230 return
231 }
232
233 const addressElement = await elements.getElement('address')!.getValue()
234 return {
235 setupIntent,
236 address: {
237 ...addressElement.value.address,
238 line2: addressElement.value.address.line2 || undefined,
239 },
240 customerName: addressElement.value.name,
241 taxId: getConfiguredTaxId(),
242 }
243 }
244
245 const getFormValues = async (): ReturnType<PaymentMethodElementRef['getFormValues']> => {
246 if (!elements) return
247
248 const isValid = await form.trigger()
249 if (
250 purchasingAsBusiness &&
251 availableTaxIds.length > 0 &&
252 (!isValid || !form.getValues('tax_id_value'))
253 ) {
254 return
255 }
256
257 const { error: submitError } = await elements.submit()
258 if (submitError) return
259
260 const addressElement = await elements.getElement('address')!.getValue()
261
262 return {
263 address: {
264 ...addressElement.value.address,
265 line2: addressElement.value.address.line2 || undefined,
266 },
267 customerName: addressElement.value.name,
268 taxId: getConfiguredTaxId(),
269 }
270 }
271
272 useImperativeHandle(ref, () => ({
273 createPaymentMethod,
274 confirmSetup,
275 getFormValues,
276 }))
277
278 const addressOptions: StripeAddressElementOptions = useMemo(
279 () => ({
280 mode: 'billing',
281 autocomplete: {
282 apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
283 mode: 'google_maps_api',
284 },
285 display: { name: purchasingAsBusiness ? 'organization' : 'full' },
286 // Use live form state (stripeAddress) so the address survives remounts triggered
287 // by the purchasingAsBusiness toggle (which changes the key prop). Without this,
288 // the element resets to the original currentAddress prop, causing the country to
289 // revert and the tax ID selector to fall out of sync.
290 defaultValues: {
291 address: stripeAddress?.address ?? currentAddress ?? undefined,
292 name: stripeAddress?.name ?? customerName,
293 },
294 }),
295 [purchasingAsBusiness]
296 )
297
298 // Reset tax ID fields when the billing country changes and preselect the
299 // first available tax ID for the new country.
300 const prevCountryRef = useRef(addressCountry)
301 useEffect(() => {
302 if (!addressCountry) return
303
304 const isCountryChange =
305 prevCountryRef.current !== undefined && prevCountryRef.current !== addressCountry
306 prevCountryRef.current = addressCountry
307
308 // On country change: always reset to the new country's default
309 // On initial load: only preselect if there's no existing tax id
310 if (isCountryChange || !currentTaxId) {
311 if (availableTaxIds.length) {
312 const taxIdOption = availableTaxIds[0]
313 form.setValue('tax_id_type', taxIdOption.type)
314 form.setValue('tax_id_value', '')
315 form.setValue('tax_id_name', taxIdOption.name)
316 } else {
317 form.setValue('tax_id_type', '')
318 form.setValue('tax_id_value', '')
319 form.setValue('tax_id_name', '')
320 }
321 }
322 }, [availableTaxIds, addressCountry, currentTaxId, form])
323
324 return (
325 <div className="space-y-2">
326 <p className="text-sm text-foreground-lighter">
327 Please ensure CVC and postal codes match what’s on file for your card.
328 </p>
329
330 <PaymentElement
331 options={{
332 layout: 'tabs',
333 defaultValues: { billingDetails: { email: email ?? undefined } },
334 readOnly,
335 }}
336 />
337
338 {fullyLoaded && (
339 <div className="flex items-center space-x-2 py-4">
340 <Checkbox
341 id="business"
342 checked={purchasingAsBusiness}
343 onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
344 />
345 <label htmlFor="business" className="text-foreground text-sm leading-none">
346 I’m purchasing as a business
347 </label>
348 </div>
349 )}
350
351 <AddressElement
352 options={addressOptions}
353 // Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
354 key={`address-elements-${purchasingAsBusiness}`}
355 onChange={(evt) => {
356 setStripeAddress(evt.value)
357 if (evt.complete) {
358 onAddressChange?.({
359 ...evt.value.address,
360 line2: evt.value.address.line2 || undefined,
361 })
362 } else {
363 onAddressIncomplete?.()
364 }
365 }}
366 onReady={() => setFullyLoaded(true)}
367 />
368
369 {purchasingAsBusiness && availableTaxIds.length > 0 && (
370 <Form {...form}>
371 <div className="grid grid-cols-2 gap-x-2 w-full">
372 <FormField
373 name="tax_id_name"
374 control={form.control}
375 render={() => (
376 <FormItemLayout hideMessage layout="vertical">
377 <Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
378 <PopoverTrigger asChild>
379 <FormControl>
380 <Button
381 type="default"
382 role="combobox"
383 size="medium"
384 aria-expanded={showTaxIDsPopover}
385 aria-controls={taxIdListboxId}
386 className={cn(
387 'w-full justify-between h-[34px]',
388 !selectedTaxId && 'text-muted'
389 )}
390 iconRight={
391 <ChevronsUpDown
392 className="ml-2 h-4 w-4 shrink-0 opacity-50"
393 strokeWidth={1.5}
394 />
395 }
396 >
397 {selectedTaxId
398 ? `${selectedTaxId.country} - ${selectedTaxId.name}`
399 : 'Select tax ID'}
400 </Button>
401 </FormControl>
402 </PopoverTrigger>
403 <PopoverContent
404 id={taxIdListboxId}
405 sameWidthAsTrigger
406 className="p-0"
407 align="start"
408 >
409 <Command>
410 <CommandInput placeholder="Search tax ID..." />
411 <CommandList>
412 <CommandEmpty>No tax ID found.</CommandEmpty>
413 <CommandGroup>
414 {availableTaxIds.map((option) => (
415 <CommandItem
416 key={option.name}
417 value={`${option.country} - ${option.name}`}
418 onSelect={() => {
419 onSelectTaxIdType(option.name)
420 setShowTaxIDsPopover(false)
421 }}
422 >
423 <Check
424 className={cn(
425 'mr-2 h-4 w-4',
426 selectedTaxId?.name === option.name
427 ? 'opacity-100'
428 : 'opacity-0'
429 )}
430 />
431 {option.country} - {option.name}
432 </CommandItem>
433 ))}
434 </CommandGroup>
435 </CommandList>
436 </Command>
437 </PopoverContent>
438 </Popover>
439 <FormMessage />
440 </FormItemLayout>
441 )}
442 />
443
444 {selectedTaxId && (
445 <FormField
446 name="tax_id_value"
447 control={form.control}
448 render={({ field }) => (
449 <FormItem>
450 <FormControl>
451 <Input {...field} placeholder={selectedTaxId?.placeholder} />
452 </FormControl>
453 <FormMessage />
454 </FormItem>
455 )}
456 />
457 )}
458 </div>
459 </Form>
460 )}
461 </div>
462 )
463 }
464)
465
466NewPaymentMethodElement.displayName = 'NewPaymentMethodElement'