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