mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
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:
@@ -258,6 +258,8 @@ import type {
|
|||||||
TierKey,
|
TierKey,
|
||||||
TierPricing
|
TierPricing
|
||||||
} from '@/platform/cloud/subscription/constants/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 { isCloud } from '@/platform/distribution/types'
|
||||||
import {
|
import {
|
||||||
FirebaseAuthStoreError,
|
FirebaseAuthStoreError,
|
||||||
@@ -269,8 +271,6 @@ type SubscriptionTier = components['schemas']['SubscriptionTier']
|
|||||||
type CheckoutTierKey = Exclude<TierKey, 'founder'>
|
type CheckoutTierKey = Exclude<TierKey, 'founder'>
|
||||||
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
|
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
|
||||||
|
|
||||||
type BillingCycle = 'monthly' | 'yearly'
|
|
||||||
|
|
||||||
const getCheckoutTier = (
|
const getCheckoutTier = (
|
||||||
tierKey: CheckoutTierKey,
|
tierKey: CheckoutTierKey,
|
||||||
billingCycle: BillingCycle
|
billingCycle: BillingCycle
|
||||||
@@ -342,6 +342,15 @@ const currentTierKey = computed<TierKey | null>(() =>
|
|||||||
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : 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 => {
|
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
|
||||||
if (!currentTierKey.value) return false
|
if (!currentTierKey.value) return false
|
||||||
|
|
||||||
@@ -443,7 +452,23 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
|||||||
if (isActiveSubscription.value) {
|
if (isActiveSubscription.value) {
|
||||||
// Pass the target tier to create a deep link to subscription update confirmation
|
// Pass the target tier to create a deep link to subscription update confirmation
|
||||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
|
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 {
|
} else {
|
||||||
const response = await initiateCheckout(tierKey)
|
const response = await initiateCheckout(tierKey)
|
||||||
if (response.checkout_url) {
|
if (response.checkout_url) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user