From a9db25ecc3c64d09a8a560f81c948b58849c727f Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Oct 2025 00:14:03 -0700 Subject: [PATCH] [backport rh-test] subscription panel (#6140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Backport of #6064 (subscription page) to the `rh-test` branch. This PR manually cherry-picks commit 7e1e8e3b65f664a5f3dbaf48f4db3920c7711a7b to the rh-test branch and resolves merge conflicts that prevented automatic backporting. ## Conflicts Resolved ### 1. `src/components/actionbar/ComfyActionbar.vue` - **Conflict**: HEAD (rh-test) used `` while the subscription PR introduced `` - **Resolution**: Updated to use `` to include the subscription functionality wrapper while maintaining the existing rh-test template structure ### 2. `src/composables/auth/useFirebaseAuthActions.ts` - **Conflict**: Simple ordering difference in the return statement - **Resolution**: Used the subscription PR's ordering: `deleteAccount, accessError, reportError` ## Testing The cherry-pick completed successfully and passed all pre-commit hooks: - ✅ ESLint - ✅ Prettier formatting - ⚠️ Note: 2 unused i18n keys detected (informational only, same as original PR) ## Related - Original PR: #6064 - Cherry-picked commit: 7e1e8e3b65f664a5f3dbaf48f4db3920c7711a7b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6140-backport-subscription-page-to-rh-test-2916d73d365081f38f00df422004f61a) by [Unito](https://www.unito.io) Co-authored-by: Terry Jia Co-authored-by: GitHub Action --- public/assets/images/cloud-subscription.webm | Bin 0 -> 66404 bytes src/components/actionbar/ComfyActionbar.vue | 4 +- .../ComfyRunButton/CloudRunButtonWrapper.vue | 19 ++ .../{ => ComfyRunButton}/ComfyQueueButton.vue | 2 +- .../actionbar/ComfyRunButton/index.ts | 7 + src/components/topbar/TopbarBadge.vue | 29 +- src/components/topbar/TopbarBadges.vue | 19 ++ .../auth/useFirebaseAuthActions.ts | 4 +- src/config/subscriptionPricesConfig.ts | 1 + src/extensions/core/cloudSubscription.ts | 24 ++ src/extensions/core/index.ts | 1 + src/locales/en/main.json | 34 +- .../components/SubscribeButton.vue | 99 ++++++ .../components/SubscribeToRun.vue | 23 ++ .../components/SubscriptionBenefits.vue | 35 ++ .../components/SubscriptionPanel.vue | 276 +++++++++++++++ .../SubscriptionRequiredDialogContent.vue | 76 +++++ .../composables/useSubscription.ts | 208 ++++++++++++ .../settings/composables/useSettingUI.ts | 28 +- src/services/dialogService.ts | 34 ++ src/stores/firebaseAuthStore.ts | 2 +- .../subscription/useSubscription.test.ts | 321 ++++++++++++++++++ 22 files changed, 1231 insertions(+), 15 deletions(-) create mode 100644 public/assets/images/cloud-subscription.webm create mode 100644 src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue rename src/components/actionbar/{ => ComfyRunButton}/ComfyQueueButton.vue (98%) create mode 100644 src/components/actionbar/ComfyRunButton/index.ts create mode 100644 src/config/subscriptionPricesConfig.ts create mode 100644 src/extensions/core/cloudSubscription.ts create mode 100644 src/platform/cloud/subscription/components/SubscribeButton.vue create mode 100644 src/platform/cloud/subscription/components/SubscribeToRun.vue create mode 100644 src/platform/cloud/subscription/components/SubscriptionBenefits.vue create mode 100644 src/platform/cloud/subscription/components/SubscriptionPanel.vue create mode 100644 src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue create mode 100644 src/platform/cloud/subscription/composables/useSubscription.ts create mode 100644 tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts diff --git a/public/assets/images/cloud-subscription.webm b/public/assets/images/cloud-subscription.webm new file mode 100644 index 0000000000000000000000000000000000000000..ec81ab6d1e23a739d00341b9f5d8baa78102b2d1 GIT binary patch literal 66404 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 S1ke - + @@ -36,7 +36,7 @@ import { computed, inject, nextTick, onMounted, ref, watch } from 'vue' 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 000000000..c0cda19bf --- /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 b290fecc3..aeaa18220 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 000000000..917c25921 --- /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 4ec12dffc..5519e28fd 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 ca5318ccc..04cc2dd09 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -176,8 +176,8 @@ export const useFirebaseAuthActions = () => { signInWithEmail, signUpWithEmail, updatePassword, + deleteAccount, accessError, - reportError, - deleteAccount + reportError } } diff --git a/src/config/subscriptionPricesConfig.ts b/src/config/subscriptionPricesConfig.ts new file mode 100644 index 000000000..46e8b4597 --- /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 000000000..c98fc5a83 --- /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 011502f44..faf4bcaf7 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 0f5dda3ba..3bed81b38 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1325,7 +1325,8 @@ "Notification Preferences": "Notification Preferences", "3DViewer": "3DViewer", "Vue Nodes": "Vue Nodes", - "Canvas Navigation": "Canvas Navigation" + "Canvas Navigation": "Canvas Navigation", + "PlanCredits": "Plan & Credits" }, "serverConfigItems": { "listen": { @@ -1765,6 +1766,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", @@ -1920,6 +1923,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 000000000..e9eb65fee --- /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 000000000..75dec98ca --- /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 000000000..c4be78f8b --- /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 000000000..8bacb94f9 --- /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 000000000..6a9beb54b --- /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 000000000..4d8e102ec --- /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 3f28c9b52..c8e9be87a 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 07316b01d..138f5be50 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 1cca35b23..526a86d00 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -40,7 +40,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 000000000..a36471bf5 --- /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() + }) + }) +})