mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +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" />
85 lines
2.5 KiB
TypeScript
85 lines
2.5 KiB
TypeScript
import {
|
|
cachedBillingControlEnabled,
|
|
cachedTeamWorkspacesEnabled,
|
|
remoteConfig,
|
|
remoteConfigState
|
|
} from './remoteConfig'
|
|
|
|
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
|
|
// A same-origin GET against the local comfyui server should resolve in well under a second;
|
|
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
|
|
const FEATURES_FETCH_TIMEOUT_MS = 5_000
|
|
|
|
interface RefreshRemoteConfigOptions {
|
|
/**
|
|
* Whether to use authenticated API (default: true).
|
|
* Set to false during bootstrap before auth is initialized.
|
|
*/
|
|
useAuth?: boolean
|
|
}
|
|
|
|
async function fetchRemoteConfig(
|
|
useAuth: boolean,
|
|
signal?: AbortSignal
|
|
): Promise<Response> {
|
|
const { api } = await import('@/scripts/api')
|
|
if (!useAuth) {
|
|
return fetch(api.apiURL('/features'), { cache: 'no-store', signal })
|
|
}
|
|
return api.fetchApi('/features', { cache: 'no-store' })
|
|
}
|
|
|
|
/**
|
|
* Loads remote configuration from the backend /features endpoint
|
|
* and updates the reactive remoteConfig ref.
|
|
*
|
|
* Sets remoteConfigState to:
|
|
* - 'anonymous' when loaded without auth
|
|
* - 'authenticated' when loaded with auth
|
|
* - 'error' when load fails
|
|
*/
|
|
export async function refreshRemoteConfig(
|
|
options: RefreshRemoteConfigOptions = {}
|
|
): Promise<void> {
|
|
const { useAuth = true } = options
|
|
|
|
const controller = useAuth ? null : new AbortController()
|
|
const timeoutId = controller
|
|
? setTimeout(() => controller.abort(), FEATURES_FETCH_TIMEOUT_MS)
|
|
: null
|
|
|
|
try {
|
|
const response = await fetchRemoteConfig(useAuth, controller?.signal)
|
|
|
|
if (response.ok) {
|
|
const config = await response.json()
|
|
window.__CONFIG__ = config
|
|
remoteConfig.value = config
|
|
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
|
|
if (useAuth) {
|
|
cachedTeamWorkspacesEnabled.value = Boolean(
|
|
config.team_workspaces_enabled
|
|
)
|
|
cachedBillingControlEnabled.value = Boolean(
|
|
config.billing_control_enabled
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
console.warn('Failed to load remote config:', response.statusText)
|
|
if (response.status === 401 || response.status === 403) {
|
|
window.__CONFIG__ = {}
|
|
remoteConfig.value = {}
|
|
remoteConfigState.value = 'error'
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch remote config:', error)
|
|
window.__CONFIG__ = {}
|
|
remoteConfig.value = {}
|
|
remoteConfigState.value = 'error'
|
|
} finally {
|
|
if (timeoutId !== null) clearTimeout(timeoutId)
|
|
}
|
|
}
|