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

@@ -258,6 +258,8 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
@@ -269,8 +271,6 @@ type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'>
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
type BillingCycle = 'monthly' | 'yearly'
const getCheckoutTier = (
tierKey: CheckoutTierKey,
billingCycle: BillingCycle
@@ -342,6 +342,15 @@ const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const currentPlanDescriptor = computed(() => {
if (!currentTierKey.value) return null
return {
tierKey: currentTierKey.value,
billingCycle: isYearlySubscription.value ? 'yearly' : 'monthly'
} as const
})
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
if (!currentTierKey.value) return false
@@ -443,7 +452,23 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (isActiveSubscription.value) {
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
await accessBillingPortal(checkoutTier)
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
}
const downgrade =
currentPlanDescriptor.value &&
isPlanDowngrade({
current: currentPlanDescriptor.value,
target: targetPlan
})
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
await accessBillingPortal(checkoutTier)
}
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {

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
}