From 69e00c6bbc87b8839811de55db9a80c71b0da898 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Tue, 14 Apr 2026 13:42:34 -0300 Subject: [PATCH] feat: validate and save tax id using the customer endpoint (#44682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reorder AddPaymentMethodForm and PaymentMethodSelection to use a dry run validation → Stripe payment setup → real update flow: - Validate address and tax ID via `dry_run: true` before touching Stripe - Proceed with Stripe payment method creation / 3DS only if validation passes - Persist the customer profile update with dry run disabled after Stripe succeeds - Add `dry_run` support to `useOrganizationCustomerProfileUpdateMutation` - Add `getFormValues()` to `PaymentMethodElementRef` to read address and tax ID form state - Delete the now-unused `organization-tax-id-update-mutation.ts` ## Test plan ### Adding a Payment Method From the billing dashboard `/org/_/billing`: - [ ]  Add a payment method with a valid tax ID: should dry-run validate, then create the payment method via Stripe, then persist the billing profile - [ ]  Add a payment method with an invalid tax ID: should show a validation error from the dry run and not proceed to Stripe payment setup - [ ]  Add a payment method with the "primary billing address" checkbox unchecked: should skip the customer profile update entirely and only create the payment method - [ ]  Add a payment method where Stripe 3DS fails: billing profile should not be persisted (only the dry run ran) ### Credit Top Up - [ ]  Top up credits with a new payment method and valid tax ID: should dry-run validate, then create the payment method, then process the top-up - [ ]  Top up credits with a new payment method and invalid tax ID: should show a validation error from the dry run and not proceed to Stripe payment method creation ### Plan Upgrade - [ ]  Upgrade plan with a new payment method and valid tax ID: should dry-run validate before creating the payment method - [ ]  Upgrade plan with a new payment method and invalid tax ID: should show a validation error and not proceed to Stripe - [ ]  Upgrade plan with an existing payment method: should proceed without dry-run validation --- .../Billing/Payment/AddPaymentMethodForm.tsx | 94 +++++++++++++------ .../NewPaymentMethodElement.tsx | 68 ++++++++++++-- .../BillingSettings/CreditTopUp.tsx | 4 + .../Subscription/PaymentMethodSelection.tsx | 56 ++++++++++- .../SubscriptionPlanUpdateDialog.tsx | 9 ++ ...zation-customer-profile-update-mutation.ts | 27 +++++- .../organization-tax-id-update-mutation.ts | 85 ----------------- 7 files changed, 217 insertions(+), 126 deletions(-) delete mode 100644 apps/studio/data/organizations/organization-tax-id-update-mutation.ts diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index c58af7e7c70..fcd6b9c6a43 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -1,5 +1,4 @@ import { useQueryClient } from '@tanstack/react-query' -import { isEqual } from 'lodash' import { useRef, useState } from 'react' import { toast } from 'sonner' import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui' @@ -14,7 +13,6 @@ import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organi 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 { useOrganizationTaxIdUpdateMutation } from '@/data/organizations/organization-tax-id-update-mutation' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' interface AddPaymentMethodFormProps { @@ -41,9 +39,14 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps const queryClient = useQueryClient() const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation() - const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() - const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation() - const { data: taxId, isPending: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ + const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({ + onError: () => {}, + }) + const { + data: taxId, + isPending: isCustomerTaxIdLoading, + isError: isTaxIdError, + } = useOrganizationTaxIdQuery({ slug: selectedOrganization?.slug, }) @@ -59,11 +62,70 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps 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 && @@ -104,28 +166,6 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps } } - if (isPrimaryBillingAddress) { - try { - if ( - result.address && - (!isEqual(result.address, customerProfile?.address) || - customerProfile?.billing_name !== result.customerName) - ) { - await updateCustomerProfile({ - slug: selectedOrganization?.slug, - billing_name: result.customerName, - address: result.address, - }) - } - - if (result.taxId && !isEqual(result.taxId, taxId)) { - await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug }) - } - } catch (error) { - toast.error('Failed to update billing address') - } - } - setIsSaving(false) onConfirm() } diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx index 3a5e6d6165e..d021f3b0eb0 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx @@ -75,6 +75,14 @@ export type PaymentMethodElementRef = { } | undefined > + getFormValues: () => Promise< + | { + address: CustomerAddress + customerName: string + taxId: CustomerTaxId | null + } + | undefined + > } export const NewPaymentMethodElement = forwardRef( @@ -162,9 +170,13 @@ export const NewPaymentMethodElement = forwardRef( PaymentMethodElementRef['createPaymentMethod'] > => { if (!stripe || !elements) return - await form.trigger() + const isValid = await form.trigger() - if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) { + if ( + purchasingAsBusiness && + availableTaxIds.length > 0 && + (!isValid || !form.getValues('tax_id_value')) + ) { return } @@ -192,7 +204,8 @@ export const NewPaymentMethodElement = forwardRef( } function getConfiguredTaxId(): CustomerTaxId | null { - return purchasingAsBusiness && selectedTaxId + const isValidForCountry = selectedTaxId && availableTaxIds.includes(selectedTaxId) + return purchasingAsBusiness && isValidForCountry ? { country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, @@ -229,9 +242,37 @@ export const NewPaymentMethodElement = forwardRef( } } + const getFormValues = async (): ReturnType => { + if (!elements) return + + const isValid = await form.trigger() + if ( + purchasingAsBusiness && + availableTaxIds.length > 0 && + (!isValid || !form.getValues('tax_id_value')) + ) { + return + } + + const { error: submitError } = await elements.submit() + if (submitError) return + + const addressElement = await elements.getElement('address')!.getValue() + + return { + address: { + ...addressElement.value.address, + line2: addressElement.value.address.line2 || undefined, + }, + customerName: addressElement.value.name, + taxId: getConfiguredTaxId(), + } + } + useImperativeHandle(ref, () => ({ createPaymentMethod, confirmSetup, + getFormValues, })) const addressOptions: StripeAddressElementOptions = useMemo( @@ -254,10 +295,11 @@ export const NewPaymentMethodElement = forwardRef( [purchasingAsBusiness] ) - // Preselect tax id when the country changes (if there are available tax ids for that country) + // Reset tax ID fields when the billing country changes and preselect the + // first available tax ID for the new country. const prevCountryRef = useRef(addressCountry) useEffect(() => { - if (!availableTaxIds.length || !addressCountry) return + if (!addressCountry) return const isCountryChange = prevCountryRef.current !== undefined && prevCountryRef.current !== addressCountry @@ -266,12 +308,18 @@ export const NewPaymentMethodElement = forwardRef( // On country change: always reset to the new country's default // On initial load: only preselect if there's no existing tax id if (isCountryChange || !currentTaxId) { - const taxIdOption = availableTaxIds[0] - form.setValue('tax_id_type', taxIdOption.type) - form.setValue('tax_id_value', '') - form.setValue('tax_id_name', taxIdOption.name) + if (availableTaxIds.length) { + const taxIdOption = availableTaxIds[0] + form.setValue('tax_id_type', taxIdOption.type) + form.setValue('tax_id_value', '') + form.setValue('tax_id_name', taxIdOption.name) + } else { + form.setValue('tax_id_type', '') + form.setValue('tax_id_value', '') + form.setValue('tax_id_name', '') + } } - }, [availableTaxIds, addressCountry, currentTaxId]) + }, [availableTaxIds, addressCountry, currentTaxId, form]) return (
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 18f779c8b3b..6b8f1843e7e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -62,6 +62,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const queryClient = useQueryClient() const paymentMethodSelectionRef = useRef<{ createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] + validateBillingProfile: () => Promise }>(null) const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( @@ -129,6 +130,9 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const token = await initHcaptcha() + const isValid = await paymentMethodSelectionRef.current?.validateBillingProfile() + if (!isValid) return + const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod() if (!paymentMethodResult) { return diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 82a0122acfb..b6ed48e4174 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -23,6 +23,7 @@ import { type PaymentMethodElementRef, } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement' import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query' +import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation' import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation' import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query' import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query' @@ -69,7 +70,14 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( useOrganizationCustomerProfileQuery({ slug, }) - const { data: taxId, isPending: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug }) + const { + data: taxId, + isPending: isCustomerTaxIdLoading, + isError: isTaxIdError, + } = useOrganizationTaxIdQuery({ slug }) + const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({ + onError: () => {}, + }) const { data: allPaymentMethods, isPending: isLoading } = useOrganizationPaymentMethodsQuery({ slug, @@ -176,6 +184,51 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( } }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod]) + const getFormValues = async (): ReturnType => { + if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { + return paymentRef.current?.getFormValues() + } else { + return { + address: customerProfile?.address ?? ({} as CustomerAddress), + customerName: customerProfile?.billing_name || '', + taxId: taxId ?? null, + } + } + } + + // 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 validateBillingProfile = async (): Promise => { + if (!useAsDefaultBillingAddress) return true + + if (isTaxIdError || isCustomerTaxIdLoading) { + toast.error( + isTaxIdError + ? 'Unable to load current tax ID. Please try again.' + : 'Tax ID is still loading. Please wait and try again.' + ) + return false + } + + const formValues = await getFormValues() + if (!formValues) return false + + try { + await updateCustomerProfile({ + 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') + return false + } + + return true + } + // If createPaymentMethod already exists, use it. Otherwise, define it here. const createPaymentMethod = async (): ReturnType< PaymentMethodElementRef['createPaymentMethod'] @@ -203,6 +256,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( useImperativeHandle(ref, () => ({ createPaymentMethod, + validateBillingProfile, })) return ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 85fbca4f2ce..a8e89faf09e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -101,6 +101,7 @@ export const SubscriptionPlanUpdateDialog = ({ const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const paymentMethodSelectionRef = useRef<{ createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] + validateBillingProfile: () => Promise }>(null) const billingViaPartner = subscription?.billing_via_partner === true @@ -180,6 +181,14 @@ export const SubscriptionPlanUpdateDialog = ({ setPaymentConfirmationLoading(true) + if (paymentMethodSelectionRef.current) { + const isValid = await paymentMethodSelectionRef.current.validateBillingProfile() + if (!isValid) { + setPaymentConfirmationLoading(false) + return + } + } + const result = await paymentMethodSelectionRef.current?.createPaymentMethod() if (result) { setSelectedPaymentMethod(result.paymentMethod.id) diff --git a/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts b/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts index 21541271a41..e1d347977dc 100644 --- a/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts +++ b/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts @@ -12,6 +12,8 @@ export type OrganizationCustomerProfileUpdateVariables = { billing_name: string /** Pass a tax ID object to set/update, `null` to clear, or `undefined` to leave unchanged */ tax_id?: CustomerTaxId | null + /** When true, validates the request without persisting changes */ + dry_run?: boolean } export async function updateOrganizationCustomerProfile({ @@ -19,6 +21,7 @@ export async function updateOrganizationCustomerProfile({ address, billing_name, tax_id, + dry_run, }: OrganizationCustomerProfileUpdateVariables) { if (!slug) return console.error('Slug is required') @@ -36,6 +39,7 @@ export async function updateOrganizationCustomerProfile({ : tax_id !== undefined ? { tax_id } : {}), + ...(dry_run ? { dry_run } : {}), }, }) if (error) throw handleError(error) @@ -67,9 +71,14 @@ export const useOrganizationCustomerProfileUpdateMutation = ({ >({ mutationFn: (vars) => updateOrganizationCustomerProfile(vars), async onSuccess(data, variables, context) { - const { address, slug, billing_name, tax_id } = variables + const { address, slug, billing_name, tax_id, dry_run } = variables - // We do not invalidate here as GET endpoint data is stale for 1-2 seconds, so we handle state manually + if (dry_run) { + await onSuccess?.(data, variables, context) + return + } + + // Optimistically update the cache for immediate UI consistency queryClient.setQueriesData( { queryKey: organizationKeys.customerProfile(slug) }, (prev: any) => { @@ -82,11 +91,23 @@ export const useOrganizationCustomerProfileUpdateMutation = ({ } ) - // Update tax ID cache if tax_id was part of this update if (tax_id !== undefined) { queryClient.setQueryData(organizationKeys.taxId(slug), tax_id) } + // Refetch after a delay to pick up server-canonical values (e.g. normalized tax IDs). + // The GET endpoint can be stale for 1-2 seconds after an update. + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.customerProfile(slug), + }) + if (tax_id !== undefined) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.taxId(slug), + }) + } + }, 3000) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/organizations/organization-tax-id-update-mutation.ts b/apps/studio/data/organizations/organization-tax-id-update-mutation.ts deleted file mode 100644 index cc2bd622998..00000000000 --- a/apps/studio/data/organizations/organization-tax-id-update-mutation.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { organizationKeys } from './keys' -import { invalidateOrganizationsQuery } from './organizations-query' -import { del, handleError, put } from '@/data/fetchers' -import type { ResponseError, UseCustomMutationOptions } from '@/types' - -export type OrganizationTaxIdUpdateVariables = { - slug?: string - taxId: { type: string; value: string; country?: string } | null -} - -export async function updateOrganizationTaxId({ slug, taxId }: OrganizationTaxIdUpdateVariables) { - if (!slug) { - throw new Error('Slug is required') - } - - if (taxId != null) { - const { data, error } = await put(`/platform/organizations/{slug}/tax-ids`, { - params: { - path: { - slug, - }, - }, - body: { type: taxId.type, value: taxId.value, country: taxId.country }, - }) - - if (error) handleError(error) - - return data - } else { - // @ts-ignore wrong type due to API versioning - const { data, error } = await del(`/platform/organizations/{slug}/tax-ids`, { - params: { - path: { - slug, - }, - }, - headers: { - Version: '2', - }, - }) - - if (error) handleError(error) - - return data - } -} - -type OrganizationTaxIdUpdateData = Awaited> - -export const useOrganizationTaxIdUpdateMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseCustomMutationOptions< - OrganizationTaxIdUpdateData, - ResponseError, - OrganizationTaxIdUpdateVariables - >, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (vars) => updateOrganizationTaxId(vars), - async onSuccess(data, variables, context) { - const { slug } = variables - - // We already have the data, no need to refetch - queryClient.setQueryData(organizationKeys.taxId(slug), data.tax_id) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to update tax id: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - }) -}