mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-23 08:15:05 +00:00
Compare commits
4 Commits
add-cla-wo
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c84df8ef0 | ||
|
|
a0f4feb111 | ||
|
|
d4be483c03 | ||
|
|
8d0b21e9e8 |
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
16270
packages/ingest-types/src/types.gen.ts
generated
16270
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
4933
packages/ingest-types/src/zod.gen.ts
generated
4933
packages/ingest-types/src/zod.gen.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -2474,6 +2474,19 @@
|
||||
"confirmCancel": "Cancel subscription",
|
||||
"failed": "Failed to cancel subscription"
|
||||
},
|
||||
"downgrade": {
|
||||
"title": "Change to {plan} plan?",
|
||||
"body": "All other members of this workspace will be immediately removed.",
|
||||
"confirmationPhrase": "I understand",
|
||||
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
|
||||
"confirm": "Change plan",
|
||||
"failed": "Failed to change plan",
|
||||
"notAllowed": "This plan change is not available",
|
||||
"paymentMethodRequired": "A payment method is required to change plans",
|
||||
"paymentPageBlocked": "Couldn't open the payment page — please try again",
|
||||
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
|
||||
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
|
||||
},
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
class="current-user-popover -m-3 w-fit max-w-96 min-w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="mb-4 flex flex-col items-center px-0 py-3">
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import DowngradeRemoveMembersDialogContent from './DowngradeRemoveMembersDialogContent.vue'
|
||||
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {
|
||||
g: { cancel: 'Cancel', close: 'Close', unknownError: 'Unknown error' },
|
||||
subscription: {
|
||||
downgrade: {
|
||||
title: 'Change to {plan} plan?',
|
||||
body: 'All other members of this workspace will be immediately removed.',
|
||||
confirmationPhrase: 'I understand',
|
||||
confirmationPrompt: 'Type "{phrase}" to confirm.',
|
||||
confirm: 'Change plan',
|
||||
failed: 'Failed to change plan'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(DowngradeRemoveMembersDialogContent, {
|
||||
props: {
|
||||
planName: 'Founder',
|
||||
planSlug: 'founder-monthly',
|
||||
onConfirm,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { user, onConfirm }
|
||||
}
|
||||
|
||||
const getPhraseInput = () => screen.getByRole('textbox')
|
||||
const getChangePlanButton = () =>
|
||||
screen.getByRole('button', { name: 'Change plan' })
|
||||
const getCancelButton = () => screen.getByRole('button', { name: 'Cancel' })
|
||||
|
||||
describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('disables Change plan until the exact phrase is typed', async () => {
|
||||
const { user } = mountComponent()
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
|
||||
await user.type(getPhraseInput(), 'I understan')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
|
||||
await user.type(getPhraseInput(), 'd')
|
||||
expect(getChangePlanButton()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps Change plan disabled for a case-mismatched phrase', async () => {
|
||||
const { user } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'i understand')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('invokes onConfirm with the plan slug and closes when confirmed', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('founder-monthly')
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'downgrade-remove-members'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes without calling onConfirm when cancelled', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getCancelButton())
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'downgrade-remove-members'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error toast and stays open when onConfirm rejects', async () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const { user } = mountComponent({ onConfirm })
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('subscription.downgrade.title', { plan: planName }) }}
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
:disabled="isLoading"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.downgrade.body') }}
|
||||
</p>
|
||||
<label class="flex flex-col gap-2 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.downgrade.confirmationPrompt', { phrase }) }}
|
||||
<Input
|
||||
v-model="typedValue"
|
||||
type="text"
|
||||
:placeholder="phrase"
|
||||
:disabled="isLoading"
|
||||
autofocus
|
||||
@keyup.enter="onConfirmDowngrade"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 p-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:disabled="!isConfirmed"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmDowngrade"
|
||||
>
|
||||
{{ $t('subscription.downgrade.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { planName, planSlug, onConfirm } = defineProps<{
|
||||
planName: string
|
||||
planSlug: string
|
||||
onConfirm: (planSlug: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
|
||||
const phrase = t('subscription.downgrade.confirmationPhrase')
|
||||
|
||||
const typedValue = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isConfirmed = computed(() => typedValue.value === phrase)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
|
||||
}
|
||||
|
||||
async function onConfirmDowngrade() {
|
||||
if (!isConfirmed.value || isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await onConfirm(planSlug)
|
||||
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.downgrade.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import { useDowngradeToPersonal } from './useDowngradeToPersonal'
|
||||
|
||||
const mockMembers = ref<WorkspaceMember[]>([])
|
||||
const mockUserEmail = ref<string | null>(null)
|
||||
const mockRemoveMember = vi.hoisted(() => vi.fn())
|
||||
const mockFetchMembers = vi.hoisted(() => vi.fn())
|
||||
const mockSubscribe = vi.hoisted(() => vi.fn())
|
||||
const mockPreviewSubscribe = vi.hoisted(() => vi.fn())
|
||||
const mockStartOperation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
members: mockMembers,
|
||||
removeMember: mockRemoveMember,
|
||||
fetchMembers: mockFetchMembers
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
startOperation: mockStartOperation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
subscribe: mockSubscribe,
|
||||
previewSubscribe: mockPreviewSubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userEmail: mockUserEmail
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.test'
|
||||
}))
|
||||
|
||||
function createMember(
|
||||
overrides: Partial<WorkspaceMember> = {}
|
||||
): WorkspaceMember {
|
||||
return {
|
||||
id: 'member-1',
|
||||
name: 'Member One',
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function teamWithOwnerAnd(...memberIds: string[]) {
|
||||
return [
|
||||
createMember({
|
||||
id: 'owner',
|
||||
role: 'owner',
|
||||
email: 'owner@example.com',
|
||||
isOriginalOwner: true
|
||||
}),
|
||||
...memberIds.map((id) => createMember({ id, email: `${id}@example.com` }))
|
||||
]
|
||||
}
|
||||
|
||||
describe('useDowngradeToPersonal', () => {
|
||||
let windowOpen: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockMembers.value = []
|
||||
mockUserEmail.value = null
|
||||
mockPreviewSubscribe.mockResolvedValue({ allowed: true })
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
windowOpen = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
describe('removableMembers / hasOtherMembers', () => {
|
||||
it('protects only the original owner, removing promoted owners and members', () => {
|
||||
mockMembers.value = [
|
||||
createMember({ id: 'creator', role: 'owner', isOriginalOwner: true }),
|
||||
createMember({
|
||||
id: 'promoted-owner',
|
||||
role: 'owner',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({ id: 'member', role: 'member', isOriginalOwner: false })
|
||||
]
|
||||
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value.map((m) => m.id)).toEqual([
|
||||
'promoted-owner',
|
||||
'member'
|
||||
])
|
||||
expect(hasOtherMembers.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no other members when only the original owner is present', () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value).toEqual([])
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to protecting owners and the current user when the flag is absent', () => {
|
||||
mockUserEmail.value = 'me@example.com'
|
||||
mockMembers.value = [
|
||||
createMember({
|
||||
id: 'owner',
|
||||
role: 'owner',
|
||||
email: 'owner@example.com',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({
|
||||
id: 'me',
|
||||
role: 'member',
|
||||
email: 'me@example.com',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({
|
||||
id: 'plain',
|
||||
role: 'member',
|
||||
email: 'plain@example.com',
|
||||
isOriginalOwner: false
|
||||
})
|
||||
]
|
||||
const { removableMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value.map((m) => m.id)).toEqual(['plain'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('downgradeToPersonal', () => {
|
||||
it('removes every non-creator member then initiates the tier change', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(mockRemoveMember).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m2')
|
||||
expect(mockRemoveMember).not.toHaveBeenCalledWith('owner')
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'founder-monthly',
|
||||
'https://platform.test/payment/success',
|
||||
'https://platform.test/payment/failed'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never removes the original owner', async () => {
|
||||
mockMembers.value = [
|
||||
createMember({ id: 'me', role: 'owner', isOriginalOwner: true })
|
||||
]
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(mockRemoveMember).not.toHaveBeenCalled()
|
||||
expect(mockSubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates the transition before removing, then removes, then subscribes', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
const calls: string[] = []
|
||||
mockPreviewSubscribe.mockImplementation(() => {
|
||||
calls.push('preview')
|
||||
return Promise.resolve({ allowed: true })
|
||||
})
|
||||
mockRemoveMember.mockImplementation(() => {
|
||||
calls.push('remove')
|
||||
return Promise.resolve()
|
||||
})
|
||||
mockSubscribe.mockImplementation(() => {
|
||||
calls.push('subscribe')
|
||||
return Promise.resolve({ billing_op_id: 'op-1', status: 'subscribed' })
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(calls).toEqual(['preview', 'remove', 'subscribe'])
|
||||
})
|
||||
|
||||
it('throws the BE reason and removes nobody when the transition is disallowed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockPreviewSubscribe.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: 'Outstanding balance'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'Outstanding balance'
|
||||
)
|
||||
expect(mockRemoveMember).not.toHaveBeenCalled()
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the payment-method page and polls when subscribe needs a payment method', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-2',
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://pay.test/method'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith(
|
||||
'https://pay.test/method',
|
||||
'_blank'
|
||||
)
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-2', 'subscription')
|
||||
})
|
||||
|
||||
it('falls back to the generic message when the transition is disallowed without a reason', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockPreviewSubscribe.mockResolvedValue({ allowed: false })
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.notAllowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and skips polling when the payment tab is popup-blocked', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-5',
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://pay.test/method'
|
||||
})
|
||||
windowOpen.mockReturnValue(null)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.paymentPageBlocked'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when a payment method is needed but no url is provided', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-3',
|
||||
status: 'needs_payment_method'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.paymentMethodRequired'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('polls without opening a tab when the payment is pending', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-4',
|
||||
status: 'pending_payment'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(windowOpen).not.toHaveBeenCalled()
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-4', 'subscription')
|
||||
})
|
||||
|
||||
it('reports the generic failure when subscribe fails and no members were removed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
/^subscription\.downgrade\.failed$/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports members were already removed when subscribe fails after removal', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.failedAfterMemberRemoval'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces which member failed and skips the plan change when removal throws', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
mockRemoveMember.mockImplementation((id: string) =>
|
||||
id === 'm2' ? Promise.reject(new Error('network')) : Promise.resolve()
|
||||
)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'm2@example.com'
|
||||
)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMembers', () => {
|
||||
it('refetches members so a stale empty list cannot skip the confirm gate', async () => {
|
||||
mockMembers.value = []
|
||||
mockFetchMembers.mockImplementation(() => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
return Promise.resolve(mockMembers.value)
|
||||
})
|
||||
const { refreshMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
|
||||
await refreshMembers()
|
||||
|
||||
expect(hasOtherMembers.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
101
src/platform/workspace/composables/useDowngradeToPersonal.ts
Normal file
101
src/platform/workspace/composables/useDowngradeToPersonal.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
/**
|
||||
* Team-plan downgrade to personal: validate via `previewSubscribe`, remove
|
||||
* every member except the original owner, then initiate the tier change.
|
||||
* BE seam (BE-1337): removal email and an atomic downgrade endpoint are
|
||||
* BE-owned; until then the FE orchestrates the two steps non-atomically.
|
||||
*/
|
||||
export function useDowngradeToPersonal() {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(workspaceStore)
|
||||
const { subscribe, previewSubscribe } = useBillingContext()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const { userEmail } = useCurrentUser()
|
||||
|
||||
const removableMembers = computed(() => {
|
||||
const hasFlag = members.value.some((m) => m.isOriginalOwner)
|
||||
if (hasFlag) return members.value.filter((m) => !m.isOriginalOwner)
|
||||
const email = userEmail.value?.toLowerCase() ?? null
|
||||
return members.value.filter(
|
||||
(m) => m.role !== 'owner' && m.email.toLowerCase() !== email
|
||||
)
|
||||
})
|
||||
|
||||
const hasOtherMembers = computed(() => removableMembers.value.length > 0)
|
||||
|
||||
async function refreshMembers(): Promise<void> {
|
||||
await workspaceStore.fetchMembers()
|
||||
}
|
||||
|
||||
async function downgradeToPersonal(planSlug: string): Promise<void> {
|
||||
const preview = await previewSubscribe(planSlug)
|
||||
if (!preview?.allowed) {
|
||||
throw new Error(preview?.reason || t('subscription.downgrade.notAllowed'))
|
||||
}
|
||||
|
||||
const membersToRemove = removableMembers.value
|
||||
for (const member of membersToRemove) {
|
||||
try {
|
||||
await workspaceStore.removeMember(member.id)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
t('subscription.downgrade.memberRemovalFailed', {
|
||||
email: member.email
|
||||
}),
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
membersToRemove.length > 0
|
||||
? t('subscription.downgrade.failedAfterMemberRemoval')
|
||||
: t('subscription.downgrade.failed')
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
if (!response.payment_method_url) {
|
||||
throw new Error(t('subscription.downgrade.paymentMethodRequired'))
|
||||
}
|
||||
const paymentTab = window.open(response.payment_method_url, '_blank')
|
||||
if (!paymentTab) {
|
||||
throw new Error(t('subscription.downgrade.paymentPageBlocked'))
|
||||
}
|
||||
void billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'pending_payment') {
|
||||
void billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removableMembers,
|
||||
hasOtherMembers,
|
||||
refreshMembers,
|
||||
downgradeToPersonal
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,27 @@ describe('useWorkspaceBilling', () => {
|
||||
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
|
||||
})
|
||||
|
||||
it('returns the successful response when the post-subscribe refresh fails', async () => {
|
||||
mockWorkspaceApi.subscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
mockWorkspaceApi.getBillingStatus.mockRejectedValue(
|
||||
new Error('refresh down')
|
||||
)
|
||||
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.subscribe('pro')).resolves.toStrictEqual({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
expect(billing.error.value).toBe(
|
||||
'Subscription succeeded, but billing state refresh failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates error and records message when subscribe fails', async () => {
|
||||
mockWorkspaceApi.subscribe.mockRejectedValue(new Error('denied'))
|
||||
|
||||
|
||||
@@ -146,8 +146,18 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
// Refresh is non-fatal: the subscribe write already succeeded, so a failed
|
||||
// refresh must not reject and prompt a retry of an active subscription.
|
||||
const [statusResult, balanceResult] = await Promise.allSettled([
|
||||
fetchStatus(),
|
||||
fetchBalance()
|
||||
])
|
||||
if (
|
||||
statusResult.status === 'rejected' ||
|
||||
balanceResult.status === 'rejected'
|
||||
) {
|
||||
error.value = 'Subscription succeeded, but billing state refresh failed'
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
|
||||
124
src/services/dialogService.downgrade.test.ts
Normal file
124
src/services/dialogService.downgrade.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* showDowngradeToPersonalDialog must refresh members before the no-members
|
||||
* fast path and stay non-dismissable (ESC derives from `closable` in
|
||||
* dialogStore); fast-path failures must toast.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const toastAdd = vi.hoisted(() => vi.fn())
|
||||
const refreshMembers = vi.hoisted(() => vi.fn())
|
||||
const downgradeToPersonal = vi.hoisted(() => vi.fn())
|
||||
const hasOtherMembers = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
isFreeTier: { value: false },
|
||||
type: { value: 'legacy' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useDowngradeToPersonal', () => ({
|
||||
useDowngradeToPersonal: () => ({
|
||||
hasOtherMembers,
|
||||
refreshMembers,
|
||||
downgradeToPersonal
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue',
|
||||
() => ({ default: { name: 'DowngradeRemoveMembersDialogContent' } })
|
||||
)
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('showDowngradeToPersonalDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
hasOtherMembers.value = false
|
||||
refreshMembers.mockResolvedValue(undefined)
|
||||
downgradeToPersonal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const options = { planName: 'Standard', planSlug: 'standard-monthly' }
|
||||
|
||||
it('refreshes members before deciding the no-members fast path', async () => {
|
||||
const calls: string[] = []
|
||||
refreshMembers.mockImplementation(() => {
|
||||
calls.push('refresh')
|
||||
return Promise.resolve()
|
||||
})
|
||||
downgradeToPersonal.mockImplementation(() => {
|
||||
calls.push('downgrade')
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(calls).toEqual(['refresh', 'downgrade'])
|
||||
expect(downgradeToPersonal).toHaveBeenCalledWith('standard-monthly')
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a non-dismissable confirm dialog when other members exist', async () => {
|
||||
hasOtherMembers.value = true
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(downgradeToPersonal).not.toHaveBeenCalled()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('downgrade-remove-members')
|
||||
expect(args.props.onConfirm).toBe(downgradeToPersonal)
|
||||
expect(args.dialogComponentProps.closable).toBe(false)
|
||||
expect(args.dialogComponentProps.dismissableMask).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts and does not rethrow when the fast-path downgrade fails', async () => {
|
||||
downgradeToPersonal.mockRejectedValue(new Error('Outstanding balance'))
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Outstanding balance'
|
||||
})
|
||||
)
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts and aborts when the member refresh fails', async () => {
|
||||
hasOtherMembers.value = true
|
||||
refreshMembers.mockRejectedValue(new Error('network'))
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error', detail: 'network' })
|
||||
)
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
expect(downgradeToPersonal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
@@ -607,6 +608,53 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade a team plan to a personal plan (FE-977). Skips the type-"I
|
||||
* understand" confirm dialog when the workspace has no other members;
|
||||
* failures on that path surface as an error toast.
|
||||
*/
|
||||
async function showDowngradeToPersonalDialog(options: {
|
||||
planName: string
|
||||
planSlug: string
|
||||
}) {
|
||||
const { useDowngradeToPersonal } =
|
||||
await import('@/platform/workspace/composables/useDowngradeToPersonal')
|
||||
const { hasOtherMembers, refreshMembers, downgradeToPersonal } =
|
||||
useDowngradeToPersonal()
|
||||
|
||||
try {
|
||||
await refreshMembers()
|
||||
if (!hasOtherMembers.value) {
|
||||
await downgradeToPersonal(options.planSlug)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.downgrade.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'downgrade-remove-members',
|
||||
component,
|
||||
props: {
|
||||
planName: options.planName,
|
||||
planSlug: options.planSlug,
|
||||
onConfirm: downgradeToPersonal
|
||||
},
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps,
|
||||
closable: false,
|
||||
dismissableMask: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Shows one-time cloud notification modal for macOS desktop users. */
|
||||
async function showCloudNotification(): Promise<void> {
|
||||
const { default: component } = await lazyCloudNotificationContent()
|
||||
@@ -668,6 +716,7 @@ export const useDialogService = () => {
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog
|
||||
showCancelSubscriptionDialog,
|
||||
showDowngradeToPersonalDialog
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user