Guard downgrades via billing portal (#7813)

- add a reusable subscription tier ranking helper + unit test
- send pricing-table downgrades to the generic billing portal until
backend proration is fixed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7813-Guard-downgrades-via-billing-portal-2da6d73d365081f0a202dd5699143332)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2025-12-31 17:16:27 -08:00
committed by GitHub
parent 14528aad6e
commit 91f7a64513
3 changed files with 135 additions and 3 deletions

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { getPlanRank, isPlanDowngrade } from './subscriptionTierRank'
describe('subscriptionTierRank', () => {
it('returns consistent order for ranked plans', () => {
const yearlyPro = getPlanRank({ tierKey: 'pro', billingCycle: 'yearly' })
const monthlyStandard = getPlanRank({
tierKey: 'standard',
billingCycle: 'monthly'
})
expect(yearlyPro).toBeLessThan(monthlyStandard)
})
it('identifies downgrades correctly', () => {
const result = isPlanDowngrade({
current: { tierKey: 'pro', billingCycle: 'yearly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
expect(result).toBe(true)
})
it('treats lateral or upgrade moves as non-downgrades', () => {
expect(
isPlanDowngrade({
current: { tierKey: 'standard', billingCycle: 'monthly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
).toBe(false)
expect(
isPlanDowngrade({
current: { tierKey: 'creator', billingCycle: 'monthly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
).toBe(false)
})
it('treats unknown plans (e.g., founder) as non-downgrade cases', () => {
const result = isPlanDowngrade({
current: { tierKey: 'founder', billingCycle: 'monthly' },
target: { tierKey: 'standard', billingCycle: 'monthly' }
})
expect(result).toBe(false)
})
})

View File

@@ -0,0 +1,58 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
export type BillingCycle = 'monthly' | 'yearly'
type RankedTierKey = Exclude<TierKey, 'founder'>
type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
interface PlanDescriptor {
tierKey: TierKey
billingCycle: BillingCycle
}
const PLAN_ORDER: RankedPlanKey[] = [
'yearly-pro',
'yearly-creator',
'yearly-standard',
'monthly-pro',
'monthly-creator',
'monthly-standard'
]
const PLAN_RANK = PLAN_ORDER.reduce<Map<RankedPlanKey, number>>(
(acc, plan, index) => acc.set(plan, index),
new Map()
)
const toRankedPlanKey = (
tierKey: TierKey,
billingCycle: BillingCycle
): RankedPlanKey | null => {
if (tierKey === 'founder') return null
return `${billingCycle}-${tierKey}` as RankedPlanKey
}
export const getPlanRank = ({
tierKey,
billingCycle
}: PlanDescriptor): number => {
const planKey = toRankedPlanKey(tierKey, billingCycle)
if (!planKey) return Number.POSITIVE_INFINITY
return PLAN_RANK.get(planKey) ?? Number.POSITIVE_INFINITY
}
interface DowngradeCheckParams {
current: PlanDescriptor
target: PlanDescriptor
}
export const isPlanDowngrade = ({
current,
target
}: DowngradeCheckParams): boolean => {
const currentRank = getPlanRank(current)
const targetRank = getPlanRank(target)
return targetRank > currentRank
}