[backport cloud/1.38] Feat/workspaces 6 billing (#8713)

Backport of #8508 to `cloud/1.38`

Automatically created by manual backport (cherry-pick of
c5431de123).

Conflicts resolved:
-
`src/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts`
— accepted PR version (uses `useBillingContext` mock instead of
pinia/firebase auth)
- `src/services/dialogService.ts` — merged: kept cloud/1.38 eager
imports for dialog components, replaced `TopUpCreditsDialogContent` with
Legacy/Workspace variants, replaced `useSubscription` with
`useBillingContext`, added workspace/legacy component selection in
`showTopUpCreditsDialog`; skipped lazy loader refactor (separate PR, not
part of #8508)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simula_r
2026-02-06 21:16:21 -08:00
committed by GitHub
parent 5c43c933d7
commit 9691f5fd00
54 changed files with 4862 additions and 564 deletions

View File

@@ -0,0 +1,527 @@
<template>
<div class="flex flex-col gap-8">
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center">
{{ t('subscription.chooseBestPlanWorkspace') }}
</h2>
<div class="flex justify-center">
<SelectButton
v-model="currentBillingCycle"
:options="billingCycleOptions"
option-label="label"
option-value="value"
:allow-empty="false"
unstyled
:pt="{
root: {
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
},
pcToggleButton: {
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
class: [
'w-36 h-8 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
context.active
? 'bg-base-foreground text-base-background'
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
]
}),
label: { class: 'flex items-center gap-2 ' }
}
}"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="bg-primary-background text-white text-[11px] px-1 py-0.5 rounded-full flex items-center font-bold"
>
-20%
</div>
</div>
</template>
</SelectButton>
</div>
<div class="flex flex-col xl:flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
:class="
cn(
'flex-1 flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)]',
tier.isPopular ? 'border-muted-foreground' : ''
)
"
>
<div class="p-8 pb-0 flex flex-col gap-8">
<div class="flex flex-row items-center gap-2 justify-between">
<span
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-base-foreground px-1.5 text-[11px] font-bold uppercase text-base-background h-5 tracking-tight flex items-center"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
<span
v-show="currentBillingCycle === 'yearly'"
class="line-through text-2xl text-muted-foreground"
>
${{ getMonthlyPrice(tier) }}
</span>
${{ getPrice(tier) }}
</span>
<span
class="font-inter text-sm leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{
currentBillingCycle === 'yearly'
? t('subscription.billedYearly', {
total: `$${getAnnualTotal(tier)}`
})
: t('subscription.billedMonthly')
}}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-4 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ n(getMonthlyCreditsPerMember(tier)) }}
</span>
</div>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.maxMembersLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ getMaxMembers(tier) }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-foreground" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span
class="text-sm font-normal text-foreground leading-relaxed"
>
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
~{{ n(tier.pricing.videoEstimate) }}
</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:variant="getButtonSeverity(tier)"
:disabled="isButtonDisabled(tier)"
:loading="props.loadingTier === tier.key"
:class="
cn(
'h-10 w-full',
getButtonTextClass(tier),
tier.key === 'creator'
? 'bg-base-foreground border-transparent hover:bg-inverted-background-hover'
: 'bg-secondary-background border-transparent hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
)
"
@click="() => handleSubscribe(tier.key)"
>
{{ getButtonLabel(tier) }}
</Button>
</div>
</div>
</div>
<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
>
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a>
</div>
</Popover>
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
<p class="text-sm text-text-secondary m-0">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-1.5">
<Button
variant="muted-textonly"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
@click="handleContactUs"
>
{{ $t('subscription.contactUs') }}
<i class="pi pi-comments" />
</Button>
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
<Button
variant="muted-textonly"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
@click="handleViewEnterprise"
>
{{ $t('subscription.viewEnterprise') }}
<i class="pi pi-external-link" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Popover from 'primevue/popover'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import {
TIER_PRICING,
TIER_TO_KEY
} from '@/platform/cloud/subscription/constants/tierPricing'
import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'>
interface Props {
isLoading?: boolean
loadingTier?: CheckoutTierKey | null
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
loadingTier: null
})
const emit = defineEmits<{
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
resubscribe: []
}>()
interface BillingCycleOption {
label: string
value: BillingCycle
}
interface PricingTierConfig {
id: SubscriptionTier
key: CheckoutTierKey
name: string
pricing: TierPricing
maxDuration: string
customLoRAs: boolean
maxMembers: number
isPopular?: boolean
}
const billingCycleOptions: BillingCycleOption[] = [
{ label: t('subscription.yearly'), value: 'yearly' },
{ label: t('subscription.monthly'), value: 'monthly' }
]
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
pricing: TIER_PRICING.standard,
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
maxMembers: 1,
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
pricing: TIER_PRICING.creator,
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
maxMembers: 5,
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
pricing: TIER_PRICING.pro,
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
maxMembers: 20,
isPopular: false
}
]
const { n } = useI18n()
const {
plans: apiPlans,
currentPlanSlug,
fetchPlans,
subscription
} = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly')
onMounted(() => {
void fetchPlans()
})
function getApiPlanForTier(
tierKey: CheckoutTierKey,
duration: BillingCycle
): Plan | undefined {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase() as Plan['tier']
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getPriceFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, currentBillingCycle.value)
if (!plan) return null
const price = plan.price_cents / 100
return currentBillingCycle.value === 'yearly' ? price / 12 : price
}
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.max_seats : null
}
const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
// Use API current_plan_slug if available
if (currentPlanSlug.value) {
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
return plan?.slug === currentPlanSlug.value
}
// Fallback to tier-based detection
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)
}
const getButtonLabel = (tier: PricingTierConfig): string => {
const planName =
currentBillingCycle.value === 'yearly'
? t('subscription.tierNameYearly', { name: tier.name })
: tier.name
if (isCurrentPlan(tier.key)) {
return isCancelled.value
? t('subscription.resubscribeTo', { plan: planName })
: t('subscription.currentPlan')
}
return currentTierKey.value
? t('subscription.changeTo', { plan: planName })
: t('subscription.subscribeTo', { plan: planName })
}
const getButtonSeverity = (
tier: PricingTierConfig
): 'primary' | 'secondary' => {
if (isCurrentPlan(tier.key)) {
return isCancelled.value ? 'primary' : 'secondary'
}
if (tier.key === 'creator') return 'primary'
return 'secondary'
}
const isButtonDisabled = (tier: PricingTierConfig): boolean => {
if (props.isLoading) return true
if (isCurrentPlan(tier.key)) {
// Allow clicking current plan button when cancelled (for resubscribe)
return !isCancelled.value
}
return false
}
const getButtonTextClass = (tier: PricingTierConfig): string =>
tier.key === 'creator'
? 'font-inter text-sm font-bold leading-normal text-base-background'
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'
const getPrice = (tier: PricingTierConfig): number =>
getPriceFromApi(tier) ?? tier.pricing[currentBillingCycle.value]
const getMonthlyPrice = (tier: PricingTierConfig): number => {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.price_cents / 100 : tier.pricing.monthly
}
const getAnnualTotal = (tier: PricingTierConfig): number => {
const plan = getApiPlanForTier(tier.key, 'yearly')
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
}
const getMaxMembers = (tier: PricingTierConfig): number =>
getMaxSeatsFromApi(tier) ?? tier.maxMembers
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
tier.pricing.credits
function handleSubscribe(tierKey: CheckoutTierKey) {
if (props.isLoading) return
// Handle resubscribe for cancelled subscription on current plan
if (isCurrentPlan(tierKey)) {
if (isCancelled.value) {
emit('resubscribe')
}
return
}
emit('subscribe', {
tierKey,
billingCycle: currentBillingCycle.value
})
}
function handleContactUs() {
window.open('https://www.comfy.org/discord', '_blank')
}
function handleViewEnterprise() {
window.open('https://www.comfy.org/enterprise', '_blank')
}
</script>

