diff --git a/browser_tests/tests/dialogs/endedSubscription.spec.ts b/browser_tests/tests/dialogs/endedSubscription.spec.ts index 11413a26c7e..7234b3af7ea 100644 --- a/browser_tests/tests/dialogs/endedSubscription.spec.ts +++ b/browser_tests/tests/dialogs/endedSubscription.spec.ts @@ -81,6 +81,9 @@ test.describe('Inactive Team subscription billing', { tag: '@cloud' }, () => { await expect( content.getByRole('heading', { name: 'Inactive team subscription' }) ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Reactivate plan' }) + ).toBeVisible() await content.getByRole('button', { name: 'Billing & invoices' }).click() await expect .poll(() => page.locator('html').getAttribute('data-opened-url')) diff --git a/browser_tests/tests/dialogs/enterpriseTierCloud.spec.ts b/browser_tests/tests/dialogs/enterpriseTierCloud.spec.ts new file mode 100644 index 00000000000..2fe2ad08a2d --- /dev/null +++ b/browser_tests/tests/dialogs/enterpriseTierCloud.spec.ts @@ -0,0 +1,247 @@ +import { expect } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' +import type { BillingStatusResponse } from '@comfyorg/ingest-types' + +import { cloudAppFixture as test } from '@e2e/fixtures/cloudAppFixture' +import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog' +import { + DEFAULT_TEAM_MEMBERS, + TEAM_BILLING_STATUS, + TEAM_WORKSPACE +} from '@e2e/fixtures/data/cloudWorkspace' +import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper' + +const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188' + +const ACTIVE_ENTERPRISE_STATUS = { + ...TEAM_BILLING_STATUS, + subscription_tier: 'PRO', + plan_slug: 'enterprise_monthly', + renewal_date: '2027-04-25T00:00:00Z' +} satisfies BillingStatusResponse + +const ENDED_ENTERPRISE_STATUS = { + ...ACTIVE_ENTERPRISE_STATUS, + billing_status: 'inactive', + is_active: false, + subscription_status: 'ended' +} satisfies BillingStatusResponse + +const CANCELLED_ACTIVE_ENTERPRISE_STATUS = { + ...ACTIVE_ENTERPRISE_STATUS, + cancel_at: '2027-04-25T00:00:00Z', + subscription_status: 'canceled' +} satisfies BillingStatusResponse + +const ACTIVE_UNRECOGNIZED_TIER_STATUS = { + ...TEAM_BILLING_STATUS, + subscription_tier: 'FUTURE_TIER', + plan_slug: 'future-tier-monthly' +} + +async function setupWorkspace( + page: Page, + billingStatus: BillingStatusResponse +) { + const workspace = new CloudWorkspaceMockHelper(page) + await workspace.setup(DEFAULT_TEAM_MEMBERS, TEAM_WORKSPACE, billingStatus) + return workspace +} + +async function captureOpenedUrls(page: Page) { + await page.addInitScript(() => { + window.open = (url) => { + document.documentElement.dataset.openedUrl = String(url) + return window + } + }) +} + +async function expectNoSelfServicePlanActions(content: Locator) { + await expect( + content.getByRole('button', { + name: /Change plan|Cancel|Subscribe|Reactivate/i + }) + ).toHaveCount(0) + await expect( + content.getByText('View more details about plans & pricing') + ).toHaveCount(0) +} + +test.describe('Enterprise workspace billing', { tag: '@cloud' }, () => { + test.describe.configure({ timeout: 60_000 }) + + test('shows sales-managed plan details without catalog or lifecycle controls', async ({ + page + }) => { + const workspace = await setupWorkspace(page, ACTIVE_ENTERPRISE_STATUS) + const content = await workspace.openWorkspaceSettings() + + await expect( + content.getByRole('heading', { name: 'Enterprise' }) + ).toBeVisible() + await expect(content.getByText('Renews on Apr 25, 2027')).toBeVisible() + await expect(content.getByText('Your plan includes:')).toHaveCount(0) + await expectNoSelfServicePlanActions(content) + }) + + test('keeps credit top-up and billing portal access available', async ({ + page + }) => { + await captureOpenedUrls(page) + const workspace = await setupWorkspace(page, ACTIVE_ENTERPRISE_STATUS) + const content = await workspace.openWorkspaceSettings() + + await content.getByRole('button', { name: 'Billing & invoices' }).click() + await expect + .poll(() => page.locator('html').getAttribute('data-opened-url')) + .toBe('https://billing.example/portal') + + await content.getByRole('button', { name: 'Add credits' }).click() + await new TopUpCreditsDialog(page).waitForVisible() + }) + + test('removes pricing entry points from the profile menu and deep links', async ({ + page + }) => { + await setupWorkspace(page, ACTIVE_ENTERPRISE_STATUS) + await page.goto(`${APP_URL}/?pricing=team`) + await page.waitForFunction(() => !!window.app?.extensionManager, null, { + timeout: 45_000 + }) + + await expect(page).not.toHaveURL(/[?&]pricing=/) + await expect( + page.getByRole('heading', { name: 'Choose a Plan' }) + ).toHaveCount(0) + + await page.getByRole('button', { name: 'Current user' }).click() + const popover = page.getByTestId('current-user-popover') + await expect(popover.getByTestId('add-credits-button')).toBeVisible() + await expect(popover.getByTestId('manage-plan-menu-item')).toBeVisible() + await expect(popover.getByTestId('plans-pricing-menu-item')).toHaveCount(0) + }) + + test('keeps a cancelled active Enterprise workspace sales-managed', async ({ + page + }) => { + const workspace = await setupWorkspace( + page, + CANCELLED_ACTIVE_ENTERPRISE_STATUS + ) + const content = await workspace.openWorkspaceSettings() + + await expect( + content.getByRole('heading', { name: 'Enterprise' }) + ).toBeVisible() + await expect(content.getByText('Canceled', { exact: true })).toBeVisible() + await expectNoSelfServicePlanActions(content) + + await page + .getByTestId('settings-dialog') + .getByRole('button', { name: 'Close dialog' }) + .click() + await page.getByRole('button', { name: 'Current user' }).click() + const popover = page.getByTestId('current-user-popover') + await expect( + popover.getByRole('button', { name: 'Resubscribe' }) + ).toHaveCount(0) + await expect(popover.getByTestId('plans-pricing-menu-item')).toHaveCount(0) + }) + + test('keeps an ended Enterprise workspace out of self-service recovery', async ({ + page + }) => { + const workspace = await setupWorkspace(page, ENDED_ENTERPRISE_STATUS) + const content = await workspace.openWorkspaceSettings() + + await expect(content.getByText('Your subscription has ended')).toBeVisible() + await expect( + content.getByRole('heading', { name: 'Enterprise' }) + ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Add credits' }) + ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Billing & invoices' }) + ).toBeVisible() + await expectNoSelfServicePlanActions(content) + + await expect(page.getByTestId('queue-button')).toBeVisible() + await expect(page.getByTestId('subscribe-to-run-button')).toHaveCount(0) + + await page + .getByTestId('settings-dialog') + .getByRole('button', { name: 'Close dialog' }) + .click() + await page.getByTestId('queue-button').click() + await expect( + page.getByRole('heading', { name: 'Choose a Plan' }) + ).toHaveCount(0) + }) +}) + +test.describe('Non-Enterprise billing regression', { tag: '@cloud' }, () => { + test('keeps self-service controls and catalog details for a Pro team', async ({ + page + }) => { + const workspace = await setupWorkspace(page, TEAM_BILLING_STATUS) + const content = await workspace.openWorkspaceSettings() + + await expect( + content.getByRole('heading', { name: 'Team', exact: true }) + ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Change plan' }) + ).toBeVisible() + await expect( + content.getByText('Your plan includes everything in Pro, plus:', { + exact: false + }) + ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Add credits' }) + ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Billing & invoices' }) + ).toBeVisible() + + await page + .getByTestId('settings-dialog') + .getByRole('button', { name: 'Close dialog' }) + .click() + await page.getByRole('button', { name: 'Current user' }).click() + await expect( + page + .getByTestId('current-user-popover') + .getByTestId('plans-pricing-menu-item') + ).toBeVisible() + }) +}) + +test.describe('Unrecognized billing tier regression', { tag: '@cloud' }, () => { + test.describe.configure({ timeout: 60_000 }) + + test('keeps an active subscription usable when its tier is unrecognized', async ({ + page + }) => { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + + const workspace = await setupWorkspace(page, TEAM_BILLING_STATUS) + await page.route('**/api/billing/status', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(ACTIVE_UNRECOGNIZED_TIER_STATUS) + }) + ) + const content = await workspace.openWorkspaceSettings() + + await expect(content.getByText('Total credits')).toBeVisible() + await expect( + content.getByRole('button', { name: 'Add credits' }) + ).toBeVisible() + expect(pageErrors).toEqual([]) + }) +}) diff --git a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts index 9ae38729181..b46f6d21bb7 100644 --- a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts +++ b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts @@ -1,13 +1,14 @@ import userEvent from '@testing-library/user-event' import { render, screen, waitFor } from '@testing-library/vue' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, ref } from 'vue' +import { computed, nextTick, ref } from 'vue' import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue' const mockCanRunWorkflows = ref(true) const mockIsInitialized = ref(true) const mockBillingStatus = ref('paid') +const mockShowsSubscribeUpsellUI = ref(true) const state = vi.hoisted(() => ({ v1PaymentRecovery: true, canManageSubscription: true, @@ -46,6 +47,18 @@ vi.mock('@/composables/useFeatureFlags', () => ({ }) })) +vi.mock( + '@/platform/cloud/subscription/composables/useBillingPolicyCapabilities', + () => ({ + useBillingPolicyCapabilities: () => ({ + billingPolicyCapabilities: computed(() => ({ + topUpAccess: 'allowed', + showsSubscribeUpsellUI: mockShowsSubscribeUpsellUI.value + })) + }) + }) +) + vi.mock('@/composables/useErrorHandling', () => ({ useErrorHandling: () => ({ toastErrorHandler: state.toastErrorHandler }) })) @@ -98,6 +111,7 @@ describe('CloudRunButtonWrapper', () => { mockCanRunWorkflows.value = true mockIsInitialized.value = true mockBillingStatus.value = 'paid' + mockShowsSubscribeUpsellUI.value = true state.v1PaymentRecovery = true state.canManageSubscription = true }) @@ -131,6 +145,17 @@ describe('CloudRunButtonWrapper', () => { expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument() }) + it('keeps the run button without a subscribe upsell when policy disables it', () => { + mockCanRunWorkflows.value = false + mockShowsSubscribeUpsellUI.value = false + renderWrapper() + + expect(screen.getByTestId('queue-button')).toBeInTheDocument() + expect( + screen.queryByTestId('subscribe-to-run-button') + ).not.toBeInTheDocument() + }) + it('refreshes stale billing state on focus and restores Run', async () => { mockCanRunWorkflows.value = false mockBillingStatus.value = 'inactive' diff --git a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue index 3d3bdac32f4..8f9c2d61deb 100644 --- a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue +++ b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue @@ -1,6 +1,10 @@ -