subscription page (#6064)

Summary

Implements cloud subscription management UI and flow for ComfyUI Cloud
users.

  Core Features:
- Subscription Status Tracking: Global reactive state management for
subscription status across all components
  using shared subscriptionStatus ref
- Subscribe to Run Button: Replaces the Run button in the actionbar with
a "Subscribe to Run" button for users
  without active subscriptions
- Subscription Required Dialog: Modal dialog with subscription benefits,
pricing, and checkout flow with video
  background
- Subscription Settings Panel: New settings panel showing subscription
status, renewal date, and quick access to
  billing management
- Auto-detection & Polling: Automatically polls subscription status
after checkout completion and syncs state
  across the application


https://github.com/user-attachments/assets/f41b8e6a-5845-48a7-8169-3a6fc0d2e5c8



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6064-subscription-page-28d6d73d36508135a2a0fe7c94b40852)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Terry Jia
2025-10-18 23:21:30 -04:00
committed by GitHub
parent d83e34d0fc
commit 7e1e8e3b65
22 changed files with 1232 additions and 14 deletions

Binary file not shown.

View File

@@ -34,7 +34,8 @@
)
"
/>
<ComfyQueueButton />
<ComfyRunButton />
</div>
</Panel>
</div>
@@ -55,7 +56,7 @@ import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyQueueButton from './ComfyQueueButton.vue'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()

View File

@@ -0,0 +1,19 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -93,7 +93,7 @@ import {
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import BatchCountEdit from './BatchCountEdit.vue'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())

View File

@@ -0,0 +1,7 @@
import { defineAsyncComponent } from 'vue'
import { isCloud } from '@/platform/distribution/types'
export default isCloud
? defineAsyncComponent(() => import('./CloudRunButtonWrapper.vue'))
: defineAsyncComponent(() => import('./ComfyQueueButton.vue'))

View File

@@ -1,12 +1,19 @@
<template>
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
<div
class="flex items-center gap-2 bg-comfy-menu-secondary"
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
>
<div
v-if="badge.label"
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
:class="labelClass"
>
{{ badge.label }}
</div>
<div class="font-inter text-sm font-extrabold text-slate-100">
<div
class="font-inter text-sm font-extrabold text-slate-100"
:class="textClass"
>
{{ badge.text }}
</div>
</div>
@@ -14,7 +21,19 @@
<script setup lang="ts">
import type { TopbarBadge } from '@/types/comfy'
defineProps<{
badge: TopbarBadge
}>()
withDefaults(
defineProps<{
badge: TopbarBadge
reverseOrder?: boolean
noPadding?: boolean
labelClass?: string
textClass?: string
}>(),
{
reverseOrder: false,
noPadding: false,
labelClass: '',
textClass: ''
}
)
</script>

View File

@@ -4,6 +4,10 @@
v-for="badge in topbarBadgeStore.badges"
:key="badge.text"
:badge
:reverse-order="reverseOrder"
:no-padding="noPadding"
:label-class="labelClass"
:text-class="textClass"
/>
</div>
</template>
@@ -13,5 +17,20 @@ import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
import TopbarBadge from './TopbarBadge.vue'
withDefaults(
defineProps<{
reverseOrder?: boolean
noPadding?: boolean
labelClass?: string
textClass?: string
}>(),
{
reverseOrder: false,
noPadding: false,
labelClass: '',
textClass: ''
}
)
const topbarBadgeStore = useTopbarBadgeStore()
</script>

View File

@@ -208,6 +208,7 @@ export const useFirebaseAuthActions = () => {
signUpWithEmail,
updatePassword,
deleteAccount,
accessError
accessError,
reportError
}
}

View File

@@ -0,0 +1 @@
export const MONTHLY_SUBSCRIPTION_PRICE = 20

View File

@@ -0,0 +1,24 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudSubscription',
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { requireActiveSubscription } = useSubscription()
const checkSubscriptionStatus = () => {
if (!isLoggedIn.value) return
void requireActiveSubscription()
}
watch(() => isLoggedIn.value, checkSubscriptionStatus, {
immediate: true
})
}
})

View File

@@ -26,4 +26,5 @@ import './widgetInputs'
if (isCloud) {
import('./cloudBadge')
import('./cloudSubscription')
}

View File

