PaymentMethodSelection.tsx398 lines · main
1import HCaptcha from '@hcaptcha/react-hcaptcha'
2import { Elements } from '@stripe/react-stripe-js'
3import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
4import { useParams } from 'common'
5import { Loader, Plus } from 'lucide-react'
6import { useTheme } from 'next-themes'
7import {
8 forwardRef,
9 useCallback,
10 useEffect,
11 useImperativeHandle,
12 useMemo,
13 useRef,
14 useState,
15} from 'react'
16import { toast } from 'sonner'
17import { Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui'
18import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
19import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
20
21import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
22import {
23 NewPaymentMethodElement,
24 type PaymentMethodElementRef,
25} from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
26import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
27import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
28import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation'
29import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query'
30import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
31import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
32import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation'
33import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
34import { BASE_PATH, STRIPE_PUBLIC_KEY } from '@/lib/constants'
35
36const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
37
38export interface PaymentMethodSelectionProps {
39 selectedPaymentMethod?: string
40 onSelectPaymentMethod: (id: string) => void
41 layout?: 'vertical' | 'horizontal'
42 readOnly: boolean
43 onAddressChange?: (address: CustomerAddress) => void
44 onTaxIdChange?: (taxId: CustomerTaxId | null) => void
45 useAsDefaultBillingAddress: boolean
46 onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
47}
48
49const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
50 {
51 selectedPaymentMethod,
52 onSelectPaymentMethod,
53 layout = 'vertical',
54 readOnly,
55 onAddressChange,
56 onTaxIdChange,
57 useAsDefaultBillingAddress,
58 onUseAsDefaultBillingAddressChange,
59 }: PaymentMethodSelectionProps,
60 ref
61) {
62 const { slug } = useParams()
63 const { data: selectedOrganization } = useSelectedOrganizationQuery()
64 const [captchaToken, setCaptchaToken] = useState<string | null>(null)
65 const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
66 const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
67 const { resolvedTheme } = useTheme()
68 const paymentRef = useRef<PaymentMethodElementRef | null>(null)
69 const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
70 const { data: customerProfile, isPending: isCustomerProfileLoading } =
71 useOrganizationCustomerProfileQuery({
72 slug,
73 })
74 const {
75 data: taxId,
76 isPending: isCustomerTaxIdLoading,
77 isError: isTaxIdError,
78 } = useOrganizationTaxIdQuery({ slug })
79 const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({
80 onError: () => {},
81 })
82
83 const { data: allPaymentMethods, isPending: isLoading } = useOrganizationPaymentMethodsQuery({
84 slug,
85 })
86
87 const paymentMethods = useMemo(() => {
88 if (!allPaymentMethods)
89 return {
90 data: [],
91 defaultPaymentMethodId: null,
92 }
93
94 return {
95 // force customer to put down address via payment method creation flow if they don't have an address set
96 data: customerProfile?.address == null ? [] : allPaymentMethods.data,
97 defaultPaymentMethodId: allPaymentMethods.data.some(
98 (pm) => pm.id === allPaymentMethods.defaultPaymentMethodId
99 )
100 ? allPaymentMethods.defaultPaymentMethodId
101 : null,
102 }
103 }, [allPaymentMethods, customerProfile])
104
105 const captchaRefCallback = useCallback((node: any) => {
106 setCaptchaRef(node)
107 }, [])
108
109 const { mutate: initSetupIntent, isPending: setupIntentLoading } =
110 useOrganizationPaymentMethodSetupIntent({
111 onSuccess: (intent) => {
112 setSetupIntent(intent)
113 },
114 onError: (error) => {
115 toast.error(`Failed to setup intent: ${error.message}`)
116 },
117 })
118
119 useEffect(() => {
120 if (paymentMethods?.data && paymentMethods.data.length === 0 && setupNewPaymentMethod == null) {
121 setSetupNewPaymentMethod(true)
122 }
123 }, [paymentMethods])
124
125 useEffect(() => {
126 const loadSetupIntent = async (hcaptchaToken: string | undefined) => {
127 const slug = selectedOrganization?.slug
128 if (!slug) return console.error('Slug is required')
129 if (!hcaptchaToken) return console.error('HCaptcha token required')
130
131 setSetupIntent(undefined)
132 initSetupIntent({ slug: slug!, hcaptchaToken })
133 }
134
135 const loadPaymentForm = async () => {
136 if (setupNewPaymentMethod && captchaRef) {
137 let token = captchaToken
138
139 try {
140 if (!token) {
141 const captchaResponse = await captchaRef.execute({ async: true })
142 token = captchaResponse?.response ?? null
143 }
144 } catch (error) {
145 return
146 }
147
148 await loadSetupIntent(token ?? undefined)
149 resetCaptcha()
150 }
151 }
152
153 loadPaymentForm()
154 }, [captchaRef, setupNewPaymentMethod])
155
156 const resetCaptcha = () => {
157 setCaptchaToken(null)
158 captchaRef?.resetCaptcha()
159 }
160
161 const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
162 () =>
163 ({
164 clientSecret: setupIntent ? setupIntent.client_secret! : '',
165 appearance: getStripeElementsAppearanceOptions(resolvedTheme),
166 paymentMethodCreation: 'manual',
167 }) as const,
168 [setupIntent, resolvedTheme]
169 )
170
171 useEffect(() => {
172 if (paymentMethods?.data && paymentMethods.data.length > 0) {
173 const selectedPaymentMethodExists = paymentMethods.data.some(
174 (it) => it.id === selectedPaymentMethod
175 )
176
177 if (!selectedPaymentMethod || !selectedPaymentMethodExists) {
178 const defaultPaymentMethod = paymentMethods.data.find((method) => method.is_default)
179 if (defaultPaymentMethod !== undefined) {
180 onSelectPaymentMethod(defaultPaymentMethod.id)
181 } else {
182 onSelectPaymentMethod(paymentMethods.data[0].id)
183 }
184 }
185 }
186 }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
187
188 const getFormValues = async (): ReturnType<PaymentMethodElementRef['getFormValues']> => {
189 if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
190 return paymentRef.current?.getFormValues()
191 } else {
192 return {
193 address: customerProfile?.address ?? ({} as CustomerAddress),
194 customerName: customerProfile?.billing_name || '',
195 taxId: taxId ?? null,
196 }
197 }
198 }
199
200 // Validate address/tax ID with a dry run before proceeding with Stripe,
201 // so validation errors (e.g. invalid tax ID) block the flow early.
202 const validateBillingProfile = async (): Promise<boolean> => {
203 if (!useAsDefaultBillingAddress) return true
204
205 if (isTaxIdError || isCustomerTaxIdLoading) {
206 toast.error(
207 isTaxIdError
208 ? 'Unable to load current tax ID. Please try again.'
209 : 'Tax ID is still loading. Please wait and try again.'
210 )
211 return false
212 }
213
214 const formValues = await getFormValues()
215 if (!formValues) return false
216
217 try {
218 await updateCustomerProfile({
219 slug,
220 address: formValues.address,
221 billing_name: formValues.customerName,
222 tax_id: formValues.taxId,
223 dry_run: true,
224 })
225 } catch (error) {
226 toast.error(error instanceof Error ? error.message : 'Failed to validate billing profile')
227 return false
228 }
229
230 return true
231 }
232
233 // If createPaymentMethod already exists, use it. Otherwise, define it here.
234 const createPaymentMethod = async (): ReturnType<
235 PaymentMethodElementRef['createPaymentMethod']
236 > => {
237 if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
238 const paymentResult = await paymentRef.current?.createPaymentMethod()
239
240 if (!paymentResult) return paymentResult
241
242 return {
243 paymentMethod: paymentResult.paymentMethod,
244 customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null,
245 address: useAsDefaultBillingAddress ? paymentResult.address : null,
246 taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null,
247 }
248 } else {
249 return {
250 paymentMethod: { id: selectedPaymentMethod } as PaymentMethod,
251 customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null,
252 address: useAsDefaultBillingAddress ? (customerProfile?.address ?? null) : null,
253 taxId: useAsDefaultBillingAddress ? (taxId ?? null) : null,
254 }
255 }
256 }
257
258 useImperativeHandle(ref, () => ({
259 createPaymentMethod,
260 validateBillingProfile,
261 }))
262
263 return (
264 <>
265 <HCaptcha
266 ref={captchaRefCallback}
267 sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
268 size="invisible"
269 onOpen={() => {
270 // [Joshen] This is to ensure that hCaptcha popup remains clickable
271 if (document !== undefined) document.body.classList.add('pointer-events-auto!')
272 }}
273 onClose={() => {
274 setSetupIntent(undefined)
275 if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
276 }}
277 onVerify={(token) => {
278 setCaptchaToken(token)
279 if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
280 }}
281 onExpire={() => {
282 setCaptchaToken(null)
283 }}
284 />
285
286 <div>
287 {isLoading || isCustomerProfileLoading ? (
288 <div className="flex items-center px-4 py-2 space-x-4 border rounded-md border-strong bg-surface-200">
289 <Loader className="animate-spin" size={14} />
290 <p className="text-sm text-foreground-light">Retrieving payment methods</p>
291 </div>
292 ) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
293 <FormItemLayout
294 id="payment-method"
295 isReactForm={false}
296 layout={layout}
297 label="Payment method"
298 className="gap-[2px]"
299 size="tiny"
300 >
301 <Select
302 value={selectedPaymentMethod}
303 onValueChange={(value) => {
304 if (value === 'new') {
305 setSetupNewPaymentMethod(true)
306 return
307 }
308 onSelectPaymentMethod(value)
309 }}
310 >
311 <SelectTrigger id="payment-method">
312 <SelectValue className="flex gap-2" />
313 </SelectTrigger>
314 <SelectContent>
315 {paymentMethods?.data.map((method) => {
316 const label = `•••• •••• •••• ${method.card?.last4}`
317 return (
318 <SelectItem key={method.id} value={method.id}>
319 <div className="flex gap-2">
320 <img
321 alt="Credit Card Brand"
322 src={`${BASE_PATH}/img/payment-methods/${method.card?.brand
323 .replace(' ', '-')
324 .toLowerCase()}.png`}
325 width="32"
326 />
327 {label}
328 </div>
329 </SelectItem>
330 )
331 })}
332 <SelectItem value="new">
333 <div className="flex gap-2">
334 <Plus size={16} />
335 <p className="transition text-foreground-light group-hover:text-foreground">
336 Add new payment method
337 </p>
338 </div>
339 </SelectItem>
340 </SelectContent>
341 </Select>
342 </FormItemLayout>
343 ) : null}
344
345 {stripePromise && setupIntent && customerProfile && (
346 <>
347 <Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
348 <NewPaymentMethodElement
349 ref={paymentRef}
350 email={selectedOrganization?.billing_email ?? undefined}
351 readOnly={readOnly}
352 customerName={customerProfile?.billing_name}
353 currentAddress={customerProfile?.address}
354 currentTaxId={taxId}
355 onAddressChange={onAddressChange}
356 onTaxIdChange={onTaxIdChange}
357 />
358 </Elements>
359
360 {/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */}
361 {customerProfile?.address != null && (
362 <div className="flex items-center space-x-2 mt-4">
363 <Checkbox
364 id="defaultBillingAddress"
365 checked={useAsDefaultBillingAddress}
366 onCheckedChange={() => {
367 onUseAsDefaultBillingAddressChange(!useAsDefaultBillingAddress)
368 }}
369 />
370 <label
371 htmlFor="defaultBillingAddress"
372 className="text-sm leading-none text-foreground-light"
373 >
374 Use address as my org's billing address
375 </label>
376 </div>
377 )}
378 </>
379 )}
380
381 {(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
382 <div className="space-y-2">
383 <ShimmeringLoader className="h-10" />
384 <div className="grid grid-cols-2 gap-4">
385 <ShimmeringLoader className="h-10" />
386 <ShimmeringLoader className="h-10" />
387 </div>
388 <ShimmeringLoader className="h-10" />
389 </div>
390 )}
391 </div>
392 </>
393 )
394})
395
396PaymentMethodSelection.displayName = 'PaymentMethodSelection'
397
398export default PaymentMethodSelection