View File

@@ -1,8 +1,7 @@
<template>
<Button
:size
:loading="isLoading"
:disabled="disabled || isPolling"
:disabled="disabled"
variant="primary"
:style="
variant === 'gradient'
@@ -23,7 +22,7 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil'
@@ -46,60 +45,9 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const isAwaitingStripeSubscription = ref(false)
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
const startPollingSubscriptionStatus = () => {
isPolling.value = true
isLoading.value = true
const startTime = Date.now()
const poll = async () => {
try {
if (Date.now() - startTime > MAX_POLL_DURATION_MS) {
stopPolling()
return
}
await fetchStatus()
if (isActiveSubscription.value) {
stopPolling()
telemetry?.trackMonthlySubscriptionSucceeded()
emit('subscribed')
}
} catch (error) {
console.error(
'[SubscribeButton] Error polling subscription status:',
error
)
}
}
void poll()
pollInterval = window.setInterval(poll, POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
isPolling.value = false
isLoading.value = false
}
watch(
[isAwaitingStripeSubscription, isActiveSubscription],
([awaiting, isActive]) => {
@@ -110,27 +58,15 @@ watch(
}
)
const handleSubscribe = async () => {
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}
isLoading.value = true
try {
await subscribe()
startPollingSubscriptionStatus()
} catch (error) {
console.error('[SubscribeButton] Error initiating subscription:', error)
isLoading.value = false
}
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
}
onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>

View File

@@ -26,7 +26,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -40,7 +40,7 @@ const buttonLabel = computed(() =>
: t('subscription.subscribeToRun')
)
const { showSubscriptionDialog } = useSubscription()
const { showSubscriptionDialog } = useBillingContext()
const handleSubscribeToRun = () => {
if (isCloud) {

View File

@@ -0,0 +1,255 @@
<template>
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center mb-8">
{{ $t('subscription.preview.confirmPayment') }}
</h2>
<div
class="flex flex-col justify-between items-stretch max-w-[400px] mx-auto text-sm h-full"
>
<div class="">
<!-- Plan Header -->
<div class="flex flex-col gap-2">
<span class="text-base-foreground text-sm">
{{ tierName }}
</span>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-semibold text-base-foreground">
${{ displayPrice }}
</span>
<span class="text-xl text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<span class="text-muted-foreground">
{{ $t('subscription.preview.startingToday') }}
</span>
</div>
<!-- Credits Section -->
<div class="flex flex-col gap-3 pt-16 pb-8">
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="font-bold text-base-foreground">
{{ displayCredits }}
</span>
<span class="text-base-foreground">
{{ $t('subscription.preview.perMember') }}
</span>
</div>
</div>
<!-- Expandable Features -->
<button
class="flex items-center justify-end gap-1 text-sm text-muted-foreground hover:text-base-foreground cursor-pointer bg-transparent border-none p-0"
@click="isFeaturesCollapsed = !isFeaturesCollapsed"
>
<span>
{{
isFeaturesCollapsed
? $t('subscription.preview.showMoreFeatures')
: $t('subscription.preview.hideFeatures')
}}
</span>
<i
:class="
cn(
'pi text-xs',
isFeaturesCollapsed ? 'pi-chevron-down' : 'pi-chevron-up'
)
"
/>
</button>
<div v-show="!isFeaturesCollapsed" class="flex flex-col gap-2 pt-2">
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.maxDurationLabel') }}
</span>
<span class="text-sm font-bold text-base-foreground">
{{ maxDuration }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="hasCustomLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
</div>
</div>
<!-- Total Due Section -->
<div class="flex flex-col gap-2 border-t border-border-subtle pt-8">
<div class="flex text-base items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.totalDueToday') }}
</span>
<span class="font-bold text-base-foreground">
${{ totalDueToday }}
</span>
</div>
<span class="text-muted-foreground text-sm">
{{
$t('subscription.preview.nextPaymentDue', {
date: nextPaymentDate
})
}}
</span>
</div>
</div>
<!-- Footer -->
<div class="flex flex-col gap-2 pt-8">
<!-- Terms Agreement -->
<p class="text-xs text-muted-foreground text-center">
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
</p>
<!-- Add Credit Card Button -->
<Button
variant="secondary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@click="$emit('addCreditCard')"
>
{{ $t('subscription.preview.addCreditCard') }}
</Button>
<!-- Back Link -->
<Button
variant="textonly"
class="text-muted-foreground hover:text-base-foreground hover:bg-none text-center cursor-pointer transition-colors text-xs"
@click="$emit('back')"
>
{{ $t('subscription.preview.backToAllPlans') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { cn } from '@/utils/tailwindUtil'
interface Props {
tierKey: Exclude<TierKey, 'founder'>
billingCycle?: BillingCycle
isLoading?: boolean
previewData?: PreviewSubscribeResponse | null
}
const {
tierKey,
billingCycle = 'monthly',
isLoading = false,
previewData = null
} = defineProps<Props>()
defineEmits<{
addCreditCard: []
back: []
}>()
const { t, n } = useI18n()
const isFeaturesCollapsed = ref(true)
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
const displayPrice = computed(() => {
if (previewData?.new_plan) {
return (previewData.new_plan.price_cents / 100).toFixed(0)
}
return getTierPrice(tierKey, billingCycle === 'yearly')
})
const displayCredits = computed(() => n(getTierCredits(tierKey)))
const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs)
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
const totalDueToday = computed(() => {
if (previewData) {
return (previewData.cost_today_cents / 100).toFixed(2)
}
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
if (billingCycle === 'yearly') {
return (priceValue * 12).toFixed(2)
}
return priceValue.toFixed(2)
})
const nextPaymentDate = computed(() => {
if (previewData?.new_plan?.period_end) {
return new Date(previewData.new_plan.period_end).toLocaleDateString(
'en-US',
{
month: 'short',
day: 'numeric',
year: 'numeric'
}
)
}
const date = new Date()
if (billingCycle === 'yearly') {
date.setFullYear(date.getFullYear() + 1)
} else {
date.setMonth(date.getMonth() + 1)
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
</script>

View File

@@ -90,6 +90,13 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
}))
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
manageSubscription: vi.fn()
})
}))
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,

