Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions src/platform/cloud/subscription/components/PricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
58 changes: 58 additions & 0 deletions src/platform/cloud/subscription/utils/subscriptionTierRank.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading