Skip to content

Commit f6eb9a0

Browse files
authored
feat: refresh tax preview on address change (supabase#44470)
## 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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9925306 commit f6eb9a0

File tree

7 files changed

+137
-25
lines changed

7 files changed

+137
-25
lines changed

apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,16 @@ export const NewPaymentMethodElement = forwardRef(
8585
currentAddress,
8686
currentTaxId,
8787
customerName,
88+
onAddressChange,
89+
onTaxIdChange,
8890
}: {
8991
email?: string | null | undefined
9092
readOnly: boolean
9193
currentAddress?: CustomerAddress | null
9294
currentTaxId?: CustomerTaxId | null
9395
customerName?: string | undefined
96+
onAddressChange?: (address: CustomerAddress) => void
97+
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
9498
},
9599
ref
96100
) => {
@@ -123,13 +127,25 @@ export const NewPaymentMethodElement = forwardRef(
123127
form.setValue('tax_id_name', name)
124128
}
125129

126-
const { tax_id_name } = form.watch()
130+
const { tax_id_name, tax_id_value } = form.watch()
127131
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
128132

129133
const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
130134
const [stripeAddress, setStripeAddress] = useState<
131135
StripeAddressElementChangeEvent['value'] | undefined
132136
>(undefined)
137+
useEffect(() => {
138+
if (!onTaxIdChange) return
139+
if (purchasingAsBusiness && selectedTaxId && tax_id_value) {
140+
onTaxIdChange({
141+
country: getEffectiveTaxCountry(selectedTaxId),
142+
type: selectedTaxId.type,
143+
value: tax_id_value,
144+
})
145+
} else {
146+
onTaxIdChange(null)
147+
}
148+
}, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange])
133149

134150
const availableTaxIds = useMemo(() => {
135151
const country = stripeAddress?.address.country || null
@@ -272,7 +288,15 @@ export const NewPaymentMethodElement = forwardRef(
272288
options={addressOptions}
273289
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
274290
key={`address-elements-${purchasingAsBusiness}`}
275-
onChange={(evt) => setStripeAddress(evt.value)}
291+
onChange={(evt) => {
292+
setStripeAddress(evt.value)
293+
if (onAddressChange && evt.complete) {
294+
onAddressChange({
295+
...evt.value.address,
296+
line2: evt.value.address.line2 || undefined,
297+
})
298+
}
299+
}}
276300
onReady={() => setFullyLoaded(true)}
277301
/>
278302

apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
8484
})
8585

8686
const [topUpModalVisible, setTopUpModalVisible] = useState(false)
87+
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
8788
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
8889
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
8990
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
@@ -281,6 +282,8 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
281282
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
282283
selectedPaymentMethod={form.getValues('paymentMethod')}
283284
readOnly={executingTopUp || paymentConfirmationLoading}
285+
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
286+
onUseAsDefaultBillingAddressChange={setUseAsDefaultBillingAddress}
284287
/>
285288
)}
286289
/>

apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organi
2626
import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation'
2727
import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query'
2828
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
29+
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
2930
import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation'
3031
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
3132
import { BASE_PATH, STRIPE_PUBLIC_KEY } from '@/lib/constants'
@@ -37,6 +38,10 @@ export interface PaymentMethodSelectionProps {
3738
onSelectPaymentMethod: (id: string) => void
3839
layout?: 'vertical' | 'horizontal'
3940
readOnly: boolean
41+
onAddressChange?: (address: CustomerAddress) => void
42+
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
43+
useAsDefaultBillingAddress: boolean
44+
onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
4045
}
4146

4247
const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
@@ -45,6 +50,10 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
4550
onSelectPaymentMethod,
4651
layout = 'vertical',
4752
readOnly,
53+
onAddressChange,
54+
onTaxIdChange,
55+
useAsDefaultBillingAddress,
56+
onUseAsDefaultBillingAddressChange,
4857
}: PaymentMethodSelectionProps,
4958
ref
5059
) {
@@ -53,7 +62,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
5362
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
5463
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
5564
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
56-
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
5765
const { resolvedTheme } = useTheme()
5866
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
5967
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
@@ -281,6 +289,8 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
281289
customerName={customerProfile?.billing_name}
282290
currentAddress={customerProfile?.address}
283291
currentTaxId={taxId}
292+
onAddressChange={onAddressChange}
293+
onTaxIdChange={onTaxIdChange}
284294
/>
285295
</Elements>
286296

@@ -290,7 +300,9 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
290300
<Checkbox_Shadcn_
291301
id="defaultBillingAddress"
292302
checked={useAsDefaultBillingAddress}
293-
onCheckedChange={() => setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)}
303+
onCheckedChange={() => {
304+
onUseAsDefaultBillingAddressChange(!useAsDefaultBillingAddress)
305+
}}
294306
/>
295307
<label
296308
htmlFor="defaultBillingAddress"

apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { PermissionAction } from '@supabase/shared-types/out/constants'
2+
import { useDebounce } from '@uidotdev/usehooks'
23
import { useParams } from 'common'
34
import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants'
45
import { isArray } from 'lodash'
56
import { Check, ExternalLink } from 'lucide-react'
67
import { useRouter } from 'next/router'
7-
import { useEffect, useMemo, useRef, useState } from 'react'
8+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
89
import { plans as subscriptionsPlans } from 'shared-data/plans'
910
import { Button, cn, SidePanel } from 'ui'
1011
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
@@ -22,6 +23,7 @@ import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToB
2223
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
2324
import { useOrganizationBillingSubscriptionPreview } from '@/data/organizations/organization-billing-subscription-preview'
2425
import { useOrganizationQuery } from '@/data/organizations/organization-query'
26+
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
2527
import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
2628
import { useOrgPlansQuery } from '@/data/subscriptions/org-plans-query'
2729
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
@@ -61,6 +63,26 @@ export const PlanUpdateSidePanel = () => {
6163
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
6264
const [showDowngradeError, setShowDowngradeError] = useState(false)
6365
const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>()
66+
const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
67+
const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
68+
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
69+
70+
const billingAddress = useAsDefaultBillingAddress ? latestAddress : undefined
71+
const billingTaxId = useAsDefaultBillingAddress ? latestTaxId : null
72+
const debouncedAddress = useDebounce(billingAddress, 1000)
73+
const debouncedTaxId = useDebounce(billingTaxId, 1000)
74+
75+
const handleAddressChange = useCallback(
76+
(address: CustomerAddress) => setLatestAddress(address),
77+
[]
78+
)
79+
80+
const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => setLatestTaxId(taxId), [])
81+
82+
const handleUseAsDefaultBillingAddressChange = useCallback(
83+
(useAsDefault: boolean) => setUseAsDefaultBillingAddress(useAsDefault),
84+
[]
85+
)
6486

6587
const { can: canUpdateSubscription } = useAsyncCheckPermissions(
6688
PermissionAction.BILLING_WRITE,
@@ -104,8 +126,14 @@ export const PlanUpdateSidePanel = () => {
104126
data: subscriptionPreview,
105127
error: subscriptionPreviewError,
106128
isPending: subscriptionPreviewIsLoading,
129+
isFetching: subscriptionPreviewIsFetching,
107130
isSuccess: subscriptionPreviewInitialized,
108-
} = useOrganizationBillingSubscriptionPreview({ tier: selectedTier, organizationSlug: slug })
131+
} = useOrganizationBillingSubscriptionPreview({
132+
tier: selectedTier,
133+
organizationSlug: slug,
134+
address: debouncedAddress,
135+
taxId: debouncedTaxId ?? undefined,
136+
})
109137

