Files
supabase/apps/studio/components/interfaces/App/UpdateBillingAddressModal.tsx
T
Kanishk Dudeja d37de74cba feat(billing): show billing address modal to users other than owners/admins (#44132)
### Summary

This PR updates the billing address required modal so it also shows for
users with billing read access but without billing write access, such as
developers and users with read-only acess.

For users with BILLING_WRITE, the existing blocking behavior remains in
place:

- the modal has no visible close button
- it closes only after a successful billing address update

For users with BILLING_READ but not BILLING_WRITE, the modal now:

- the modal includes a visible close button
- no editable billing form is shown
- the copy tells the user to ask an organization administrator or owner
to update the billing address

<img width="1280" height="626" alt="Screenshot 2026-03-24 at 7 18 49 PM"
src="https://github.com/user-attachments/assets/ddc5e90d-c7d8-4d67-a138-59a5a5baf71b"
/>

### Manual testing

#### Admins/owners

1. As a user with BILLING_WRITE, open an org that is missing a billing
address.
2. Confirm the modal appears with the billing address form.
3. Confirm there is no visible close button.
4. Submit a valid billing address and verify the modal closes after a
successful save.

For testing whether an update successfully closes the modal, you can:

-  You can create a free org without a billing address
- You will need to tweak this logic (on the frontend)

```
const shouldShow = Boolean(
  IS_PLATFORM &&
  // showMissingAddressModal &&
  org &&
  // org.plan.id !== 'free' &&
  org.organization_missing_address &&
  !org.billing_partner &&
  permissionsLoaded &&
  canViewModal
)
```

- Tweak the backend logic which computes `organization_missing_address`
to exclude free orgs

#### Developers/users with readp-only access

1. As a user with BILLING_READ but not BILLING_WRITE, open an org that
is missing a billing address.
2. Confirm the modal appears with informational copy only and no
editable form.
3. Click the close button and verify the modal dismisses.
2026-03-25 01:58:47 +05:30

200 lines
6.9 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useFlag } from 'common'
import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm'
import { resolveStoredTaxId } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.utils'
import { useBillingCustomerDataForm } from 'components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form_Shadcn_ as Form,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
export function UpdateBillingAddressModal() {
const queryClient = useQueryClient()
const [dismissed, setDismissed] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const showMissingAddressModal = useFlag('enableBillingAddressModal')
const { data: org } = useSelectedOrganizationQuery()
const slug = org?.slug
const { can: canBillingRead, isSuccess: billingReadLoaded } = useAsyncCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.customer'
)
const { can: canBillingWrite, isSuccess: billingWriteLoaded } = useAsyncCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const permissionsLoaded = billingReadLoaded && billingWriteLoaded
const canViewModal = canBillingRead || canBillingWrite
const canManageBillingAddress = canBillingWrite
const shouldShow = Boolean(
IS_PLATFORM &&
showMissingAddressModal &&
org &&
org.plan.id !== 'free' &&
org.organization_missing_address &&
!org.billing_partner &&
permissionsLoaded &&
canViewModal
)
const {
data: customerProfile,
isSuccess: profileLoaded,
isError: profileError,
} = useOrganizationCustomerProfileQuery(
{ slug },
{ enabled: shouldShow && !dismissed && !!slug && canManageBillingAddress }
)
const {
data: taxId,
isSuccess: taxIdLoaded,
isError: taxIdError,
} = useOrganizationTaxIdQuery(
{ slug },
{ enabled: shouldShow && !dismissed && !!slug && canManageBillingAddress }
)
const open =
shouldShow && !dismissed && (!canManageBillingAddress || (!profileError && !taxIdError))
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
() => ({
city: customerProfile?.address?.city ?? undefined,
country: customerProfile?.address?.country,
line1: customerProfile?.address?.line1,
line2: customerProfile?.address?.line2 ?? undefined,
postal_code: customerProfile?.address?.postal_code ?? undefined,
state: customerProfile?.address?.state ?? undefined,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
'')
: '',
}),
[customerProfile, taxId]
)
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { form, handleSubmit, isDirty } = useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange: async (data) => {
if (!slug) return
setIsSubmitting(true)
try {
await updateCustomerProfile({
slug,
address: data.address,
billing_name: data.billing_name,
})
await updateTaxId({ slug, taxId: data.tax_id })
await invalidateOrganizationsQuery(queryClient)
toast.success('Successfully updated billing address')
setDismissed(true)
} catch (error: any) {
toast.error(`Failed to update billing address: ${error.message}`)
} finally {
setIsSubmitting(false)
}
},
})
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) setDismissed(true)
}}
>
<DialogContent
size="medium"
hideClose={canManageBillingAddress}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Billing address required</DialogTitle>
<DialogDescription>
{canManageBillingAddress ? (
'Please provide a billing address for your organization. If you are a registered business and have a Tax ID, please add your Tax ID too.'
) : (
<>
Your organization requires a billing address. If you are a registered business, a
Tax ID is also required.
<br />
<br />
Please ask an organization administrator or owner to update it as soon as possible
to avoid service restrictions.
</>
)}
</DialogDescription>
</DialogHeader>
{canManageBillingAddress &&
(!profileLoaded || !taxIdLoaded ? (
<DialogSection>
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
</DialogSection>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<DialogSection className="max-h-[60vh] overflow-y-auto">
<BillingCustomerDataForm form={form} />
</DialogSection>
<DialogFooter>
<Button
type="primary"
htmlType="submit"
loading={isSubmitting}
disabled={!isDirty || isSubmitting}
>
Save address
</Button>
</DialogFooter>
</form>
</Form>
))}
</DialogContent>
</Dialog>
)
}