fix(billing): block downgrade requests at the component level

RequestUpgradeToBillingOwners now checks the requested plan against the
viewer's current plan and renders a disabled state when the request would
be a downgrade or no-op. This protects every callsite: the plan switcher
(previously offered 'Request upgrade to Free' to higher-tier members), the
log-drains empty state (hardcoded 'Pro' showing to Team members), the
billing-metric paywall, and the custom-auth-providers fallback path.

Addon-flavored requests (compute size, PITR, custom domain, IPv4, spend
cap) bypass the guard because they are about feature add-ons, not plan
tier changes.
This commit is contained in:
Pamela Chia committed 2026-05-12 02:38:54 +08:00
1 parent e55411da5e
commit 3fc23db748
2 files changed
+36 -5

No files matched your search

@@ -308,7 +308,10 @@ export const PlanUpdateSidePanel = () => {
Current plan
</Button>
) : !canUpdateSubscription ? (
<RequestUpgradeToBillingOwners block plan={plan.name as 'Pro' | 'Team'} />
<RequestUpgradeToBillingOwners
block
plan={plan.name as 'Free' | 'Pro' | 'Team'}
/>
) : (
<ButtonTooltip
block
@@ -25,12 +25,15 @@ import {
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import { getPlanChangeType } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
import {
PlanRequest,
useSendUpgradeRequestMutation,
} from '@/data/organizations/request-upgrade-mutation'
import type { PlanId } from '@/data/subscriptions/types'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTrack } from '@/lib/telemetry/track'
@@ -41,9 +44,11 @@ const FormSchema = z.object({
const formId = 'request-upgrade-form'
type DisplayPlan = 'Free' | PlanRequest
interface RequestUpgradeToBillingOwnersProps {
block?: boolean
plan?: PlanRequest
plan?: DisplayPlan
addon?: 'pitr' | 'customDomain' | 'ipv4' | 'spendCap' | 'computeSize'
/** Used in the default message template, e.g: "Upgrade to ..." */
featureProposition?: string
@@ -68,6 +73,10 @@ export const RequestUpgradeToBillingOwners = ({
const currentPlan = organization?.plan?.id
const isFreePlan = currentPlan === 'free'
const requestedPlanId = plan.toLowerCase() as PlanId
const planChange = !addon ? getPlanChangeType(currentPlan, requestedPlanId) : 'upgrade'
const isInvalidDirection = planChange === 'downgrade' || planChange === 'none'
const { data: members = [] } = useOrganizationMembersQuery({ slug: organization?.slug })
const { data: roles } = useOrganizationRolesV2Query({ slug: organization?.slug })
const orgRoles = roles?.org_scoped_roles ?? []
@@ -75,7 +84,7 @@ export const RequestUpgradeToBillingOwners = ({
const { mutate: sendUpgradeRequest, isPending: isSubmitting } = useSendUpgradeRequestMutation({
onSuccess: () => {
track('request_upgrade_submitted', {
requestedPlan: plan,
requestedPlan: plan as PlanRequest,
addon,
currentPlan,
})
@@ -144,13 +153,13 @@ export const RequestUpgradeToBillingOwners = ({
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!slug) return console.error('Slug is required')
sendUpgradeRequest({ slug, plan, note: values.note })
sendUpgradeRequest({ slug, plan: plan as PlanRequest, note: values.note })
}
const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
track('request_upgrade_modal_opened', {
requestedPlan: plan,
requestedPlan: plan as PlanRequest,
addon,
currentPlan,
featureProposition,
@@ -159,6 +168,25 @@ export const RequestUpgradeToBillingOwners = ({
setOpen(isOpen)
}
if (isInvalidDirection) {
return (
<ButtonTooltip
block={block}
disabled
type="default"
className={className}
tooltip={{
content: {
side: 'bottom',
text: 'Downgrades cannot be requested. Ask a billing owner to change the plan directly.',
},
}}
>
Not available
</ButtonTooltip>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>