diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 82ec83c97..1015a5129 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -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 type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly` -type BillingCycle = 'monthly' | 'yearly' - const getCheckoutTier = ( tierKey: CheckoutTierKey, billingCycle: BillingCycle @@ -345,6 +345,15 @@ const currentTierKey = computed(() => 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) { diff --git a/src/platform/cloud/subscription/utils/subscriptionTierRank.test.ts b/src/platform/cloud/subscription/utils/subscriptionTierRank.test.ts new file mode 100644 index 000000000..cce7ab050 --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionTierRank.test.ts @@ -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) + }) +}) diff --git a/src/platform/cloud/subscription/utils/subscriptionTierRank.ts b/src/platform/cloud/subscription/utils/subscriptionTierRank.ts new file mode 100644 index 000000000..f85c8af91 --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionTierRank.ts @@ -0,0 +1,58 @@ +import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' + +export type BillingCycle = 'monthly' | 'yearly' + +type RankedTierKey = Exclude +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>( + (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 +}