mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
### 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>
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
import dayjs from 'dayjs'
|
|
import type { ReactNode } from 'react'
|
|
|
|
import { RESTRICTION_MESSAGES } from 'components/interfaces/Organization/restriction.constants'
|
|
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
|
|
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
|
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
|
import { useIsFeatureEnabled } from './useIsFeatureEnabled'
|
|
|
|
export type WarningBannerProps = {
|
|
variant: 'danger' | 'warning' | 'note'
|
|
title: string
|
|
description: ReactNode
|
|
}
|
|
|
|
/**
|
|
* Compute billing-related restriction banners for the currently selected organization.
|
|
*
|
|
* The hook examines billing feature availability, overdue invoices, organization billing data,
|
|
* and restriction status to produce an ordered list of warning/danger banner props.
|
|
*
|
|
* @returns An object containing:
|
|
* - `warnings`: an array of `WarningBannerProps` to display for the selected organization (may be empty).
|
|
* - `org`: the selected organization data (may be `undefined`).
|
|
*/
|
|
export function useOrganizationRestrictions() {
|
|
const { data: org } = useSelectedOrganizationQuery()
|
|
|
|
const { data: overdueInvoices } = useOverdueInvoicesQuery()
|
|
const { data: organizations } = useOrganizationsQuery()
|
|
|
|
const warnings: WarningBannerProps[] = []
|
|
|
|
const billingEnabled = useIsFeatureEnabled('billing:all')
|
|
if (!billingEnabled) {
|
|
return { warnings, org }
|
|
}
|
|
|
|
const overdueInvoicesFromOtherOrgs = overdueInvoices?.filter(
|
|
(invoice) => invoice.organization_id !== org?.id
|
|
)
|
|
const thisOrgHasOverdueInvoices = overdueInvoices?.filter(
|
|
(invoice) => invoice.organization_id === org?.id
|
|
)
|
|
|
|
if (thisOrgHasOverdueInvoices?.length) {
|
|
warnings.push({
|
|
variant: 'danger',
|
|
title: RESTRICTION_MESSAGES.OVERDUE_INVOICES.title,
|
|
description: RESTRICTION_MESSAGES.OVERDUE_INVOICES.description(org?.slug ?? 'default'),
|
|
})
|
|
}
|
|
|
|
if (overdueInvoicesFromOtherOrgs?.length) {
|
|
const otherOrgSlug = organizations?.find(
|
|
(o) => o.id === overdueInvoicesFromOtherOrgs[0].organization_id
|
|
)?.slug
|
|
warnings.push({
|
|
variant: 'danger',
|
|
title: RESTRICTION_MESSAGES.OVERDUE_INVOICES_FROM_OTHER_ORGS.title,
|
|
description: RESTRICTION_MESSAGES.OVERDUE_INVOICES_FROM_OTHER_ORGS.description(
|
|
otherOrgSlug ?? org?.slug ?? 'default'
|
|
),
|
|
})
|
|
}
|
|
|
|
if (org?.restriction_status === 'grace_period') {
|
|
warnings.push({
|
|
variant: 'warning',
|
|
title: RESTRICTION_MESSAGES.GRACE_PERIOD.title,
|
|
description: RESTRICTION_MESSAGES.GRACE_PERIOD.description(
|
|
dayjs(org?.restriction_data?.['grace_period_end']).format('DD MMM, YYYY'),
|
|
org.slug
|
|
),
|
|
})
|
|
}
|
|
|
|
if (org?.restriction_status === 'grace_period_over') {
|
|
warnings.push({
|
|
variant: 'warning',
|
|
title: RESTRICTION_MESSAGES.GRACE_PERIOD_OVER.title,
|
|
description: RESTRICTION_MESSAGES.GRACE_PERIOD_OVER.description(org.slug),
|
|
})
|
|
}
|
|
|
|
if (org?.restriction_status === 'restricted') {
|
|
warnings.push({
|
|
variant: 'danger',
|
|
title: RESTRICTION_MESSAGES.RESTRICTED.title,
|
|
description: RESTRICTION_MESSAGES.RESTRICTED.description(org.slug),
|
|
})
|
|
}
|
|
|
|
return { warnings, org }
|
|
}
|