View File

@@ -72,10 +72,10 @@ import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { isCloud } from '@/platform/distribution/types'
@@ -91,11 +91,15 @@ const teamWorkspacesEnabled = computed(
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
const { isActiveSubscription, manageSubscription } = useBillingContext()
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
const handleInvoiceHistory = async () => {
await manageSubscription()
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),

View File

@@ -1,246 +1,336 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
class="rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-center gap-2 text-muted-foreground py-4">
<i class="pi pi-spin pi-spinner" />
<span>{{ $t('billingOperation.subscriptionProcessing') }}</span>
</div>
</div>
<template v-else>
<!-- Cancelled subscription info card -->
<div
v-if="isCancelled"
class="mb-6 flex gap-1 rounded-2xl border border-warning-background bg-warning-background/20 p-4"
>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
class="flex size-8 shrink-0 items-center justify-center rounded-full text-warning-background"
>
<!-- OWNER Unsubscribed State -->
<template v-if="showSubscribePrompt">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.subscriptionRequiredMessage') }}
</div>
</div>
<Button
variant="primary"
size="lg"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<i class="pi pi-info-circle" />
</div>
<div class="flex flex-col gap-2">
<h2 class="text-sm font-bold text-text-primary m-0 pt-1.5">
{{ $t('subscription.canceledCard.title') }}
</h2>
<p class="text-sm text-text-secondary m-0">
{{
$t('subscription.canceledCard.description', {
date: formattedEndDate
})
}}
</p>
</div>
</div>
<!-- MEMBER View - read-only, no subscription data yet -->
<template v-else-if="isMemberView">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
>
<!-- OWNER Unsubscribed State -->
<template v-if="showSubscribePrompt">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.subscriptionRequiredMessage') }}
</div>
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.contactOwnerToSubscribe') }}
</div>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription) -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<div
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<Button
variant="primary"
size="lg"
variant="secondary"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<!-- MEMBER View - read-only, workspace not subscribed -->
<template v-else-if="isMemberView">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.contactOwnerToSubscribe') }}
</div>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription, or member viewing subscribed workspace) -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</span>
<StatusBadge
v-if="isCancelled"
:label="$t('subscription.canceled')"
severity="warn"
/>
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base"
>{{ $t('subscription.perMonth') }} /
{{ $t('subscription.member') }}</span
>
</div>
<div
v-if="isActiveSubscription"
:class="
cn(
'text-sm',
isCancelled
? 'text-warning-background'
: 'text-text-secondary'
)
"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<div
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: show only Resubscribe button -->
<template v-if="isCancelled">
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('subscription.resubscribe') }}
</Button>
</template>
<!-- Active state: show Manage Payment, Upgrade, and menu -->
<template v-else>
<Button
size="lg"
variant="secondary"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="manageSubscription"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
</div>
</template>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</div>
</template>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription && !showZeroState"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
{{ $t('subscription.addCredits') }}
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<Button
v-if="isActiveSubscription && !showZeroState"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<i
v-else-if="benefit.type === 'icon' && benefit.icon"
:class="[benefit.icon, 'text-xs text-text-primary']"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Members invoice card -->
<div
v-if="isActiveSubscription && !isInPersonalWorkspace"
class="mt-6 flex gap-1 rounded-2xl border border-interface-stroke p-6 justify-between items-center text-sm"
>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
<h4 class="text-sm text-text-primary m-0">
{{ $t('subscription.nextMonthInvoice') }}
</h4>
<span
class="text-muted-foreground underline cursor-pointer"
@click="manageSubscription"
>
{{ $t('subscription.invoiceHistory') }}
</span>
</div>
<div class="flex flex-col gap-2 items-end">
<h4 class="m-0 font-bold">${{ nextMonthInvoice }}</h4>
<h5 class="m-0 text-muted-foreground">
{{ $t('subscription.memberCount', memberCount) }}
</h5>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-6">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</template>
</div>
</template>
@@ -251,13 +341,17 @@ import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'primevue/usetoast'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
@@ -269,51 +363,121 @@ import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
const { showBillingComingSoonDialog } = useDialogService()
const toast = useToast()
const billingOperationStore = useBillingOperationStore()
const isSettingUp = computed(() => billingOperationStore.isSettingUp)
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
manageSubscription,
fetchStatus,
fetchBalance,
plans: apiPlans
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const isResubscribing = ref(false)
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to resubscribe'
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message,
life: 5000
})
} finally {
isResubscribing.value = false
}
}
// Only show cancelled state for team workspaces (workspace billing)
// Personal workspaces use legacy billing which has different cancellation semantics
const isCancelled = computed(
() =>
!isInPersonalWorkspace.value && (subscription.value?.isCancelled ?? false)
)
// Show subscribe prompt to owners without active subscription
// Don't show if subscription is cancelled (still active until end date)
const showSubscribePrompt = computed(() => {
if (workspaceRole.value !== 'owner') return false
if (isCancelled.value) return false
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
return !isWorkspaceSubscribed.value
})
// MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription)
// MEMBER view without subscription - members can't manage subscription
const isMemberView = computed(
() =>
!permissions.value.canManageSubscription &&
!isActiveSubscription.value &&
!isWorkspaceSubscribed.value
)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => showSubscribePrompt.value || isMemberView.value
)
// Subscribe workspace - show billing coming soon dialog for team workspaces
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
function handleSubscribeWorkspace() {
if (!isInPersonalWorkspace.value) {
showBillingComingSoonDialog()
return
}
subscribeWorkspace('PRO_MONTHLY')
showSubscriptionDialog()
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const formattedRenewalDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const renewalDate = new Date(subscription.value.renewalDate)
return renewalDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const formattedEndDate = computed(() => {
if (!subscription.value?.endDate) return ''
const endDate = new Date(subscription.value.endDate)
return endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
if (!tier) return ''
const key = TIER_TO_KEY[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
return isYearlySubscription.value
? t('subscription.tierNameYearly', { name: baseName })
: baseName
})
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
@@ -321,8 +485,8 @@ const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: async () => {
await authActions.accessBillingPortal()
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
}
}
])
@@ -336,9 +500,29 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
const plan = getApiPlanForTier(tierKey, 'monthly')
return plan ? plan.max_seats : null
}
function getMaxMembers(tierKey: TierKey): number {
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
}
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
@@ -366,19 +550,26 @@ const includedCreditsDisplay = computed(
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
type BenefitType = 'metric' | 'feature' | 'icon'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
icon?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'members',
type: 'icon',
label: t('subscription.membersLabel', { count: getMaxMembers(key) }),
icon: 'pi pi-user'
},
{
key: 'maxDuration',
type: 'metric',
@@ -436,6 +627,7 @@ function handleWindowFocus() {
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
void Promise.all([fetchStatus(), fetchBalance()])
})
onBeforeUnmount(() => {

View File

@@ -127,7 +127,8 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
@@ -139,8 +140,10 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { fetchStatus, isActiveSubscription, isSubscriptionEnabled } =
useSubscription()
const { fetchStatus, isActiveSubscription } = useBillingContext()
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
// Legacy price for non-tier flow with locale-aware formatting
const formattedMonthlyPrice = new Intl.NumberFormat(

View File

@@ -0,0 +1,329 @@
<template>
<div
class="relative flex flex-col p-4 pt-8 md:p-16 !overflow-y-auto h-full gap-8"
>
<Button
v-if="checkoutStep === 'preview'"
size="icon"
variant="muted-textonly"
class="rounded-full shrink-0 text-text-secondary hover:bg-white/10 absolute left-2.5 top-2.5"
:aria-label="$t('g.back')"
@click="handleBackToPricing"
>
<i class="pi pi-arrow-left text-xl" />
</Button>
<Button
size="icon"
variant="muted-textonly"
class="rounded-full shrink-0 text-text-secondary hover:bg-white/10 absolute right-2.5 top-2.5"
:aria-label="$t('g.close')"
@click="handleClose"
>
<i class="pi pi-times text-xl" />
</Button>
<!-- Pricing Table Step -->
<PricingTableWorkspace
v-if="checkoutStep === 'pricing'"
class="flex-1"
:is-loading="isLoadingPreview || isResubscribing"
:loading-tier="loadingTier"
@subscribe="handleSubscribeClick"
@resubscribe="handleResubscribe"
/>
<!-- Subscription Preview Step - New Subscription -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type === 'new_subscription'
"
:preview-data="previewData"
:tier-key="selectedTierKey!"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleAddCreditCard"
@back="handleBackToPricing"
/>
<!-- Subscription Preview Step - Plan Transition -->
<SubscriptionTransitionPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type !== 'new_subscription'
"
:preview-data="previewData"
:is-loading="isSubscribing || isPolling"
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
type CheckoutStep = 'pricing' | 'preview'
type CheckoutTierKey = Exclude<TierKey, 'founder'>
const props = defineProps<{
onClose: () => void
}>()
const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
const checkoutStep = ref<CheckoutStep>('pricing')
const isLoadingPreview = ref(false)
const loadingTier = ref<CheckoutTierKey | null>(null)
const isSubscribing = ref(false)
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
function getApiPlanSlug(
tierKey: CheckoutTierKey,
billingCycle: BillingCycle
): string | null {
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
return plan?.slug ?? null
}
async function handleSubscribeClick(payload: {
tierKey: CheckoutTierKey
billingCycle: BillingCycle
}) {
const { tierKey, billingCycle } = payload
isLoadingPreview.value = true
loadingTier.value = tierKey
selectedTierKey.value = tierKey
selectedBillingCycle.value = billingCycle
try {
const planSlug = getApiPlanSlug(tierKey, billingCycle)
if (!planSlug) {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: 'This plan is not available',
life: 5000
})
return
}
const response = await previewSubscribe(planSlug)
if (!response || !response.allowed) {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: response?.reason || 'This plan is not available',
life: 5000
})
return
}
previewData.value = response
checkoutStep.value = 'preview'
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Failed to load subscription preview'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isLoadingPreview.value = false
loadingTier.value = null
}
}
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
}
async function handleAddCreditCard() {
if (!selectedTierKey.value) return
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
)
if (!response) return
if (response.status === 'subscribed') {
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} else if (
response.status === 'needs_payment_method' &&
response.payment_method_url
) {
window.open(response.payment_method_url, '_blank')
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
} else if (response.status === 'pending_payment') {
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to subscribe'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isSubscribing.value = false
}
}
async function handleConfirmTransition() {
if (!selectedTierKey.value) return
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
)
if (!response) return
if (response.status === 'subscribed') {
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} else if (
response.status === 'needs_payment_method' &&
response.payment_method_url
) {
window.open(response.payment_method_url, '_blank')
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
} else if (response.status === 'pending_payment') {
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to update subscription'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isSubscribing.value = false
}
}
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to resubscribe'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isResubscribing.value = false
}
}
function handleClose() {
props.onClose()
}
</script>
<style scoped>
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
.legacy-dialog :deep(.p-button) {
color: white;
}
</style>

