Files
ComfyUI_frontend/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Dante cfaf89edea feat: role-aware billing status banner for team workspaces (FE-1246) (#13641)
Adds the single billing-status banner slot for team plans, rendered in
priority order — **paused > payment declined > out of credits > ending**
— with role-aware CTAs. At most one state shows at a time.

Placement matches the prototype (`comfydesigner/team-workspaces-v1`) and
Figma: the banner lives in the **workspace settings panel**, in one slot
between the tab list and the tab body, so it shows on every tab. Our
container has two tabs, so one mount covers both rather than duplicating
the banner into each panel as the prototype does.

Derivation lives in a pure `deriveBillingBanner()`; the component only
renders. State comes from `billing-status` fields, never from error
strings (per Luke's user story).


## Gated on the plan, not the workspace type

`useBillingContext` gains `isTeamPlan`: a credit stop marks the
per-credit Team plan, a `team-` slug the retired seat-based ones.
Workspace type is the wrong question in both directions:

- A **team workspace can sit on a retired seat-based plan** (tier
STANDARD/CREATOR/PRO) — the type gate admits it.
- Once consolidated billing lands (cloud#5010), a **personal workspace
can hold a team plan**. The backend blocks that today pending BE-1526,
so the type gate is only accidentally right, and only for now. Per
[Hunter](https://comfy-organization.slack.com/archives/C0BEE5503RQ/p1784151520761519?thread_ts=1784051425.176829):
*"We should deprecate the workspace type and derive it from the
subscription."*

Two deliberate non-choices:

- **Not gated on `isActiveSubscription`**, unlike the existing
`isLegacyTeamPlan`. The spend gate folds `billing_status` into
`is_active`, so a paused or payment-failed team plan reports
`is_active=false` — it must still read as a team plan exactly when the
banner is needed.
- **Not gated on `subscription_tier === 'TEAM'`**, which would silently
drop every legacy team subscriber. The FE cannot express `'TEAM'`
anyway: `tierPricing.ts` resolves `SubscriptionTier` from the
**registry** spec for what is an **ingest** field. That's a real bug,
but orthogonal — filing separately.

## Fixes payment_failed, which was dead code

`payment_failed` denies spend, so it always arrives with
`is_active=false` — and the check sat **below** the `is_active` gate,
two lines under a comment documenting that exact trap for `paused`.
**Every team in Stripe dunning saw no banner.** Now hoisted alongside
`paused`.

Its tests passed only because they spread the `funded` fixture
(`isActiveSubscription: true`) onto `payment_failed` — a pairing the
backend never emits. They now pin `is_active=false`. Both this and the
`isTeamPlan` decoupling are mutation-tested: reverting either kills
tests.

Members no longer fall through from `payment_failed` to out-of-credits —
with the real pairing that path was unreachable, and DES-380 says
members never see the payment banner.

## Renders

The six role/state variants render from the Storybook stories at
`Platform/Workspace/BillingStatusBanner`. Each story drives the real
`deriveBillingBanner` through a stubbed billing context, so a story can
only show a state the backend can actually produce — not a hand-set
banner kind.

Run them with `DISTRIBUTION=cloud pnpm storybook` — the banner is
cloud-only and `isCloud` is compile-time, so a plain `pnpm storybook`
renders every story empty.

Icon language follows the prototype's severity rule: amber
triangle-alert for every action-needed state (paused, payment declined,
out of credits), muted circle-alert reserved for the informational "plan
ends" notice.

## Feature flag

Personal-workspace routing now keys off **`billing_control_enabled`**,
replacing `consolidated_billing_enabled` (which had exactly one consumer
— this routing check — so it is replaced rather than left alongside).
Cloud registers the new flag in
[Comfy-Org/cloud#5091](https://github.com/Comfy-Org/cloud/pull/5091).

This coupling is load-bearing for the banner. `isTeamPlan` requires the
workspace billing rail, so a personal workspace holding a team plan only
surfaces billing state once its routing flag is on. Had routing stayed
on `consolidated_billing_enabled` while the subscribe path unblocks
under `billing_control_enabled`, the two could diverge: a personal
workspace could buy a team plan and then be routed to legacy, which
carries no `billing_status` at all, blanking the banner.

**Merge order:** cloud#5091 must merge and `billing_control_enabled`
must exist in PostHog before this ships, otherwise the flag resolves
false for everyone and every personal workspace routes to legacy. Both
flags default false, so the floor is today's behaviour — but any users
already rolled out on `consolidated_billing_enabled` would revert to
legacy billing until `billing_control_enabled` is rolled out to them.
Worth confirming the current rollout state before merge.

## Backend status

`billing_status: 'paused'` merged in cloud
[#5075](https://github.com/Comfy-Org/cloud/pull/5075) but is **not
emitted yet**: the lifecycle handler that writes it is unreachable in
prod (ingest's forward allowlist omits `customer.subscription.*`;
billing-api is ClusterIP with no ingress). That's BE-1530's unfinished
half, tracked backend-side. The paused banner stays inert until then.

The other three states render from fields the API already returns:
`billing_status=payment_failed` + `renewal_date`, `has_funds`, and
`cancel_at`.

## How has this been tested?

- `useBillingContext.test.ts` — `isTeamPlan` across per-credit, legacy,
paused, payment-failed, and team-workspace-on-personal-plan
- `deriveBillingBanner.test.ts` — priority order, role gating, and the
realistic `is_active=false` pairing for both paused and payment_failed
- `BillingStatusBanner.test.ts` — per-variant copy and actions, date
interpolation + no-date fallback, cross-mount dismiss, ending read-only
for a non-original owner
- `useBillingBanner.test.ts` — dismiss resets after a top-up so a later
exhaustion re-shows
- `WorkspacePanelContent.test.ts` — the banner takes one slot above the
tab body
- 696 tests pass across the touched areas; typecheck / lint / knip clean

## Known gaps

- **Blocked-run surface:** settings-scoped per the prototype, so a user
who exhausts credits mid-canvas won't see it until they open settings.
Queue-time blocked-submit is carried by the existing
insufficient-credits dialog. An app-shell surface would be a design
call, not this PR.
- **Personal workspaces on consolidated billing** get no banner —
`isTeamPlan` excludes them by design, matching FE-1246's team scope and
the `!isInPersonalWorkspace` precedent in `useSubscriptionDialog`. Their
copy would need personal variants (`outOfCredits.body` says "Your
team…") and a free-tier decision. Belongs with the consolidated-billing
rollout.
- **Prototype slot:** the prototype's banner exposes an `actions` slot
used only by its Invoices view. We have no such view, so it's omitted
until needed.


## screenshots

<img width="1150" height="574" alt="01-paused-owner"
src="https://github.com/user-attachments/assets/c1ff0abd-0286-4453-85f2-6f89cff1e655"
/>
<img width="1150" height="574" alt="02-paused-member"
src="https://github.com/user-attachments/assets/ad5ce843-cbc6-49d1-95fc-28e908bc010a"
/>
<img width="1150" height="574" alt="03-payment-declined"
src="https://github.com/user-attachments/assets/717c1191-8f12-4835-90c6-5fe345b20780"
/>
<img width="1150" height="574" alt="04-payment-declined-no-date"
src="https://github.com/user-attachments/assets/c7e483e4-e0cb-4810-b090-a9c432546dbd"
/>
<img width="1150" height="574" alt="05-out-of-credits-owner"
src="https://github.com/user-attachments/assets/9261fd39-ecdb-4501-a401-47d9cbee04dc"
/>
<img width="1150" height="574" alt="06-out-of-credits-member"
src="https://github.com/user-attachments/assets/4a91af88-4405-43e1-8975-b64fdf180a13"
/>
<img width="1150" height="574" alt="07-ending-owner"
src="https://github.com/user-attachments/assets/80e5011e-ef11-482f-bc49-1ddfbe991221"
/>
<img width="1150" height="574" alt="08-ending-non-original-owner"
src="https://github.com/user-attachments/assets/129b79aa-1427-4286-829c-eed501a6d44d"
/>
2026-07-17 00:08:30 +00:00

134 lines
4.6 KiB
TypeScript

import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import {
member,
mockWorkspace,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
// billing_control_enabled routes personal workspaces to the unified pricing
// table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
billing_control_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
// chain must not throw before it: a missing settings subpath, prompt exec_info,
// or queue status each abort that chain.
async function mockGraphBootExtras(page: Page) {
// Boot only reads these; fall back on any write so an unexpected POST/PUT
// surfaces instead of being masked by a blanket 200.
await page.route('**/api/settings/**', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({}))
})
await page.route('**/api/prompt', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
})
await page.route('**/api/queue', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
})
}
async function setupCloudApp(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await mockGraphBootExtras(page)
await mockBilling(page)
await mockWorkspace(page, ws, members)
await bootCloud(page)
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]pricing=/)
})
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=team`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(
page.getByRole('button', { name: 'For Teams' })
).toHaveAttribute('aria-pressed', 'true')
})
test('opens for a team original owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
})
test('is a silent no-op for a team member', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await page.goto(`${APP_URL}/?pricing=1`)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/[?&]pricing=/)
await expect(pricingHeading(page)).toBeHidden()
})
})