import { useQueryClient } from '@tanstack/react-query' import { useRef, useState } from 'react' import { toast } from 'sonner' import { Button, Checkbox, Label, Modal } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { NewPaymentMethodElement, type PaymentMethodElementRef, } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement' import { organizationKeys } from '@/data/organizations/keys' import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query' import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation' import { useOrganizationPaymentMethodMarkAsDefaultMutation } from '@/data/organizations/organization-payment-method-default-mutation' import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' interface AddPaymentMethodFormProps { returnUrl: string onCancel: () => void onConfirm: () => void } // Stripe docs recommend to use the new SetupIntent flow over // manually creating and attaching payment methods via the API // Small UX annoyance here, that the page will be refreshed const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => { const { data: selectedOrganization } = useSelectedOrganizationQuery() const { data: customerProfile, isPending: customerProfileLoading } = useOrganizationCustomerProfileQuery({ slug: selectedOrganization?.slug, }) const [isSaving, setIsSaving] = useState(false) const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true) const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true) const queryClient = useQueryClient() const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation() const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({ onError: () => {}, }) const { data: taxId, isPending: isCustomerTaxIdLoading, isError: isTaxIdError, } = useOrganizationTaxIdQuery({ slug: selectedOrganization?.slug, }) const paymentRef = useRef(null) const handleSubmit = async (event: any) => { event.preventDefault() setIsSaving(true) if (document !== undefined) { // [Joshen] This is to ensure that any 3DS popup from Stripe remains clickable document.body.classList.add('pointer-events-auto!') } if (isPrimaryBillingAddress && isTaxIdError) { toast.error('Unable to load current tax ID. Please try again.') setIsSaving(false) if (document !== undefined) { document.body.classList.remove('pointer-events-auto!') } return } // Validate address/tax ID with a dry run before proceeding with Stripe, // so validation errors (e.g. invalid tax ID) block the flow early. const formValues = isPrimaryBillingAddress ? await paymentRef.current?.getFormValues() : undefined if (isPrimaryBillingAddress && !formValues) { setIsSaving(false) if (document !== undefined) { document.body.classList.remove('pointer-events-auto!') } return } if (isPrimaryBillingAddress && formValues) { try { await updateCustomerProfile({ slug: selectedOrganization?.slug, address: formValues.address, billing_name: formValues.customerName, tax_id: formValues.taxId, dry_run: true, }) } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to validate billing profile') setIsSaving(false) if (document !== undefined) { document.body.classList.remove('pointer-events-auto!') } return } } // Dry run passed — proceed with Stripe payment method creation / 3DS const result = await paymentRef.current?.confirmSetup() if (!result) { setIsSaving(false) } else { // Stripe succeeded — persist the customer profile update for real if (isPrimaryBillingAddress && formValues) { try { await updateCustomerProfile({ slug: selectedOrganization?.slug, address: formValues.address, billing_name: formValues.customerName, tax_id: formValues.taxId, }) } catch { toast.error( 'Your payment method was added successfully, but we could not save your billing address. Please update it in your organization settings.' ) } } if ( isDefaultPaymentMethod && selectedOrganization && typeof result.setupIntent?.payment_method === 'string' ) { try { await markAsDefault({ slug: selectedOrganization.slug, paymentMethodId: result.setupIntent.payment_method, }) await queryClient.invalidateQueries({ queryKey: organizationKeys.paymentMethods(selectedOrganization.slug), }) queryClient.setQueriesData( { queryKey: organizationKeys.paymentMethods(selectedOrganization.slug) }, (prev: any) => { if (!prev) return prev return { ...prev, defaultPaymentMethodId: result.setupIntent.payment_method, data: prev.data.map((pm: any) => ({ ...pm, is_default: pm.id === result.setupIntent.payment_method, })), } } ) } catch (error) { toast.error('Failed to set payment method as default') } } else { if (selectedOrganization) { await queryClient.invalidateQueries({ queryKey: organizationKeys.paymentMethods(selectedOrganization.slug), }) } } setIsSaving(false) onConfirm() } if (document !== undefined) { document.body.classList.remove('pointer-events-auto!') } } if (customerProfileLoading || isCustomerTaxIdLoading) { return (
) } return (
{ if (typeof checked === 'boolean') { setIsDefaultPaymentMethod(checked) } }} />
{ if (typeof checked === 'boolean') { setIsPrimaryBillingAddress(checked) } }} />
) } export default AddPaymentMethodForm