110138
const availablePlans: OrgPlan[] = plans?.plans ?? []
111139
const hasMembersExceedingFreeTierLimit =
@@ -118,6 +146,9 @@ export const PlanUpdateSidePanel = () => {
118146
useEffect(() => {
119147
if (visible) {
120148
setSelectedTier(undefined)
149+
setLatestAddress(undefined)
150+
setLatestTaxId(undefined)
151+
setUseAsDefaultBillingAddress(true)
121152
const source = Array.isArray(router.query.source)
122153
? router.query.source[0]
123154
: router.query.source
@@ -342,6 +373,7 @@ export const PlanUpdateSidePanel = () => {
342373
planMeta={planMeta}
343374
subscriptionPreviewError={subscriptionPreviewError}
344375
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
376+
subscriptionPreviewIsFetching={subscriptionPreviewIsFetching}
345377
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
346378
subscriptionPreview={subscriptionPreview}
347379
subscription={subscription}
@@ -352,6 +384,10 @@ export const PlanUpdateSidePanel = () => {
352384
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
353385
?.features || [],
354386
}}
387+
onAddressChange={handleAddressChange}
388+
onTaxIdChange={handleTaxIdChange}
389+
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
390+
onUseAsDefaultBillingAddressChange={handleUseAsDefaultBillingAddressChange}
355391
/>
356392

357393
<MembersExceedLimitModal

apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Link from 'next/link'
66
import { useMemo, useRef, useState } from 'react'
77
import { plans as subscriptionsPlans } from 'shared-data/plans'
88
import { toast } from 'sonner'
9-
import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
9+
import { Button, cn, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
1010
import { Admonition } from 'ui-patterns'
1111
import { InfoTooltip } from 'ui-patterns/info-tooltip'
1212
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
@@ -21,6 +21,7 @@ import {
2121
} from '@/components/interfaces/Billing/Subscription/Subscription.utils'
2222
import AlertError from '@/components/ui/AlertError'
2323
import { OrganizationBillingSubscriptionPreviewData } from '@/data/organizations/organization-billing-subscription-preview'
24+
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
2425
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
2526
import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change'
2627
import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
@@ -64,11 +65,16 @@ interface Props {
6465
planMeta: any
6566
subscriptionPreviewError: any
6667
subscriptionPreviewIsLoading: boolean
68+
subscriptionPreviewIsFetching: boolean
6769
subscriptionPreviewInitialized: boolean
6870
subscriptionPreview: OrganizationBillingSubscriptionPreviewData | undefined
6971
subscription: any
7072
currentPlanMeta: any
7173
projects: OrgProject[]
74+
onAddressChange?: (address: CustomerAddress) => void
75+
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
76+
useAsDefaultBillingAddress: boolean
77+
onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
7278
}
7379

7480
export const SubscriptionPlanUpdateDialog = ({
@@ -77,11 +83,16 @@ export const SubscriptionPlanUpdateDialog = ({
7783
planMeta,
7884
subscriptionPreviewError,
7985
subscriptionPreviewIsLoading,
86+
subscriptionPreviewIsFetching,
8087
subscriptionPreviewInitialized,
8188
subscriptionPreview,
8289
subscription,
8390
currentPlanMeta,
8491
projects,
92+
onAddressChange,
93+
onTaxIdChange,
94+
useAsDefaultBillingAddress,
95+
onUseAsDefaultBillingAddressChange,
8596
}: Props) => {
8697
const { resolvedTheme } = useTheme()
8798
const { data: selectedOrganization } = useSelectedOrganizationQuery()
@@ -325,16 +336,22 @@ export const SubscriptionPlanUpdateDialog = ({
325336
<div className="p-8 pb-8 flex flex-col xl:col-span-3">
326337
<div className="flex-1">
327338
<div>
328-
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
329-
<div className="space-y-2 mb-4">
330-
<PaymentMethodSelection
331-
ref={paymentMethodSelectionRef}
332-
selectedPaymentMethod={selectedPaymentMethod}
333-
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
334-
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
335-
/>
336-
</div>
337-
)}
339+
{!billingViaPartner &&
340+
subscriptionPreviewInitialized &&
341+
changeType === 'upgrade' && (
342+
<div className="space-y-2 mb-4">
343+
<PaymentMethodSelection
344+
ref={paymentMethodSelectionRef}
345+
selectedPaymentMethod={selectedPaymentMethod}
346+
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
347+
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
348+
onAddressChange={onAddressChange}
349+
onTaxIdChange={onTaxIdChange}
350+
useAsDefaultBillingAddress={useAsDefaultBillingAddress}
351+
onUseAsDefaultBillingAddressChange={onUseAsDefaultBillingAddressChange}
352+
/>
353+
</div>
354+
)}
338355

339356
{billingViaPartner && (
340357
<div className="mb-4">
@@ -368,7 +385,12 @@ export const SubscriptionPlanUpdateDialog = ({
368385
)}
369386
{subscriptionPreviewInitialized && (
370387
<>
371-
<div className="mt-2 mb-4 text-foreground-light text-sm">
388+
<div
389+
className={cn(
390+
'mt-2 mb-4 text-foreground-light text-sm transition-opacity',
391+
subscriptionPreviewIsFetching && 'opacity-50'
392+
)}
393+
>
372394
{breakdownItems.map((item, i) =>
373395
item.type === 'amount' ? (
374396
<div
@@ -668,7 +690,7 @@ export const SubscriptionPlanUpdateDialog = ({
668690
<div className="flex space-x-2">
669691
<Button
670692
loading={isUpdating || paymentConfirmationLoading || isConfirming}
671-
disabled={subscriptionPreviewIsLoading}
693+
disabled={subscriptionPreviewIsLoading || subscriptionPreviewIsFetching}
672694
type="primary"
673695
onClick={onUpdateSubscription}
674696
className="flex-1"

apps/studio/data/organizations/keys.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ export const organizationKeys = {
1414
slug: string | undefined,
1515
{ date_start, date_end }: { date_start: string | undefined; date_end: string | undefined }
1616
) => ['organizations', slug, 'audit-logs', { date_start, date_end }] as const,
17-
subscriptionPreview: (slug: string | undefined, tier: string | undefined) =>
18-
['organizations', slug, 'subscription', 'preview', tier] as const,
17+
subscriptionPreview: (
18+
slug: string | undefined,
19+
tier: string | undefined,
20+
params?: { address?: Record<string, unknown>; taxId?: Record<string, unknown> }
21+
) => ['organizations', slug, 'subscription', 'preview', tier, params] as const,
1922
taxId: (slug: string | undefined) => ['organizations', slug, 'tax-ids'] as const,
2023
tokenValidation: (slug: string | undefined, token: string | undefined) =>
2124
['organizations', slug, 'validate-token', token] as const,

0 commit comments

Comments
 (0)