mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[backport cloud/1.35] Guard downgrades via billing portal (#7819)
Backport of #7813 to `cloud/1.35` Automatically created by backport workflow. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7819-backport-cloud-1-35-Guard-downgrades-via-billing-portal-2db6d73d365081d591e3fde9037164d6) by [Unito](https://www.unito.io) Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -261,6 +261,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,
|
||||
@@ -272,8 +274,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
|
||||
@@ -345,6 +345,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
|
||||
|
||||
@@ -446,7 +455,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) {
|
||||
|
||||
@@ -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