AddPaymentMethodForm.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { useQueryClient } from '@tanstack/react-query'
  2. import { useRef, useState } from 'react'
  3. import { toast } from 'sonner'
  4. import { Button, Checkbox, Label, Modal } from 'ui'
  5. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  6. import {
  7. NewPaymentMethodElement,
  8. type PaymentMethodElementRef,
  9. } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
  10. import { organizationKeys } from '@/data/organizations/keys'
  11. import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
  12. import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
  13. import { useOrganizationPaymentMethodMarkAsDefaultMutation } from '@/data/organizations/organization-payment-method-default-mutation'
  14. import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
  15. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  16. interface AddPaymentMethodFormProps {
  17. returnUrl: string
  18. onCancel: () => void
  19. onConfirm: () => void
  20. }
  21. // Stripe docs recommend to use the new SetupIntent flow over
  22. // manually creating and attaching payment methods via the API
  23. // Small UX annoyance here, that the page will be refreshed
  24. const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => {
  25. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  26. const { data: customerProfile, isPending: customerProfileLoading } =
  27. useOrganizationCustomerProfileQuery({
  28. slug: selectedOrganization?.slug,
  29. })
  30. const [isSaving, setIsSaving] = useState(false)
  31. const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true)
  32. const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true)
  33. const queryClient = useQueryClient()
  34. const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
  35. const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({
  36. onError: () => {},
  37. })
  38. const {
  39. data: taxId,
  40. isPending: isCustomerTaxIdLoading,
  41. isError: isTaxIdError,
  42. } = useOrganizationTaxIdQuery({
  43. slug: selectedOrganization?.slug,
  44. })
  45. const paymentRef = useRef<PaymentMethodElementRef | null>(null)
  46. const handleSubmit = async (event: any) => {
  47. event.preventDefault()
  48. setIsSaving(true)
  49. if (document !== undefined) {
  50. // [Joshen] This is to ensure that any 3DS popup from Stripe remains clickable
  51. document.body.classList.add('pointer-events-auto!')
  52. }
  53. if (isPrimaryBillingAddress && isTaxIdError) {
  54. toast.error('Unable to load current tax ID. Please try again.')
  55. setIsSaving(false)
  56. if (document !== undefined) {
  57. document.body.classList.remove('pointer-events-auto!')
  58. }
  59. return
  60. }
  61. // Validate address/tax ID with a dry run before proceeding with Stripe,
  62. // so validation errors (e.g. invalid tax ID) block the flow early.
  63. const formValues = isPrimaryBillingAddress
  64. ? await paymentRef.current?.getFormValues()
  65. : undefined
  66. if (isPrimaryBillingAddress && !formValues) {
  67. setIsSaving(false)
  68. if (document !== undefined) {
  69. document.body.classList.remove('pointer-events-auto!')
  70. }
  71. return
  72. }
  73. if (isPrimaryBillingAddress && formValues) {
  74. try {
  75. await updateCustomerProfile({
  76. slug: selectedOrganization?.slug,
  77. address: formValues.address,
  78. billing_name: formValues.customerName,
  79. tax_id: formValues.taxId,
  80. dry_run: true,
  81. })
  82. } catch (error) {
  83. toast.error(error instanceof Error ? error.message : 'Failed to validate billing profile')
  84. setIsSaving(false)
  85. if (document !== undefined) {
  86. document.body.classList.remove('pointer-events-auto!')
  87. }
  88. return
  89. }
  90. }
  91. // Dry run passed — proceed with Stripe payment method creation / 3DS
  92. const result = await paymentRef.current?.confirmSetup()
  93. if (!result) {
  94. setIsSaving(false)
  95. } else {
  96. // Stripe succeeded — persist the customer profile update for real
  97. if (isPrimaryBillingAddress && formValues) {
  98. try {
  99. await updateCustomerProfile({
  100. slug: selectedOrganization?.slug,
  101. address: formValues.address,
  102. billing_name: formValues.customerName,
  103. tax_id: formValues.taxId,
  104. })
  105. } catch {
  106. toast.error(
  107. 'Your payment method was added successfully, but we could not save your billing address. Please update it in your organization settings.'
  108. )
  109. }
  110. }
  111. if (
  112. isDefaultPaymentMethod &&
  113. selectedOrganization &&
  114. typeof result.setupIntent?.payment_method === 'string'
  115. ) {
  116. try {
  117. await markAsDefault({
  118. slug: selectedOrganization.slug,
  119. paymentMethodId: result.setupIntent.payment_method,
  120. })
  121. await queryClient.invalidateQueries({
  122. queryKey: organizationKeys.paymentMethods(selectedOrganization.slug),
  123. })
  124. queryClient.setQueriesData(
  125. { queryKey: organizationKeys.paymentMethods(selectedOrganization.slug) },
  126. (prev: any) => {
  127. if (!prev) return prev
  128. return {
  129. ...prev,
  130. defaultPaymentMethodId: result.setupIntent.payment_method,
  131. data: prev.data.map((pm: any) => ({
  132. ...pm,
  133. is_default: pm.id === result.setupIntent.payment_method,
  134. })),
  135. }
  136. }
  137. )
  138. } catch (error) {
  139. toast.error('Failed to set payment method as default')
  140. }
  141. } else {
  142. if (selectedOrganization) {
  143. await queryClient.invalidateQueries({
  144. queryKey: organizationKeys.paymentMethods(selectedOrganization.slug),
  145. })
  146. }
  147. }
  148. setIsSaving(false)
  149. onConfirm()
  150. }
  151. if (document !== undefined) {
  152. document.body.classList.remove('pointer-events-auto!')
  153. }
  154. }
  155. if (customerProfileLoading || isCustomerTaxIdLoading) {
  156. return (
  157. <Modal.Content>
  158. <div className="space-y-2">
  159. <ShimmeringLoader />
  160. <ShimmeringLoader className="w-3/4" />
  161. <ShimmeringLoader className="w-1/2" />
  162. <ShimmeringLoader />
  163. <ShimmeringLoader />
  164. <ShimmeringLoader />
  165. </div>
  166. </Modal.Content>
  167. )
  168. }
  169. return (
  170. <div>
  171. <Modal.Content
  172. className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
  173. >
  174. <NewPaymentMethodElement
  175. readOnly={isSaving}
  176. email={selectedOrganization?.billing_email}
  177. currentAddress={customerProfile?.address}
  178. customerName={customerProfile?.billing_name}
  179. currentTaxId={taxId}
  180. ref={paymentRef}
  181. />
  182. <div className="flex items-center gap-x-2 mt-4 mb-2">
  183. <Checkbox
  184. id="save-as-default"
  185. checked={isDefaultPaymentMethod}
  186. onCheckedChange={(checked) => {
  187. if (typeof checked === 'boolean') {
  188. setIsDefaultPaymentMethod(checked)
  189. }
  190. }}
  191. />
  192. <Label htmlFor="save-as-default" className="text-foreground-light">
  193. Save as default payment method
  194. </Label>
  195. </div>
  196. <div className="flex items-center gap-x-2 mt-4 mb-2">
  197. <Checkbox
  198. id="is-primary-billing-address"
  199. checked={isPrimaryBillingAddress}
  200. onCheckedChange={(checked) => {
  201. if (typeof checked === 'boolean') {
  202. setIsPrimaryBillingAddress(checked)
  203. }
  204. }}
  205. />
  206. <Label htmlFor="is-primary-billing-address" className="text-foreground-light">
  207. Use the billing address as my organization's primary address
  208. </Label>
  209. </div>
  210. </Modal.Content>
  211. <Modal.Separator />
  212. <Modal.Content className="flex items-center space-x-2">
  213. <Button
  214. htmlType="button"
  215. size="small"
  216. type="default"
  217. onClick={onCancel}
  218. block
  219. disabled={isSaving}
  220. >
  221. Cancel
  222. </Button>
  223. <Button
  224. block
  225. htmlType="button"
  226. size="small"
  227. type="primary"
  228. loading={isSaving}
  229. disabled={isSaving}
  230. onClick={handleSubmit}
  231. >
  232. Add payment method
  233. </Button>
  234. </Modal.Content>
  235. </div>
  236. )
  237. }
  238. export default AddPaymentMethodForm