diff --git a/public/assets/images/cloud-subscription.webm b/public/assets/images/cloud-subscription.webm new file mode 100644 index 0000000000..ec81ab6d1e Binary files /dev/null and b/public/assets/images/cloud-subscription.webm differ diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index 9a93d2ebfd..c2b7fe8ed3 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -34,7 +34,8 @@ ) " /> - + + @@ -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() diff --git a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue new file mode 100644 index 0000000000..c0cda19bff --- /dev/null +++ b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue @@ -0,0 +1,19 @@ + + diff --git a/src/components/actionbar/ComfyQueueButton.vue b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue similarity index 98% rename from src/components/actionbar/ComfyQueueButton.vue rename to src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue index b290fecc3a..aeaa182203 100644 --- a/src/components/actionbar/ComfyQueueButton.vue +++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue @@ -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()) diff --git a/src/components/actionbar/ComfyRunButton/index.ts b/src/components/actionbar/ComfyRunButton/index.ts new file mode 100644 index 0000000000..917c25921d --- /dev/null +++ b/src/components/actionbar/ComfyRunButton/index.ts @@ -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')) diff --git a/src/components/topbar/TopbarBadge.vue b/src/components/topbar/TopbarBadge.vue index 4ec12dffcc..5519e28fd9 100644 --- a/src/components/topbar/TopbarBadge.vue +++ b/src/components/topbar/TopbarBadge.vue @@ -1,12 +1,19 @@ @@ -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() diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts index 52ac27e1c6..90ee9b33c2 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -157,6 +157,7 @@ export const useFirebaseAuthActions = () => { signUpWithEmail, updatePassword, deleteAccount, - accessError + accessError, + reportError } } diff --git a/src/config/subscriptionPricesConfig.ts b/src/config/subscriptionPricesConfig.ts new file mode 100644 index 0000000000..46e8b45977 --- /dev/null +++ b/src/config/subscriptionPricesConfig.ts @@ -0,0 +1 @@ +export const MONTHLY_SUBSCRIPTION_PRICE = 20 diff --git a/src/extensions/core/cloudSubscription.ts b/src/extensions/core/cloudSubscription.ts new file mode 100644 index 0000000000..c98fc5a83c --- /dev/null +++ b/src/extensions/core/cloudSubscription.ts @@ -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 + }) + } +}) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index 011502f443..faf4bcaf72 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -26,4 +26,5 @@ import './widgetInputs' if (isCloud) { import('./cloudBadge') + import('./cloudSubscription') } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 320c425574..8ea467724a 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1334,7 +1334,8 @@ "Notification Preferences": "Notification Preferences", "3DViewer": "3DViewer", "Vue Nodes": "Vue Nodes", - "Canvas Navigation": "Canvas Navigation" + "Canvas Navigation": "Canvas Navigation", + "PlanCredits": "Plan & Credits" }, "serverConfigItems": { "listen": { @@ -1774,6 +1775,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", @@ -1914,6 +1917,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", diff --git a/src/platform/cloud/subscription/components/SubscribeButton.vue b/src/platform/cloud/subscription/components/SubscribeButton.vue new file mode 100644 index 0000000000..e9eb65fee5 --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscribeButton.vue @@ -0,0 +1,99 @@ + + + diff --git a/src/platform/cloud/subscription/components/SubscribeToRun.vue b/src/platform/cloud/subscription/components/SubscribeToRun.vue new file mode 100644 index 0000000000..75dec98ca7 --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscribeToRun.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/platform/cloud/subscription/components/SubscriptionBenefits.vue b/src/platform/cloud/subscription/components/SubscriptionBenefits.vue new file mode 100644 index 0000000000..c4be78f8b1 --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscriptionBenefits.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/platform/cloud/subscription/components/SubscriptionPanel.vue b/src/platform/cloud/subscription/components/SubscriptionPanel.vue new file mode 100644 index 0000000000..8bacb94f9e --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscriptionPanel.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue new file mode 100644 index 0000000000..6a9beb54b1 --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts new file mode 100644 index 0000000000..4d8e102ece --- /dev/null +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -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(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 => { + 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 => { + 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 => { + 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 + } +} diff --git a/src/platform/settings/composables/useSettingUI.ts b/src/platform/settings/composables/useSettingUI.ts index 3f28c9b524..c8e9be87a8 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 { 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(() => [ - // 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 diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 07316b01dd..138f5be50b 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -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, diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 602de5ec6a..19512fd202 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -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' diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts new file mode 100644 index 0000000000..a36471bf50 --- /dev/null +++ b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts @@ -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() + }) + }) +})