| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- 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<PaymentMethodElementRef | null>(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 (
- <Modal.Content>
- <div className="space-y-2">
- <ShimmeringLoader />
- <ShimmeringLoader className="w-3/4" />
- <ShimmeringLoader className="w-1/2" />
- <ShimmeringLoader />
- <ShimmeringLoader />
- <ShimmeringLoader />
- </div>
- </Modal.Content>
- )
- }
- return (
- <div>
- <Modal.Content
- className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
- >
- <NewPaymentMethodElement
- readOnly={isSaving}
- email={selectedOrganization?.billing_email}
- currentAddress={customerProfile?.address}
- customerName={customerProfile?.billing_name}
- currentTaxId={taxId}
- ref={paymentRef}
- />
- <div className="flex items-center gap-x-2 mt-4 mb-2">
- <Checkbox
- id="save-as-default"
- checked={isDefaultPaymentMethod}
- onCheckedChange={(checked) => {
- if (typeof checked === 'boolean') {
- setIsDefaultPaymentMethod(checked)
- }
- }}
- />
- <Label htmlFor="save-as-default" className="text-foreground-light">
- Save as default payment method
- </Label>
- </div>
- <div className="flex items-center gap-x-2 mt-4 mb-2">
- <Checkbox
- id="is-primary-billing-address"
- checked={isPrimaryBillingAddress}
- onCheckedChange={(checked) => {
- if (typeof checked === 'boolean') {
- setIsPrimaryBillingAddress(checked)
- }
- }}
- />
- <Label htmlFor="is-primary-billing-address" className="text-foreground-light">
- Use the billing address as my organization's primary address
- </Label>
- </div>
- </Modal.Content>
- <Modal.Separator />
- <Modal.Content className="flex items-center space-x-2">
- <Button
- htmlType="button"
- size="small"
- type="default"
- onClick={onCancel}
- block
- disabled={isSaving}
- >
- Cancel
- </Button>
- <Button
- block
- htmlType="button"
- size="small"
- type="primary"
- loading={isSaving}
- disabled={isSaving}
- onClick={handleSubmit}
- >
- Add payment method
- </Button>
- </Modal.Content>
- </div>
- )
- }
- export default AddPaymentMethodForm
|