diff --git a/src/locales/en/main.json b/src/locales/en/main.json index c900ef54c..ee084f1a2 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1921,6 +1921,7 @@ "billedYearly": "{total} Billed yearly", "monthly": "Monthly", "yearly": "Yearly", + "tierNameYearly": "{name} Yearly", "messageSupport": "Message support", "invoiceHistory": "Invoice history", "benefits": { diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 2cb309f40..ea9266304 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -331,8 +331,9 @@ const tiers: PricingTierConfig[] = [ const { n } = useI18n() const { getAuthHeader } = useFirebaseAuthStore() -const { isActiveSubscription, subscriptionTier } = useSubscription() -const { accessBillingPortal, reportError } = useFirebaseAuthActions() +const { isActiveSubscription, subscriptionTier, isYearlySubscription } = + useSubscription() +const { reportError } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() const isLoading = ref(false) @@ -344,8 +345,16 @@ const currentTierKey = computed(() => subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null ) -const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => - currentTierKey.value === tierKey +const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => { + if (!currentTierKey.value) return false + + const selectedIsYearly = currentBillingCycle.value === 'yearly' + + return ( + currentTierKey.value === tierKey && + isYearlySubscription.value === selectedIsYearly + ) +} const togglePopover = (event: Event) => { popover.value.toggle(event) @@ -353,9 +362,15 @@ const togglePopover = (event: Event) => { const getButtonLabel = (tier: PricingTierConfig): string => { if (isCurrentPlan(tier.key)) return t('subscription.currentPlan') - if (!isActiveSubscription.value) - return t('subscription.subscribeTo', { plan: tier.name }) - return t('subscription.changeTo', { plan: tier.name }) + + const planName = + currentBillingCycle.value === 'yearly' + ? t('subscription.tierNameYearly', { name: tier.name }) + : tier.name + + return isActiveSubscription.value + ? t('subscription.changeTo', { plan: planName }) + : t('subscription.subscribeTo', { plan: planName }) } const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' => @@ -428,13 +443,9 @@ const handleSubscribe = wrapWithErrorHandlingAsync( loadingTier.value = tierKey try { - if (isActiveSubscription.value) { - await accessBillingPortal() - } else { - const response = await initiateCheckout(tierKey) - if (response.checkout_url) { - window.open(response.checkout_url, '_blank') - } + const response = await initiateCheckout(tierKey) + if (response.checkout_url) { + window.open(response.checkout_url, '_blank') } } finally { isLoading.value = false diff --git a/src/platform/cloud/subscription/components/SubscriptionPanel.vue b/src/platform/cloud/subscription/components/SubscriptionPanel.vue index 75ec92c94..78835ab2e 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanel.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanel.vue @@ -365,9 +365,9 @@ import { useSubscriptionCredits } from '@/platform/cloud/subscription/composable import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import { DEFAULT_TIER_KEY, - TIER_FEATURES, TIER_TO_KEY, getTierCredits, + getTierFeatures, getTierPrice } from '@/platform/cloud/subscription/constants/tierPricing' import { cn } from '@/utils/tailwindUtil' @@ -383,6 +383,7 @@ const { formattedEndDate, subscriptionTier, subscriptionTierName, + isYearlySubscription, handleInvoiceHistory } = useSubscription() @@ -393,7 +394,9 @@ const tierKey = computed(() => { if (!tier) return DEFAULT_TIER_KEY return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY }) -const tierPrice = computed(() => getTierPrice(tierKey.value)) +const tierPrice = computed(() => + getTierPrice(tierKey.value, isYearlySubscription.value) +) // Tier benefits for v-for loop type BenefitType = 'metric' | 'feature' @@ -433,7 +436,7 @@ const tierBenefits = computed((): Benefit[] => { } ] - if (TIER_FEATURES[key].customLoRAs) { + if (getTierFeatures(key).customLoRAs) { benefits.push({ key: 'customLoRAs', type: 'feature', diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 77e889f13..816e5afbf 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -13,7 +13,8 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useDialogService } from '@/services/dialogService' -import type { components, operations } from '@/types/comfyRegistryTypes' +import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing' +import type { operations } from '@/types/comfyRegistryTypes' import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher' type CloudSubscriptionCheckoutResponse = NonNullable< @@ -24,15 +25,6 @@ export type CloudSubscriptionStatusResponse = NonNullable< operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json'] > -type SubscriptionTier = components['schemas']['SubscriptionTier'] - -const TIER_TO_I18N_KEY: Record = { - STANDARD: 'standard', - CREATOR: 'creator', - PRO: 'pro', - FOUNDERS_EDITION: 'founder' -} - function useSubscriptionInternal() { const subscriptionStatus = ref(null) const telemetry = useTelemetry() @@ -82,11 +74,22 @@ function useSubscriptionInternal() { () => subscriptionStatus.value?.subscription_tier ?? null ) + const subscriptionDuration = computed( + () => subscriptionStatus.value?.subscription_duration ?? null + ) + + const isYearlySubscription = computed( + () => subscriptionDuration.value === 'ANNUAL' + ) + const subscriptionTierName = computed(() => { const tier = subscriptionTier.value if (!tier) return '' - const key = TIER_TO_I18N_KEY[tier] ?? 'standard' - return t(`subscription.tiers.${key}.name`) + const key = TIER_TO_KEY[tier] ?? 'standard' + const baseName = t(`subscription.tiers.${key}.name`) + return isYearlySubscription.value + ? t('subscription.tierNameYearly', { name: baseName }) + : baseName }) const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` @@ -241,6 +244,8 @@ function useSubscriptionInternal() { formattedRenewalDate, formattedEndDate, subscriptionTier, + subscriptionDuration, + isYearlySubscription, subscriptionTierName, subscriptionStatus, diff --git a/src/platform/cloud/subscription/constants/tierPricing.ts b/src/platform/cloud/subscription/constants/tierPricing.ts index 8107991c7..88ed0cd7d 100644 --- a/src/platform/cloud/subscription/constants/tierPricing.ts +++ b/src/platform/cloud/subscription/constants/tierPricing.ts @@ -28,7 +28,7 @@ interface TierFeatures { customLoRAs: boolean } -export const TIER_FEATURES: Record = { +const TIER_FEATURES: Record = { standard: { customLoRAs: false }, creator: { customLoRAs: true }, pro: { customLoRAs: true }, @@ -37,16 +37,20 @@ export const TIER_FEATURES: Record = { export const DEFAULT_TIER_KEY: TierKey = 'standard' -// Founder tier pricing: legacy tier with fixed values not in TIER_PRICING const FOUNDER_MONTHLY_PRICE = 20 const FOUNDER_MONTHLY_CREDITS = 5460 -export function getTierPrice(tierKey: TierKey): number { +export function getTierPrice(tierKey: TierKey, isYearly = false): number { if (tierKey === 'founder') return FOUNDER_MONTHLY_PRICE - return TIER_PRICING[tierKey].monthly + const pricing = TIER_PRICING[tierKey] + return isYearly ? pricing.yearly : pricing.monthly } export function getTierCredits(tierKey: TierKey): number { if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS return TIER_PRICING[tierKey].credits } + +export function getTierFeatures(tierKey: TierKey): TierFeatures { + return TIER_FEATURES[tierKey] +} diff --git a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts index 2c6f9c04a..968281b35 100644 --- a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts @@ -12,6 +12,7 @@ const mockIsCancelled = ref(false) const mockSubscriptionTier = ref< 'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null >('CREATOR') +const mockIsYearlySubscription = ref(false) const TIER_TO_NAME: Record = { STANDARD: 'Standard', @@ -27,9 +28,12 @@ const mockSubscriptionData = { formattedRenewalDate: computed(() => '2024-12-31'), formattedEndDate: computed(() => '2024-12-31'), subscriptionTier: computed(() => mockSubscriptionTier.value), - subscriptionTierName: computed(() => - mockSubscriptionTier.value ? TIER_TO_NAME[mockSubscriptionTier.value] : '' - ), + subscriptionTierName: computed(() => { + if (!mockSubscriptionTier.value) return '' + const baseName = TIER_TO_NAME[mockSubscriptionTier.value] + return mockIsYearlySubscription.value ? `${baseName} Yearly` : baseName + }), + isYearlySubscription: computed(() => mockIsYearlySubscription.value), handleInvoiceHistory: vi.fn() } @@ -212,6 +216,7 @@ describe('SubscriptionPanel', () => { mockIsActiveSubscription.value = false mockIsCancelled.value = false mockSubscriptionTier.value = 'CREATOR' + mockIsYearlySubscription.value = false }) describe('subscription state functionality', () => {