@@ -1337,7 +1337,8 @@
"Notification Preferences": "Notification Preferences",
"3DViewer": "3DViewer",
"Vue Nodes": "Vue Nodes",
"Canvas Navigation": "Canvas Navigation"
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits"
},
"serverConfigItems": {
"listen": {
@@ -1777,6 +1778,8 @@
"failedToInitiateCreditPurchase": "Failed to initiate credit purchase: {error}",
"failedToAccessBillingPortal": "Failed to access billing portal: {error}",
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"failedToFetchSubscription": "Failed to fetch subscription status: {error}",
"failedToInitiateSubscription": "Failed to initiate subscription: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected",
@@ -1923,6 +1926,35 @@
"added": "Added",
"accountInitialized": "Account initialized"
},
"subscription": {
"title": "Subscription",
"comfyCloud": "Comfy Cloud",
"beta": "BETA",
"perMonth": "USD / month",
"renewsDate": "Renews {date}",
"manageSubscription": "Manage subscription",
"apiNodesBalance": "\"API Nodes\" Credit Balance",
"apiNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
"viewUsageHistory": "View usage history",
"addApiCredits": "Add API credits",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "$10 in monthly credits for API models — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"subscribeToRun": "Subscribe to Run",
"subscribeNow": "Subscribe Now"
},
"userSettings": {
"title": "User Settings",
"name": "Name",

View File

@@ -0,0 +1,99 @@
<template>
<Button
:label="label || $t('subscription.required.subscribe')"
:size="size"
:class="buttonClass"
:loading="isLoading"
:disabled="isPolling"
severity="primary"
@click="handleSubscribe"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { onBeforeUnmount, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
withDefaults(
defineProps<{
label?: string
size?: 'small' | 'large'
buttonClass?: string
}>(),
{
size: 'large',
buttonClass: 'w-full font-bold'
}
)
const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
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()
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
}
const handleSubscribe = async () => {
isLoading.value = true
try {
await subscribe()
startPollingSubscriptionStatus()
} catch (error) {
console.error('[SubscribeButton] Error initiating subscription:', error)
isLoading.value = false
}
}
onBeforeUnmount(() => {
stopPolling()
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<Button
v-tooltip.bottom="{
value: $t('subscription.subscribeToRun'),
showDelay: 600
}"
class="subscribe-to-run-button"
:label="$t('subscription.subscribeToRun')"
icon="pi pi-lock"
severity="primary"
size="small"
data-testid="subscribe-to-run-button"
@click="showSubscriptionDialog"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { showSubscriptionDialog } = useSubscription()
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
<Button
:label="$t('subscription.viewMoreDetails')"
text
icon="pi pi-external-link"
icon-pos="left"
size="small"
class="self-start !p-0 text-sm hover:!bg-transparent [&]:!text-[inherit]"
@click="handleViewMoreDetails"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud', '_blank')
}
</script>

View File

@@ -0,0 +1,276 @@
<template>
<TabPanel value="PlanCredits" class="subscription-container h-full">
<div class="flex h-full flex-col">
<div class="flex items-center gap-2">
<h2 class="text-2xl">
{{ $t('subscription.title') }}
</h2>
<TopbarBadges reverse-order />
</div>
<div class="grow overflow-auto">
<div class="rounded-lg border border-charcoal-400 p-4">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold">{{
formattedMonthlyPrice
}}</span>
<span>{{ $t('subscription.perMonth') }}</span>
</div>
<div v-if="isActiveSubscription" class="text-xs text-muted">
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</div>
</div>
<Button
v-if="isActiveSubscription"
:label="$t('subscription.manageSubscription')"
severity="secondary"
class="text-xs"
@click="manageSubscription"
/>
<SubscribeButton
v-else
:label="$t('subscription.subscribeNow')"
size="small"
button-class="text-xs"
@subscribed="handleRefresh"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 rounded-lg pt-10 lg:grid-cols-2">
<div class="flex flex-col">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.apiNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-xs text-muted">
{{ $t('subscription.apiNodesDescription') }}
</div>
<Button
icon="pi pi-question-circle"
text
rounded
size="small"
severity="secondary"
class="h-5 w-5"
/>
</div>
</div>
<div
class="flex flex-col gap-3 rounded-lg border p-4 dark-theme:border-0 dark-theme:bg-charcoal-600"
>
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<div class="text-2xl font-bold">${{ totalCredits }}</div>
</div>
<Button
icon="pi pi-sync"
severity="secondary"
size="small"
:loading="isLoadingBalance"
@click="handleRefresh"
/>
</div>
<div
v-if="latestEvents.length > 0"
class="flex flex-col gap-2 pt-3 text-xs"
>
<div
v-for="event in latestEvents"
:key="event.event_id"
class="flex items-center justify-between py-1"
>
<div class="flex flex-col gap-0.5">
<span class="font-medium">
{{
event.event_type
? customerEventService.formatEventType(
event.event_type
)
: ''
}}
</span>
<span class="text-muted">
{{
event.createdAt
? customerEventService.formatDate(event.createdAt)
: ''
}}
</span>
</div>
<div
v-if="event.params?.amount !== undefined"
class="font-bold"
>
${{
customerEventService.formatAmount(
event.params.amount as number
)
}}
</div>
</div>
</div>
<div class="flex items-center justify-between pt-2">
<Button
:label="$t('subscription.viewUsageHistory')"
text
severity="secondary"
class="p-0 text-xs text-muted"
@click="handleViewUsageHistory"
/>
<Button
:label="$t('subscription.addApiCredits')"
severity="secondary"
class="text-xs"
@click="handleAddApiCredits"
/>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="text-sm">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<SubscriptionBenefits />
</div>
</div>
</div>
</div>
<div
class="flex items-center justify-between border-t border-charcoal-400 pt-3"
>
<div class="flex gap-2">
<Button
:label="$t('subscription.learnMore')"
text
severity="secondary"
icon="pi pi-question-circle"
class="text-xs"
@click="handleLearnMore"
/>
<Button
:label="$t('subscription.messageSupport')"
text
severity="secondary"
icon="pi pi-comment"
class="text-xs"
@click="handleMessageSupport"
/>
</div>
<Button
:label="$t('subscription.invoiceHistory')"
text
severity="secondary"
icon="pi pi-external-link"
icon-pos="right"
class="text-xs"
@click="handleInvoiceHistory"
/>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import TabPanel from 'primevue/tabpanel'
import { computed, onMounted, ref } from 'vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 type { AuditLog } from '@/services/customerEventsService'
import { useCustomerEventsService } from '@/services/customerEventsService'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const authStore = useFirebaseAuthStore()
const customerEventService = useCustomerEventsService()
const {
isActiveSubscription,
formattedRenewalDate,
formattedMonthlyPrice,
manageSubscription,
handleViewUsageHistory,
handleLearnMore,
handleInvoiceHistory,
fetchStatus
} = useSubscription()
const latestEvents = ref<AuditLog[]>([])
const totalCredits = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
const fetchLatestEvents = async () => {
try {
const response = await customerEventService.getMyEvents({
page: 1,
limit: 2
})
if (response?.events) {
latestEvents.value = response.events
}
} catch (error) {
console.error('[SubscriptionPanel] Error fetching latest events:', error)
}
}
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
await commandStore.execute('Comfy.ContactSupport')
}
const handleRefresh = async () => {
await Promise.all([
authActions.fetchBalance(),
fetchStatus(),
fetchLatestEvents()
])
}
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="grid h-full grid-cols-5 px-10 pb-10">
<div
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
>
<video
autoplay
loop
muted
playsinline
class="h-full min-w-[125%] object-cover"
style="margin-left: -20%"
>
<source
src="/assets/images/cloud-subscription.webm"
type="video/webm"
/>
</video>
</div>
<div class="col-span-3 flex flex-col justify-between pl-8">
<div>
<div class="flex flex-col gap-4">
<div class="inline-flex items-center gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.required.title') }}
</div>
<TopbarBadges
reverse-order
no-padding
text-class="!text-sm !font-normal"
/>
</div>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-bold">{{ formattedMonthlyPrice }}</span>
<span class="text-xl">{{ $t('subscription.perMonth') }}</span>
</div>
</div>
<SubscriptionBenefits class="mt-6 text-muted" />
</div>
<div class="flex flex-col">
<SubscribeButton @subscribed="handleSubscribed" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import TopbarBadges from '@/components/topbar/TopbarBadges.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'
const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { formattedMonthlyPrice } = useSubscription()
const handleSubscribed = () => {
emit('close', true)
}
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
:deep(.p-button) {
color: white;
}
</style>

