mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
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" />
134 lines
4.6 KiB
TypeScript
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()
|
|
})
|
|
})
|