From f6eb9a024ed60d0dff205b94688dbbc47d1700fa Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Tue, 7 Apr 2026 13:02:49 -0300 Subject: [PATCH] feat: refresh tax preview on address change (#44470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Passes billing address and tax ID from the payment form to the subscription preview endpoint, so taxes are recalculated live as the user updates their details - Debounces address/tax ID changes (1s) in `NewPaymentMethodElement` to avoid excessive API calls while typing - Decouples the preview refetch from the payment form's mount state - uses `keepPreviousData` so the form stays mounted and the breakdown dims with `opacity-50` instead of unmounting/remounting during refetches. Shimmer skeleton only shows on initial load. - Disables the "Confirm upgrade" button while a refetch is in progress to prevent submitting with stale tax data - Respects the "Use address as my org's billing address" checkbox: the preview should mirror what will actually happen - if the checkbox is unchecked, the address won't be saved to Orb, so it shouldn't be used for the tax estimate either. Otherwise the user sees one price and gets charged another. The logic for this lives in `PlanUpdateSidePanel` ``` mermaid flowchart TD A[User opens upgrade dialog] --> B[Fetch subscription preview] B --> C[Show payment form + price breakdown] C --> D[User edits address or tax ID] D -->|1s debounce| E{Use as billing address?} E -->|Yes| F[Re-fetch preview with new address] E -->|No| G[Re-fetch preview without address override] F --> H[Update breakdown — form stays mounted] G --> H H --> C ``` ## Test plan - [x]  Open the plan upgrade dialog, verify the payment form and breakdown load normally on first open - [x]  Change the billing address country - verify the breakdown dims briefly and updates with new tax amounts without the payment form unmounting - [x]  Toggle the tax ID on/off and change its value - verify the preview refreshes after ~1s debounce - [x]  Confirm the upgrade button is disabled while the preview is refetching - [x]  Uncheck "Use address as my org's billing address" - verify the preview refetches without address/tax overrides - [x]  Re-check the checkbox - verify the preview refetches again with the current form address - [x] Assert that adding a new billing address in the CreditTopUp form works and saves the address ## Summary by CodeRabbit * **New Features** * Payment and subscription flows now propagate billing address and tax ID via new callbacks; subscription preview requests include these values and preserve prior results while fetching. * Preview updates are debounced to reduce noise; loading state disables confirm actions and visually dims charge breakdown. * **UX** * Address input emits complete normalized address updates (empty second line cleared). * Tax ID input emits updates and explicit clears (null). * “Use address as my org's billing address” is now controlled and reports changes. --- .../NewPaymentMethodElement.tsx | 28 ++++++++++- .../BillingSettings/CreditTopUp.tsx | 3 ++ .../Subscription/PaymentMethodSelection.tsx | 16 ++++++- .../Subscription/PlanUpdateSidePanel.tsx | 40 +++++++++++++++- .../SubscriptionPlanUpdateDialog.tsx | 48 ++++++++++++++----- apps/studio/data/organizations/keys.ts | 7 ++- ...ganization-billing-subscription-preview.ts | 20 ++++++-- 7 files changed, 137 insertions(+), 25 deletions(-) diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx index 3710afb8300..6fd104901f4 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx @@ -85,12 +85,16 @@ export const NewPaymentMethodElement = forwardRef( currentAddress, currentTaxId, customerName, + onAddressChange, + onTaxIdChange, }: { email?: string | null | undefined readOnly: boolean currentAddress?: CustomerAddress | null currentTaxId?: CustomerTaxId | null customerName?: string | undefined + onAddressChange?: (address: CustomerAddress) => void + onTaxIdChange?: (taxId: CustomerTaxId | null) => void }, ref ) => { @@ -123,13 +127,25 @@ export const NewPaymentMethodElement = forwardRef( form.setValue('tax_id_name', name) } - const { tax_id_name } = form.watch() + const { tax_id_name, tax_id_value } = form.watch() const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null) const [stripeAddress, setStripeAddress] = useState< StripeAddressElementChangeEvent['value'] | undefined >(undefined) + useEffect(() => { + if (!onTaxIdChange) return + if (purchasingAsBusiness && selectedTaxId && tax_id_value) { + onTaxIdChange({ + country: getEffectiveTaxCountry(selectedTaxId), + type: selectedTaxId.type, + value: tax_id_value, + }) + } else { + onTaxIdChange(null) + } + }, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange]) const availableTaxIds = useMemo(() => { const country = stripeAddress?.address.country || null @@ -272,7 +288,15 @@ export const NewPaymentMethodElement = forwardRef( options={addressOptions} // Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise key={`address-elements-${purchasingAsBusiness}`} - onChange={(evt) => setStripeAddress(evt.value)} + onChange={(evt) => { + setStripeAddress(evt.value) + if (onAddressChange && evt.complete) { + onAddressChange({ + ...evt.value.address, + line2: evt.value.address.line2 || undefined, + }) + } + }} onReady={() => setFullyLoaded(true)} /> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 4eb34ecbf72..caa483e390b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -84,6 +84,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { }) const [topUpModalVisible, setTopUpModalVisible] = useState(false) + const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) @@ -281,6 +282,8 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} readOnly={executingTopUp || paymentConfirmationLoading} + useAsDefaultBillingAddress={useAsDefaultBillingAddress} + onUseAsDefaultBillingAddressChange={setUseAsDefaultBillingAddress} /> )} /> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 3eccf239531..82a0122acfb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -26,6 +26,7 @@ import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organi 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' +import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { BASE_PATH, STRIPE_PUBLIC_KEY } from '@/lib/constants' @@ -37,6 +38,10 @@ export interface PaymentMethodSelectionProps { onSelectPaymentMethod: (id: string) => void layout?: 'vertical' | 'horizontal' readOnly: boolean + onAddressChange?: (address: CustomerAddress) => void + onTaxIdChange?: (taxId: CustomerTaxId | null) => void + useAsDefaultBillingAddress: boolean + onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void } const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( @@ -45,6 +50,10 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( onSelectPaymentMethod, layout = 'vertical', readOnly, + onAddressChange, + onTaxIdChange, + useAsDefaultBillingAddress, + onUseAsDefaultBillingAddressChange, }: PaymentMethodSelectionProps, ref ) { @@ -53,7 +62,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) const [setupIntent, setSetupIntent] = useState(undefined) - const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true) const { resolvedTheme } = useTheme() const paymentRef = useRef(null) const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(null) @@ -281,6 +289,8 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( customerName={customerProfile?.billing_name} currentAddress={customerProfile?.address} currentTaxId={taxId} + onAddressChange={onAddressChange} + onTaxIdChange={onTaxIdChange} /> @@ -290,7 +300,9 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)} + onCheckedChange={() => { + onUseAsDefaultBillingAddressChange(!useAsDefaultBillingAddress) + }} />