View File

@@ -0,0 +1,208 @@
import { computed, ref, watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
interface CloudSubscriptionCheckoutResponse {
checkout_url: string
}
interface CloudSubscriptionStatusResponse {
is_active: boolean
subscription_id: string
renewal_date: string
}
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const isActiveSubscription = computed(() => {
if (!isCloud) return true
return subscriptionStatus.value?.is_active ?? false
})
let isWatchSetup = false
export function useSubscription() {
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { reportError } = useFirebaseAuthActions()
const { isLoggedIn } = useCurrentUser()
const formattedRenewalDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const renewalDate = new Date(subscriptionStatus.value.renewal_date)
return renewalDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const formattedMonthlyPrice = computed(
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const fetchStatus = wrapWithErrorHandlingAsync(async () => {
return await fetchSubscriptionStatus()
}, reportError)
const subscribe = wrapWithErrorHandlingAsync(async () => {
const response = await initiateSubscriptionCheckout()
if (!response.checkout_url) {
throw new Error(
t('toastMessages.failedToInitiateSubscription', {
error: 'No checkout URL returned'
})
)
}
window.open(response.checkout_url, '_blank')
}, reportError)
const showSubscriptionDialog = () => {
dialogService.showSubscriptionRequiredDialog()
}
const manageSubscription = async () => {
await authActions.accessBillingPortal()
}
const requireActiveSubscription = async (): Promise<void> => {
await fetchSubscriptionStatus()
if (!isActiveSubscription.value) {
showSubscriptionDialog()
}
}
const handleViewUsageHistory = () => {
window.open('https://platform.comfy.org/profile/usage', '_blank')
}
const handleLearnMore = () => {
window.open('https://docs.comfy.org', '_blank')
}
const handleInvoiceHistory = async () => {
await authActions.accessBillingPortal()
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
const fetchSubscriptionStatus =
async (): Promise<CloudSubscriptionStatusResponse | null> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
)
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
)
}
const statusData = await response.json()
subscriptionStatus.value = statusData
return statusData
}
if (!isWatchSetup) {
isWatchSetup = true
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
}
},
{ immediate: true }
)
}
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-checkout`,
{
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
)
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorData.message
})
)
}
return response.json()
}
return {
// State
isActiveSubscription,
formattedRenewalDate,
formattedMonthlyPrice,
// Actions
subscribe,
fetchStatus,
showSubscriptionDialog,
manageSubscription,
requireActiveSubscription,
handleViewUsageHistory,
handleLearnMore,
handleInvoiceHistory
}
}

View File

@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
@@ -23,6 +24,7 @@ export function useSettingUI(
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
@@ -78,6 +80,22 @@ export function useSettingUI(
)
}
const subscriptionPanel: SettingPanelItem | null = !isCloud
? null
: {
node: {
key: 'subscription',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
)
)
}
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -129,7 +147,8 @@ export function useSettingUI(
userPanel,
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : [])
...(isElectron() ? [serverConfigPanel] : []),
...(isCloud && subscriptionPanel ? [subscriptionPanel] : [])
].filter((panel) => panel.component)
)
@@ -155,13 +174,16 @@ export function useSettingUI(
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Account settings - only show credits when user is authenticated
// Account settings - show different panels based on distribution and auth state
{
key: 'account',
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value ? [creditsPanel.node] : [])
...(isLoggedIn.value && isCloud && subscriptionPanel
? [subscriptionPanel.node]
: []),
...(isLoggedIn.value && !isCloud ? [creditsPanel.node] : [])
].map(translateCategory)
},
// Normal settings stored in the settingStore

View File

@@ -1,4 +1,5 @@
import { merge } from 'es-toolkit/compat'
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
@@ -13,6 +14,7 @@ import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordCon
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { useDialogStore } from '@/stores/dialogStore'
@@ -485,6 +487,37 @@ export const useDialogService = () => {
})
}
function showSubscriptionRequiredDialog() {
if (!isCloud) {
return
}
dialogStore.showDialog({
key: 'subscription-required',
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue'
)
),
props: {
onClose: () => {
dialogStore.closeDialog({ key: 'subscription-required' })
}
},
dialogComponentProps: {
closable: true,
style: 'width: 700px;',
pt: {
header: { class: '!p-0 !m-0' },
content: {
class: 'overflow-hidden !p-0 !m-0'
}
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -495,6 +528,7 @@ export const useDialogService = () => {
showManagerProgressDialog,
showApiNodesSignInDialog,
showSignInDialog,
showSubscriptionRequiredDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,

View File

@@ -39,7 +39,7 @@ type AccessBillingPortalResponse =
type AccessBillingPortalReqBody =
operations['AccessBillingPortal']['requestBody']
class FirebaseAuthStoreError extends Error {
export class FirebaseAuthStoreError extends Error {
constructor(message: string) {
super(message)
this.name = 'FirebaseAuthStoreError'

View File

@@ -0,0 +1,321 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
// Create mocks
const mockIsLoggedIn = ref(false)
const mockReportError = vi.fn()
const mockAccessBillingPortal = vi.fn()
const mockShowSubscriptionRequiredDialog = vi.fn()
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
// Mock dependencies
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
isLoggedIn: mockIsLoggedIn
}))
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
reportError: mockReportError,
accessBillingPortal: mockAccessBillingPortal
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: vi.fn(() => ({
wrapWithErrorHandlingAsync: vi.fn(
(fn, errorHandler) =>
async (...args: any[]) => {
try {
return await fn(...args)
} catch (error) {
if (errorHandler) {
errorHandler(error)
}
throw error
}
}
)
}))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
}))
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader
})),
FirebaseAuthStoreError: class extends Error {}
}))
// Mock fetch
global.fetch = vi.fn()
describe('useSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsLoggedIn.value = false
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response)
})
describe('computed properties', () => {
it('should compute isActiveSubscription correctly when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
const { isActiveSubscription, fetchStatus } = useSubscription()
await fetchStatus()
expect(isActiveSubscription.value).toBe(true)
})
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
const { isActiveSubscription, fetchStatus } = useSubscription()
await fetchStatus()
expect(isActiveSubscription.value).toBe(false)
})
it('should format renewal date correctly', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16T12:00:00Z'
})
} as Response)
mockIsLoggedIn.value = true
const { formattedRenewalDate, fetchStatus } = useSubscription()
await fetchStatus()
// The date format may vary based on timezone, so we just check it's a valid date string
expect(formattedRenewalDate.value).toMatch(/^[A-Za-z]{3} \d{1,2}, \d{4}$/)
expect(formattedRenewalDate.value).toContain('2025')
expect(formattedRenewalDate.value).toContain('Nov')
})
it('should return empty string when renewal date is not available', () => {
const { formattedRenewalDate } = useSubscription()
expect(formattedRenewalDate.value).toBe('')
})
it('should format monthly price correctly', () => {
const { formattedMonthlyPrice } = useSubscription()
expect(formattedMonthlyPrice.value).toBe('$20')
})
})
describe('fetchStatus', () => {
it('should fetch subscription status successfully', async () => {
const mockStatus = {
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
}
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockStatus
} as Response)
mockIsLoggedIn.value = true
const { fetchStatus } = useSubscription()
await fetchStatus()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-status'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
})
})
)
})
it('should handle fetch errors gracefully', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
json: async () => ({ message: 'Subscription not found' })
} as Response)
const { fetchStatus } = useSubscription()
await expect(fetchStatus()).rejects.toThrow()
})
})
describe('subscribe', () => {
it('should initiate subscription checkout successfully', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
// Mock window.open
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { subscribe } = useSubscription()
await subscribe()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
})
})
)
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
windowOpenSpy.mockRestore()
})
it('should throw error when checkout URL is not returned', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({})
} as Response)
const { subscribe } = useSubscription()
await expect(subscribe()).rejects.toThrow()
})
})
describe('requireActiveSubscription', () => {
it('should not show dialog when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
const { requireActiveSubscription } = useSubscription()
await requireActiveSubscription()
expect(mockShowSubscriptionRequiredDialog).not.toHaveBeenCalled()
})
it('should show dialog when subscription is inactive', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
const { requireActiveSubscription } = useSubscription()
await requireActiveSubscription()
expect(mockShowSubscriptionRequiredDialog).toHaveBeenCalled()
})
})
describe('action handlers', () => {
it('should open usage history URL', () => {
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { handleViewUsageHistory } = useSubscription()
handleViewUsageHistory()
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://platform.comfy.org/profile/usage',
'_blank'
)
windowOpenSpy.mockRestore()
})
it('should open learn more URL', () => {
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { handleLearnMore } = useSubscription()
handleLearnMore()
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://docs.comfy.org',
'_blank'
)
windowOpenSpy.mockRestore()
})
it('should call accessBillingPortal for invoice history', async () => {
const { handleInvoiceHistory } = useSubscription()
await handleInvoiceHistory()
expect(mockAccessBillingPortal).toHaveBeenCalled()
})
it('should call accessBillingPortal for manage subscription', async () => {
const { manageSubscription } = useSubscription()
await manageSubscription()
expect(mockAccessBillingPortal).toHaveBeenCalled()
})
})
})