Files
supabase/apps/studio/tests/components/Billing/UpdateBillingAddressModal.test.tsx
Kanishk DudejaandIgnacio Dobronich 83592b1932 feat(billing): show billing address modal for paid orgs with missing address (#43480)
### Summary

This PR adds a dismissable modal that prompts paid org users to fill in
their billing address. We need billing addresses for tax compliance and
the existing banner hasn't been as effective (~10k orgs still missing).

The modal shows on page load when all of these are true:

- ConfigCat flag `enableBillingAddressModal` is on
- Org is on a paid plan (not free)
- Org does not have billing address set
- Org is not partner-managed
- User has BILLING_WRITE permission on stripe.customer

The modal can be dismissed via the close button, but re-opens on every
page navigation.

Once the user saves a valid address, the organizations query is
invalidated and organization_missing_address flips to false - the modal
stops appearing permanently.

<img width="1256" height="703" alt="Screenshot 2026-03-05 at 4 22 11 PM"
src="https://github.com/user-attachments/assets/57e7d1b8-b3b6-4366-ad23-60910a0defe8"
/>

### Testing

#### Automated Tests

I've added unit tests in this PR

#### Manual testing

Manual test cases:

- Free org - modal does not appear
- Partner-managed org - modal does not appear
- User with "developer role" modal does not appear
- Configcat Flag disabled - modal does not appear
- Dismiss modal, navigate to another page - modal re-opens
- Fill form and save - modal closes and does not reappear
- API failure loading billing data - shows error state with Retry button

---------

Co-authored-by: Ignacio Dobronich <ignacio@dobronich.com>
2026-03-13 09:33:09 -03:00

192 lines
6.4 KiB
TypeScript

import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UpdateBillingAddressModal } from 'components/interfaces/App/UpdateBillingAddressModal'
import { createMockOrganization, render } from 'tests/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('lib/constants', async (importOriginal) => {
const original = (await importOriginal()) as any
return { ...original, IS_PLATFORM: true }
})
const mockFlag = vi.fn(() => true)
vi.mock('common', async (importOriginal) => {
const original = (await importOriginal()) as any
return {
...original,
useFlag: (_name: string) => mockFlag(),
useParams: () => ({ slug: 'test-org' }),
}
})
const mockOrg = vi.fn(() =>
createMockOrganization({
slug: 'test-org',
organization_missing_address: true,
billing_partner: null,
})
)
vi.mock('hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({ data: mockOrg() }),
}))
const mockCanWrite = vi.fn(() => true)
const mockPermissionsLoaded = vi.fn(() => true)
vi.mock('hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: () => ({
can: mockCanWrite(),
isSuccess: mockPermissionsLoaded(),
}),
}))
const mockCustomerProfile = vi.fn(() => ({
address: { line1: '', line2: '', city: '', state: '', postal_code: '', country: '' },
billing_name: '',
}))
const mockProfileLoaded = vi.fn(() => true)
const mockProfileError = vi.fn(() => false)
vi.mock('data/organizations/organization-customer-profile-query', () => ({
useOrganizationCustomerProfileQuery: () => ({
data: mockCustomerProfile(),
isSuccess: mockProfileLoaded(),
isError: mockProfileError(),
}),
}))
const mockTaxId = vi.fn(() => null)
const mockTaxIdLoaded = vi.fn(() => true)
const mockTaxIdError = vi.fn(() => false)
vi.mock('data/organizations/organization-tax-id-query', () => ({
useOrganizationTaxIdQuery: () => ({
data: mockTaxId(),
isSuccess: mockTaxIdLoaded(),
isError: mockTaxIdError(),
}),
}))
const mockUpdateCustomerProfile = vi.fn(() => Promise.resolve())
vi.mock('data/organizations/organization-customer-profile-update-mutation', () => ({
useOrganizationCustomerProfileUpdateMutation: () => ({
mutateAsync: mockUpdateCustomerProfile,
}),
}))
const mockUpdateTaxId = vi.fn(() => Promise.resolve())
vi.mock('data/organizations/organization-tax-id-update-mutation', () => ({
useOrganizationTaxIdUpdateMutation: () => ({
mutateAsync: mockUpdateTaxId,
}),
}))
vi.mock('data/organizations/organizations-query', () => ({
invalidateOrganizationsQuery: vi.fn(() => Promise.resolve()),
}))
describe('UpdateBillingAddressModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlag.mockReturnValue(true)
mockOrg.mockReturnValue(
createMockOrganization({
slug: 'test-org',
organization_missing_address: true,
billing_partner: null,
plan: { id: 'pro', name: 'Pro' },
})
)
mockCanWrite.mockReturnValue(true)
mockPermissionsLoaded.mockReturnValue(true)
mockProfileLoaded.mockReturnValue(true)
mockTaxIdLoaded.mockReturnValue(true)
mockCustomerProfile.mockReturnValue({
address: { line1: '', line2: '', city: '', state: '', postal_code: '', country: '' },
billing_name: '',
})
mockTaxId.mockReturnValue(null)
mockProfileError.mockReturnValue(false)
mockTaxIdError.mockReturnValue(false)
})
it('renders when all conditions are met', async () => {
render(<UpdateBillingAddressModal />)
expect(await screen.findByText('Billing address required')).toBeInTheDocument()
})
it('does not render when feature flag is off', () => {
mockFlag.mockReturnValue(false)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render when org has no missing address', () => {
mockOrg.mockReturnValue(createMockOrganization({ organization_missing_address: false }))
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render for free orgs', () => {
mockOrg.mockReturnValue(
createMockOrganization({
organization_missing_address: true,
plan: { id: 'free', name: 'Free' },
})
)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render for partner-managed orgs', () => {
mockOrg.mockReturnValue(
createMockOrganization({
organization_missing_address: true,
billing_partner: 'vercel_marketplace',
})
)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render when user lacks billing write permission', () => {
mockCanWrite.mockReturnValue(false)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render when no org is selected', () => {
mockOrg.mockReturnValue(undefined as any)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render when customer profile query errors', () => {
mockProfileError.mockReturnValue(true)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('does not render when tax id query errors', () => {
mockTaxIdError.mockReturnValue(true)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
it('shows loading shimmer while customer data loads', () => {
mockProfileLoaded.mockReturnValue(false)
render(<UpdateBillingAddressModal />)
expect(screen.queryByText('Save address')).not.toBeInTheDocument()
})
it('dismisses on close button click', async () => {
render(<UpdateBillingAddressModal />)
expect(await screen.findByText('Billing address required')).toBeInTheDocument()
// Click the X (close) button in DialogContent
const closeButton = screen.getByRole('button', { name: /close/i })
await userEvent.click(closeButton)
await waitFor(() => {
expect(screen.queryByText('Billing address required')).not.toBeInTheDocument()
})
})
})