import Button from 'primevue/button'
+import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
+import { useFeatureFlags } from '@/composables/useFeatureFlags'
+import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.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 { isCloud } from '@/platform/distribution/types'
+import { useTelemetry } from '@/platform/telemetry'
+import { useCommandStore } from '@/stores/commandStore'
-defineProps<{
+const props = defineProps<{
onClose: () => void
}>()
@@ -86,19 +155,119 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
-const { formattedMonthlyPrice } = useSubscription()
+const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
+ useSubscription()
+const { featureFlag } = useFeatureFlags()
+const subscriptionTiersEnabled = featureFlag(
+ 'subscription_tiers_enabled',
+ false
+)
+const commandStore = useCommandStore()
+const telemetry = useTelemetry()
+
+const showStripePricingTable = computed(
+ () =>
+ subscriptionTiersEnabled.value &&
+ isCloud &&
+ window.__CONFIG__?.subscription_required
+)
+
+const POLL_INTERVAL_MS = 3000
+const MAX_POLL_DURATION_MS = 5 * 60 * 1000
+let pollInterval: number | null = null
+let pollStartTime = 0
+
+const stopPolling = () => {
+ if (pollInterval) {
+ clearInterval(pollInterval)
+ pollInterval = null
+ }
+}
+
+const startPolling = () => {
+ stopPolling()
+ pollStartTime = Date.now()
+
+ const poll = async () => {
+ try {
+ await fetchStatus()
+ } catch (error) {
+ console.error(
+ '[SubscriptionDialog] Failed to poll subscription status',
+ error
+ )
+ }
+ }
+
+ void poll()
+ pollInterval = window.setInterval(() => {
+ if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
+ stopPolling()
+ return
+ }
+ void poll()
+ }, POLL_INTERVAL_MS)
+}
+
+watch(
+ showStripePricingTable,
+ (enabled) => {
+ if (enabled) {
+ startPolling()
+ } else {
+ stopPolling()
+ }
+ },
+ { immediate: true }
+)
+
+watch(
+ () => isActiveSubscription.value,
+ (isActive) => {
+ if (isActive && showStripePricingTable.value) {
+ emit('close', true)
+ }
+ }
+)
const handleSubscribed = () => {
emit('close', true)
}
+
+const handleClose = () => {
+ stopPolling()
+ props.onClose()
+}
+
+const handleContactUs = async () => {
+ telemetry?.trackHelpResourceClicked({
+ resource_type: 'help_feedback',
+ is_external: true,
+ source: 'subscription'
+ })
+ await commandStore.execute('Comfy.ContactSupport')
+}
+
+const handleViewEnterprise = () => {
+ telemetry?.trackHelpResourceClicked({
+ resource_type: 'docs',
+ is_external: true,
+ source: 'subscription'
+ })
+ window.open('https://www.comfy.org/cloud/enterprise', '_blank')
+}
+
+onBeforeUnmount(() => {
+ stopPolling()
+})
diff --git a/src/platform/cloud/subscription/composables/useStripePricingTableLoader.ts b/src/platform/cloud/subscription/composables/useStripePricingTableLoader.ts
new file mode 100644
index 0000000000..06776166bc
--- /dev/null
+++ b/src/platform/cloud/subscription/composables/useStripePricingTableLoader.ts
@@ -0,0 +1,118 @@
+import { createSharedComposable } from '@vueuse/core'
+import { ref } from 'vue'
+
+import { STRIPE_PRICING_TABLE_SCRIPT_SRC } from '@/config/stripePricingTableConfig'
+
+function useStripePricingTableLoaderInternal() {
+ const isLoaded = ref(false)
+ const isLoading = ref(false)
+ const error = ref(null)
+ let pendingPromise: Promise | null = null
+
+ const resolveLoaded = () => {
+ isLoaded.value = true
+ isLoading.value = false
+ pendingPromise = null
+ }
+
+ const resolveError = (err: Error) => {
+ error.value = err
+ isLoading.value = false
+ pendingPromise = null
+ }
+
+ const loadScript = (): Promise => {
+ if (isLoaded.value) {
+ return Promise.resolve()
+ }
+
+ if (pendingPromise) {
+ return pendingPromise
+ }
+
+ const existingScript = document.querySelector(
+ `script[src="${STRIPE_PRICING_TABLE_SCRIPT_SRC}"]`
+ )
+
+ if (existingScript) {
+ isLoading.value = true
+
+ pendingPromise = new Promise((resolve, reject) => {
+ existingScript.addEventListener(
+ 'load',
+ () => {
+ existingScript.dataset.loaded = 'true'
+ resolveLoaded()
+ resolve()
+ },
+ { once: true }
+ )
+ existingScript.addEventListener(
+ 'error',
+ () => {
+ const err = new Error('Stripe pricing table script failed to load')
+ resolveError(err)
+ reject(err)
+ },
+ { once: true }
+ )
+
+ // Check if script already loaded after attaching listeners
+ if (
+ existingScript.dataset.loaded === 'true' ||
+ (existingScript as any).readyState === 'complete' ||
+ (existingScript as any).complete
+ ) {
+ existingScript.dataset.loaded = 'true'
+ resolveLoaded()
+ resolve()
+ }
+ })
+
+ return pendingPromise
+ }
+
+ isLoading.value = true
+ pendingPromise = new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.src = STRIPE_PRICING_TABLE_SCRIPT_SRC
+ script.async = true
+ script.dataset.loaded = 'false'
+
+ script.addEventListener(
+ 'load',
+ () => {
+ script.dataset.loaded = 'true'
+ resolveLoaded()
+ resolve()
+ },
+ { once: true }
+ )
+
+ script.addEventListener(
+ 'error',
+ () => {
+ const err = new Error('Stripe pricing table script failed to load')
+ resolveError(err)
+ reject(err)
+ },
+ { once: true }
+ )
+
+ document.head.appendChild(script)
+ })
+
+ return pendingPromise
+ }
+
+ return {
+ loadScript,
+ isLoaded,
+ isLoading,
+ error
+ }
+}
+
+export const useStripePricingTableLoader = createSharedComposable(
+ useStripePricingTableLoaderInternal
+)
diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts
index ccd54fe5e4..9762647d83 100644
--- a/src/platform/cloud/subscription/composables/useSubscription.ts
+++ b/src/platform/cloud/subscription/composables/useSubscription.ts
@@ -9,11 +9,11 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
-import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
+import { useDialogService } from '@/services/dialogService'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = {
@@ -37,7 +37,7 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
- const dialogService = useDialogService()
+ const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -102,7 +102,7 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
- void dialogService.showSubscriptionRequiredDialog()
+ void showSubscriptionRequiredDialog()
}
const shouldWatchCancellation = (): boolean =>
diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
index 1f3542c14a..30dea16dce 100644
--- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
@@ -1,4 +1,7 @@
-import { defineAsyncComponent } from 'vue'
+import { computed, defineAsyncComponent } from 'vue'
+
+import { useFeatureFlags } from '@/composables/useFeatureFlags'
+import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -7,6 +10,14 @@ const DIALOG_KEY = 'subscription-required'
export const useSubscriptionDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
+ const { flags } = useFeatureFlags()
+
+ const showStripeDialog = computed(
+ () =>
+ flags.subscriptionTiersEnabled &&
+ isCloud &&
+ window.__CONFIG__?.subscription_required
+ )
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -25,7 +36,19 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
- style: 'width: 700px;'
+ style: showStripeDialog.value
+ ? 'width: min(1100px, 90vw); max-height: 90vh;'
+ : 'width: 700px;',
+ pt: showStripeDialog.value
+ ? {
+ root: {
+ class: '!rounded-[32px] overflow-visible'
+ },
+ content: {
+ class: '!p-0 bg-transparent'
+ }
+ }
+ : undefined
}
})
}
diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts
index 7375ec048d..1faf919e4c 100644
--- a/src/platform/remoteConfig/types.ts
+++ b/src/platform/remoteConfig/types.ts
@@ -38,4 +38,6 @@ export type RemoteConfig = {
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
+ stripe_publishable_key?: string
+ stripe_pricing_table_id?: string
}
diff --git a/src/platform/settings/composables/useSettingUI.ts b/src/platform/settings/composables/useSettingUI.ts
index cfeb571dbd..6273a2fec9 100644
--- a/src/platform/settings/composables/useSettingUI.ts
+++ b/src/platform/settings/composables/useSettingUI.ts
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
+import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
+import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
@@ -33,6 +35,8 @@ export function useSettingUI(
const activeCategory = ref(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
+ const { isActiveSubscription } = useSubscription()
+ const { flags } = useFeatureFlags()
const settingRoot = computed(() => {
const root = buildTree(
@@ -102,6 +106,12 @@ export function useSettingUI(
)
}
+ const shouldShowPlanCreditsPanel = computed(() => {
+ if (!subscriptionPanel) return false
+ if (!flags.subscriptionTiersEnabled) return true
+ return isActiveSubscription.value
+ })
+
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -154,9 +164,7 @@ export function useSettingUI(
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
- ...(isCloud &&
- window.__CONFIG__?.subscription_required &&
- subscriptionPanel
+ ...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
@@ -191,8 +199,7 @@ export function useSettingUI(
children: [
userPanel.node,
...(isLoggedIn.value &&
- isCloud &&
- window.__CONFIG__?.subscription_required &&
+ shouldShowPlanCreditsPanel.value &&
subscriptionPanel
? [subscriptionPanel.node]
: []),
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index c38a275850..e02fab73a3 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -16,6 +16,15 @@ declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
}
+
+ interface ImportMetaEnv {
+ readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
+ readonly VITE_STRIPE_PRICING_TABLE_ID?: string
+ }
+
+ interface ImportMeta {
+ readonly env: ImportMetaEnv
+ }
}
export {}
diff --git a/tests-ui/tests/platform/cloud/subscription/components/StripePricingTable.test.ts b/tests-ui/tests/platform/cloud/subscription/components/StripePricingTable.test.ts
new file mode 100644
index 0000000000..9a4d9a3524
--- /dev/null
+++ b/tests-ui/tests/platform/cloud/subscription/components/StripePricingTable.test.ts
@@ -0,0 +1,113 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+import { ref } from 'vue'
+
+import enMessages from '@/locales/en/main.json' with { type: 'json' }
+import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
+
+const mockLoadStripeScript = vi.fn()
+let currentConfig = {
+ publishableKey: 'pk_test_123',
+ pricingTableId: 'prctbl_123'
+}
+let hasConfig = true
+
+vi.mock('@/config/stripePricingTableConfig', () => ({
+ getStripePricingTableConfig: () => currentConfig,
+ hasStripePricingTableConfig: () => hasConfig
+}))
+
+const mockIsLoaded = ref(false)
+const mockIsLoading = ref(false)
+const mockError = ref(null)
+
+vi.mock(
+ '@/platform/cloud/subscription/composables/useStripePricingTableLoader',
+ () => ({
+ useStripePricingTableLoader: () => ({
+ loadScript: mockLoadStripeScript,
+ isLoaded: mockIsLoaded,
+ isLoading: mockIsLoading,
+ error: mockError
+ })
+ })
+)
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: enMessages }
+})
+
+const mountComponent = () =>
+ mount(StripePricingTable, {
+ global: {
+ plugins: [i18n]
+ }
+ })
+
+describe('StripePricingTable', () => {
+ beforeEach(() => {
+ currentConfig = {
+ publishableKey: 'pk_test_123',
+ pricingTableId: 'prctbl_123'
+ }
+ hasConfig = true
+ mockLoadStripeScript.mockReset().mockResolvedValue(undefined)
+ mockIsLoaded.value = false
+ mockIsLoading.value = false
+ mockError.value = null
+ })
+
+ it('renders the Stripe pricing table when config is available', async () => {
+ const wrapper = mountComponent()
+
+ await flushPromises()
+
+ expect(mockLoadStripeScript).toHaveBeenCalled()
+
+ const stripePricingTable = wrapper.find('stripe-pricing-table')
+ expect(stripePricingTable.exists()).toBe(true)
+ expect(stripePricingTable.attributes('publishable-key')).toBe('pk_test_123')
+ expect(stripePricingTable.attributes('pricing-table-id')).toBe('prctbl_123')
+ })
+
+ it('shows missing config message when credentials are absent', () => {
+ hasConfig = false
+ currentConfig = { publishableKey: '', pricingTableId: '' }
+
+ const wrapper = mountComponent()
+
+ expect(
+ wrapper.find('[data-testid="stripe-table-missing-config"]').exists()
+ ).toBe(true)
+ expect(mockLoadStripeScript).not.toHaveBeenCalled()
+ })
+
+ it('shows loading indicator when script is loading', async () => {
+ // Mock loadScript to never resolve, simulating loading state
+ mockLoadStripeScript.mockImplementation(() => new Promise(() => {}))
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="stripe-table-loading"]').exists()).toBe(
+ true
+ )
+ expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
+ })
+
+ it('shows error indicator when script fails to load', async () => {
+ // Mock loadScript to reject, simulating error state
+ mockLoadStripeScript.mockRejectedValue(new Error('Script failed to load'))
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="stripe-table-error"]').exists()).toBe(
+ true
+ )
+ expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
+ })
+})