Compare commits

...

4 Commits

Author SHA1 Message Date
skishore23
3c84df8ef0 [chore] Update Ingest API types from cloud@14d24ef 2026-06-23 02:58:35 +00:00
Dante
a0f4feb111 feat(billing): downgrade-to-personal member-removal confirm flow (FE-977) (#12789)
## Summary

Owner-initiated team→personal downgrade flow: a confirm dialog ("Change
to {plan} plan?", "All other members of this workspace will be
immediately removed", type-"I understand" gate) that removes non-owner
members and then initiates the tier change. No dialog when there are no
other members.

## Changes

- **What**: `DowngradeRemoveMembersDialogContent.vue` (confirm + type-"I
understand" gate, destructive Change plan); `useDowngradeToPersonal.ts`
(validates via `previewSubscribe`, removes every non-creator member via
`workspaceApi.removeMember`, then `useBillingContext().subscribe` with
`needs_payment_method`/`pending_payment` handling à la
`useSubscriptionCheckout`);
`dialogService.showDowngradeToPersonalDialog` (refreshes members,
skip-dialog when no other members; non-dismissable while open).
`subscription.downgrade.*` i18n.
- **Breaking**: none.

## Review Focus

- **Creator protection**: the cloud model is single-owner with no
distinct creator field — a member is protected if `role === 'owner'` OR
they are the current user. To be reconciled with FE-770's
earliest-`joinDate` creator inference when both land (single source: the
`isCreator` predicate). BE-1337 will expose an explicit original-owner
determination.
- **Integration seam**: there is no on-main team→personal trigger
(FE-934 pricing-table plan change is unmerged); the flow is exposed via
`dialogService.showDowngradeToPersonalDialog` for FE-934 to call on
`transition_type: 'downgrade'`.
- **Failure-path hardening** (follow-up commits): `previewSubscribe`
gate runs before any member is removed (a BE-disallowed transition
removes nobody and surfaces the BE reason); `subscribe` outcomes
`needs_payment_method` (payment tab + billing-op polling; a
popup-blocked tab throws so the dialog stays open and a retry
re-subscribes) and `pending_payment` (polling) are handled instead of
discarded; the member list is refetched before the no-members fast path
(a stale empty store can no longer skip the confirm gate);
fast-path/refresh failures toast instead of escaping as unhandled
rejections; the dialog is not dismissable via ESC/overlay-click
(`closable: false` — `dialogStore.updateCloseOnEscapeStates` derives
`closeOnEscape` from `closable`, so `closeOnEscape: false` alone is
overridden).
- **Accepted non-atomicity (until BE-1337)**: removals and the tier
change are separate FE-orchestrated calls — if the user confirms, then
abandons/fails the payment-method step, members are already removed
while the plan stays team (poller surfaces an error toast after 120s).
The dedicated BE downgrade endpoint makes removal+transition atomic.
- Tests: type-gate exact-match; no-dialog when no other members;
preview→remove→subscribe ordering; disallowed preview removes nobody;
payment-method/pending/popup-blocked outcomes; member-refresh gate
(composable + dialogService level); non-dismissable dialog props;
fast-path toast; creator excluded; cancel no-ops; error keeps dialog
open (23 green). typecheck / oxlint / eslint / stylelint / oxfmt / knip
clean.

## Screenshots

Captured live on the PR branch against mocked workspace/billing APIs
(4-member team workspace; `DELETE /api/workspace/members/:id` and `POST
/api/billing/subscribe` intercepted). Flow verified end-to-end: all 3
non-owner members removed, then `subscribe('standard-monthly')` issued;
owner retained.

| Confirm gate (initial) | Phrase typed → enabled | Removing members +
subscribing |
| --- | --- | --- |
| <img width="400" alt="initial"
src="https://github.com/user-attachments/assets/bd5c2d41-edc5-48c2-a44e-b95f9b6bcbd7"
/> | <img width="400" alt="typed enabled"
src="https://github.com/user-attachments/assets/69d5ef0f-56f4-44ce-94a3-f4c05594f403"
/> | <img width="400" alt="loading"
src="https://github.com/user-attachments/assets/073ff7b3-49ca-4fda-9ab8-0fca869f7347"
/> |

In context over the pricing table (the FE-934 trigger seam):

<img width="800" alt="dialog over pricing table"
src="https://github.com/user-attachments/assets/6589f823-f511-42eb-ae2e-5d7d523bb2ee"
/>

Disallowed transition (`previewSubscribe` gate): BE reason surfaced,
dialog stays open, no member removed:

<img width="800" alt="preview disallowed error toast"
src="https://github.com/user-attachments/assets/58759c77-d824-4bfe-80d4-3847e2145456"
/>

Fixes FE-977
2026-06-23 02:24:17 +00:00
Dante
d4be483c03 fix(billing): widen user popover so the credits row keeps both buttons inside (#13052)
## Issue

In the top-right user popover, a **cancelled-but-still-active** personal
subscription renders **both** "Add credits" and "Resubscribe" in the
credits row (the user can still top up *and* re-subscribe during the
grace period). With a wide (7-digit) credit balance, balance + help icon
+ both buttons exceeded the fixed `w-80` (320px) popover and the
trailing "Resubscribe" button spilled past the right edge.

Surfaced during FE-991 (Billing Rework V1) testing. Pre-existing on
`main` — reproducible for any personal owner whose subscription is
cancelled but not yet expired.

## Fix

Make the popover width **fluid** instead of fixed: `w-fit` clamped to
`min-w-80 max-w-96`. It stays **320px** in the common single-action case
(unchanged) and only grows — to **~370px** — when the credits row
actually needs the room for a second button.

## Before / After

**Before (`w-80`)** — "Resubscribe" clipped past the popover edge:

<img width="320" alt="before"
src="https://github.com/user-attachments/assets/439baae8-9e04-4cdf-b43f-098fb5e3853f"
/>

**After — single action (stays 320px):**

<img width="320" alt="after-single"
src="https://github.com/user-attachments/assets/e96f784e-6afd-4286-80c3-1cf0ecec7aa8"
/>

**After — both actions (grows to ~370px, fits):**

<img width="370" alt="after-both"
src="https://github.com/user-attachments/assets/578c1528-24ad-4717-a2b5-33e1af78f048"
/>

## Test

Adds a `@cloud` e2e that opens the popover in the cancelled-but-active
state (mocked `/customers/cloud-subscription-status` with `end_date` + a
7-digit balance) and asserts the "Resubscribe" button's right edge stays
within the popover bounds — same bounding-box pattern as
`workspaceSwitcher.spec.ts`. Validated red→green locally (fails on fixed
`w-80`, passes with the fluid width); single-action width measured at
320px, both-action at ~370px.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 01:55:46 +00:00
Dante
8d0b21e9e8 fix(billing): keep successful team subscribe when post-write refresh fails (#12951)
## What

Mirrors the personal/legacy adapter fix (`useLegacyBilling.subscribe` in
#12945, FE-967) on the **team** workspace adapter.

`useWorkspaceBilling.subscribe` performed the write
(`workspaceApi.subscribe`) and then refreshed status + balance with
`Promise.all([fetchStatus(), fetchBalance()])`. A post-write **refresh**
failure rejected the whole call, so the caller saw "subscribe failed"
and could prompt a retry of an **already-active** subscription.

## Fix

The refresh is now non-fatal: `Promise.allSettled` runs the refresh and,
on a rejected refresh, surfaces a soft signal via the existing `error`
ref (`Subscription succeeded, but billing state refresh failed`) and
returns the successful `SubscribeResponse`. Write semantics and the
success / needs-payment / pending branches are unchanged; a failed
**write** still rejects as before.

## Test

Added a regression test: `subscribe` resolves but the post-write refresh
rejects -> still returns the response (no false failure) and records the
soft error.

## Verification

- `pnpm typecheck` clean
- `pnpm test:unit
src/platform/workspace/composables/useWorkspaceBilling.test.ts` -> 41
passed
- eslint / oxlint / oxfmt clean on changed files
2026-06-23 01:04:49 +00:00
14 changed files with 11800 additions and 11380 deletions

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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">

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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)
})
})
})

View 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
}
}

View File

@@ -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'))

View File

@@ -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) {

View 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()
})
})

View File

@@ -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
}
}