PaymentMethodSelection.tsx 14 KB

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