View File

@@ -0,0 +1,264 @@
<template>
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center mb-8">
{{ $t('subscription.preview.confirmPlanChange') }}
</h2>
<div
class="flex flex-col justify-between items-stretch mx-auto text-sm h-full"
>
<div>
<!-- Plan Comparison Header -->
<div class="flex items-center gap-4">
<!-- Current Plan -->
<div class="flex flex-col gap-1 w-[250px]">
<span class="text-base-foreground text-sm">
{{ currentTierName }}
</span>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ currentDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<i class="icon-[lucide--component] text-amber-400 text-xs" />
<span
>{{ currentDisplayCredits }}
{{ $t('subscription.perMonth') }}</span
>
</div>
<span class="text-muted-foreground text-sm inline">
{{
$t('subscription.preview.ends', { date: currentPeriodEndDate })
}}
</span>
</div>
<!-- Arrow -->
<i class="pi pi-arrow-right text-muted-foreground w-8 h-8" />
<!-- New Plan -->
<div class="flex flex-col gap-1">
<span class="text-base-foreground text-sm font-semibold">
{{ newTierName }}
</span>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ newDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<i class="icon-[lucide--component] text-amber-400 text-xs" />
<span
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
>
</div>
<span class="text-muted-foreground text-sm">
{{ $t('subscription.preview.starting', { date: effectiveDate }) }}
</span>
</div>
</div>
<!-- Credits Section -->
<div class="flex flex-col gap-3 pt-12 pb-6">
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="font-bold text-base-foreground">
{{ newDisplayCredits }}
</span>
</div>
</div>
</div>
<!-- Proration Section -->
<div
v-if="showProration"
class="flex flex-col gap-2 border-t border-border-subtle pt-6 pb-6"
>
<div
v-if="proratedRefundCents > 0"
class="flex items-center justify-between"
>
<span class="text-muted-foreground">
{{
$t('subscription.preview.proratedRefund', {
plan: currentTierName
})
}}
</span>
<span class="text-muted-foreground">-${{ proratedRefund }}</span>
</div>
<div
v-if="proratedChargeCents > 0"
class="flex items-center justify-between"
>
<span class="text-muted-foreground">
{{
$t('subscription.preview.proratedCharge', { plan: newTierName })
}}
</span>
<span class="text-muted-foreground">${{ proratedCharge }}</span>
</div>
</div>
<!-- Total Due Section -->
<div class="flex flex-col gap-2 border-t border-border-subtle pt-6">
<div class="flex text-base items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.totalDueToday') }}
</span>
<span class="font-bold text-base-foreground">
${{ totalDueToday }}
</span>
</div>
<span class="text-muted-foreground text-sm">
{{
$t('subscription.preview.nextPaymentDue', {
date: nextPaymentDate
})
}}
</span>
</div>
</div>
<!-- Footer -->
<div class="flex flex-col gap-2 pt-8">
<Button
variant="secondary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@click="$emit('confirm')"
>
{{ $t('subscription.preview.confirm') }}
</Button>
<Button
variant="textonly"
class="text-muted-foreground hover:text-base-foreground hover:bg-none text-center cursor-pointer transition-colors text-xs"
@click="$emit('back')"
>
{{ $t('subscription.preview.backToAllPlans') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
interface Props {
previewData: PreviewSubscribeResponse
isLoading?: boolean
}
const { previewData, isLoading = false } = defineProps<Props>()
defineEmits<{
confirm: []
back: []
}>()
const { t, n } = useI18n()
function formatTierName(tier: string): string {
return t(`subscription.tiers.${tier.toLowerCase()}.name`)
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const currentTierName = computed(() =>
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
)
const newTierName = computed(() => formatTierName(previewData.new_plan.tier))
const currentDisplayPrice = computed(() =>
previewData.current_plan
? (previewData.current_plan.price_cents / 100).toFixed(0)
: '0'
)
const newDisplayPrice = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(0)
)
const currentDisplayCredits = computed(() => {
if (!previewData.current_plan) return n(0)
const tierKey = previewData.current_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey))
})
const newDisplayCredits = computed(() => {
const tierKey = previewData.new_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey))
})
const currentPeriodEndDate = computed(() =>
previewData.current_plan?.period_end
? formatDate(previewData.current_plan.period_end)
: ''
)
const effectiveDate = computed(() => formatDate(previewData.effective_at))
const showProration = computed(() => previewData.is_immediate)
const proratedRefundCents = computed(() => {
if (!previewData.current_plan || !previewData.is_immediate) return 0
const chargeToday = previewData.cost_today_cents
const newPlanCost = previewData.new_plan.price_cents
if (chargeToday < newPlanCost) {
return newPlanCost - chargeToday
}
return 0
})
const proratedRefund = computed(() =>
(proratedRefundCents.value / 100).toFixed(2)
)
const proratedChargeCents = computed(() => {
if (!previewData.is_immediate) return 0
return previewData.cost_today_cents
})
const proratedCharge = computed(() =>
(proratedChargeCents.value / 100).toFixed(2)
)
const totalDueToday = computed(() =>
(previewData.cost_today_cents / 100).toFixed(2)
)
const nextPaymentDate = computed(() =>
previewData.new_plan.period_end
? formatDate(previewData.new_plan.period_end)
: formatDate(previewData.effective_at)
)
</script>