Skip to content

Commit cfaf89e

Browse files
authored
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](Comfy-Org/cloud#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](Comfy-Org/cloud#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" />
1 parent a154e6a commit cfaf89e

34 files changed

Lines changed: 1622 additions & 93 deletions

.storybook/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ const config: StorybookConfig = {
8888
replacement:
8989
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
9090
},
91+
{
92+
find: '@/platform/workspace/composables/useWorkspaceUI',
93+
replacement:
94+
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
95+
},
9196
{
9297
find: '@/platform/workspace/stores/teamWorkspaceStore',
9398
replacement:

browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
2828
// matches it against the members self-row.
2929
const SELF_EMAIL = 'e2e@test.comfy.org'
3030

31-
// consolidated_billing_enabled routes personal workspaces to the unified
32-
// pricing table asserted here; without it they fall back to the legacy table.
31+
// billing_control_enabled routes personal workspaces to the unified pricing
32+
// table asserted here; without it they fall back to the legacy table.
3333
const BOOT_FEATURES = {
3434
team_workspaces_enabled: true,
35-
consolidated_billing_enabled: true
35+
billing_control_enabled: true
3636
} satisfies RemoteConfig
3737
// Disable the experimental Asset API: with it on (cloud default) the unmocked
3838
// asset endpoints 403 and workflow restore throws uncaught, aborting the

src/composables/billing/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,12 @@ export interface BillingContext extends BillingState, BillingActions {
112112
* (legacy) per-member tier plan, which keeps the old team pricing table.
113113
*/
114114
isLegacyTeamPlan: ComputedRef<boolean>
115+
/**
116+
* True when the subscription is a team plan of either generation. Unlike
117+
* `isLegacyTeamPlan` this does not require an active subscription: the spend
118+
* gate folds billing_status into is_active, so a paused or payment-failed team
119+
* plan reports is_active=false and must still read as a team plan.
120+
*/
121+
isTeamPlan: ComputedRef<boolean>
115122
getMaxSeats: (tierKey: TierKey) => number
116123
}

src/composables/billing/useBillingContext.test.ts

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
1919

2020
const {
2121
mockTeamWorkspacesEnabled,
22-
mockConsolidatedBillingEnabled,
22+
mockBillingControlEnabled,
2323
mockIsPersonal,
2424
mockPlans,
2525
mockPurchaseCredits,
2626
mockUpdateActiveWorkspace,
2727
mockBillingStatus
2828
} = vi.hoisted(() => ({
2929
mockTeamWorkspacesEnabled: { value: false },
30-
mockConsolidatedBillingEnabled: { value: false },
30+
mockBillingControlEnabled: { value: false },
3131
mockIsPersonal: { value: true },
3232
mockPlans: { value: [] as Plan[] },
3333
mockPurchaseCredits: vi.fn(),
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
5959
teamWorkspacesEnabledRef.value = value
6060
}
6161
})
62-
const consolidatedBillingEnabledRef = ref(
63-
mockConsolidatedBillingEnabled.value
64-
)
65-
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
66-
get: () => consolidatedBillingEnabledRef.value,
62+
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
63+
Object.defineProperty(mockBillingControlEnabled, 'value', {
64+
get: () => billingControlEnabledRef.value,
6765
set: (value: boolean) => {
68-
consolidatedBillingEnabledRef.value = value
66+
billingControlEnabledRef.value = value
6967
}
7068
})
7169
return {
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
7472
get teamWorkspacesEnabled() {
7573
return mockTeamWorkspacesEnabled.value
7674
},
77-
get consolidatedBillingEnabled() {
78-
return mockConsolidatedBillingEnabled.value
75+
get billingControlEnabled() {
76+
return mockBillingControlEnabled.value
7977
}
8078
}
8179
})
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
165163
setActivePinia(createPinia())
166164
vi.clearAllMocks()
167165
mockTeamWorkspacesEnabled.value = false
168-
mockConsolidatedBillingEnabled.value = false
166+
mockBillingControlEnabled.value = false
169167
mockIsPersonal.value = true
170168
mockPlans.value = []
171169
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
177175
expect(type.value).toBe('legacy')
178176
})
179177

180-
it('keeps personal on legacy when consolidated billing is disabled', () => {
178+
it('keeps personal on legacy when billing control is disabled', () => {
181179
mockTeamWorkspacesEnabled.value = true
182-
mockConsolidatedBillingEnabled.value = false
180+
mockBillingControlEnabled.value = false
183181
mockIsPersonal.value = true
184182

185183
const { type } = useBillingContext()
186184
expect(type.value).toBe('legacy')
187185
})
188186

189-
it('selects workspace type for personal when consolidated billing is enabled', () => {
187+
it('selects workspace type for personal when billing control is enabled', () => {
190188
mockTeamWorkspacesEnabled.value = true
191-
mockConsolidatedBillingEnabled.value = true
189+
mockBillingControlEnabled.value = true
192190
mockIsPersonal.value = true
193191

194192
const { type } = useBillingContext()
195193
expect(type.value).toBe('workspace')
196194
})
197195

198-
it('selects workspace type for team regardless of consolidated billing', () => {
196+
it('selects workspace type for team regardless of billing control', () => {
199197
mockTeamWorkspacesEnabled.value = true
200-
mockConsolidatedBillingEnabled.value = false
198+
mockBillingControlEnabled.value = false
201199
mockIsPersonal.value = false
202200

203201
const { type } = useBillingContext()
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
298296
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
299297

300298
// Authenticated remote config resolves the flag on for the same workspace
301-
mockConsolidatedBillingEnabled.value = true
299+
mockBillingControlEnabled.value = true
302300
mockTeamWorkspacesEnabled.value = true
303301

304302
await vi.waitFor(() => {
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
307305
})
308306
})
309307

310-
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
308+
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
311309
mockTeamWorkspacesEnabled.value = true
312-
mockConsolidatedBillingEnabled.value = false
310+
mockBillingControlEnabled.value = false
313311
mockIsPersonal.value = true
314312

315313
const { type } = useBillingContext()
316314
await nextTick()
317315
expect(type.value).toBe('legacy')
318316

319-
mockConsolidatedBillingEnabled.value = true
317+
mockBillingControlEnabled.value = true
320318

321319
await vi.waitFor(() => {
322320
expect(type.value).toBe('workspace')
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
325323
})
326324

327325
describe('subscription mirror to workspace store', () => {
328-
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
326+
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
329327
mockTeamWorkspacesEnabled.value = true
330-
mockConsolidatedBillingEnabled.value = true
328+
mockBillingControlEnabled.value = true
331329
mockIsPersonal.value = true
332330

333331
const { initialize } = useBillingContext()
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
555553
expect(isLegacyTeamPlan.value).toBe(true)
556554
})
557555
})
556+
557+
describe('isTeamPlan', () => {
558+
it('is false for a personal workspace', () => {
559+
const { isTeamPlan } = useBillingContext()
560+
expect(isTeamPlan.value).toBe(false)
561+
})
562+
563+
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
564+
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
565+
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
566+
// isTeamPlan reads the credit stop and the slug, never the tier — which is
567+
// what keeps it working despite that divergence.
568+
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
569+
mockTeamWorkspacesEnabled.value = true
570+
mockIsPersonal.value = false
571+
mockBillingStatus.value = {
572+
is_active: true,
573+
has_funds: true,
574+
plan_slug: 'team_per_credit_monthly',
575+
team_credit_stop: {
576+
id: 'team_700',
577+
credits_monthly: 700,
578+
stop_usd: 332
579+
}
580+
}
581+
582+
const { initialize, isTeamPlan } = useBillingContext()
583+
await initialize()
584+
585+
expect(isTeamPlan.value).toBe(true)
586+
})
587+
588+
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
589+
mockTeamWorkspacesEnabled.value = true
590+
mockIsPersonal.value = false
591+
mockBillingStatus.value = {
592+
is_active: true,
593+
has_funds: true,
594+
subscription_tier: 'STANDARD',
595+
plan_slug: 'team-standard-annual'
596+
}
597+
598+
const { initialize, isTeamPlan } = useBillingContext()
599+
await initialize()
600+
601+
expect(isTeamPlan.value).toBe(true)
602+
})
603+
604+
// The banner states that need isTeamPlan most — paused and payment_failed —
605+
// are exactly the ones the backend reports with is_active=false, because the
606+
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
607+
// subscription would blank the banner precisely when it is needed.
608+
it('stays true for a paused team plan, which the backend reports inactive', async () => {
609+
mockTeamWorkspacesEnabled.value = true
610+
mockIsPersonal.value = false
611+
mockBillingStatus.value = {
612+
is_active: false,
613+
has_funds: true,
614+
billing_status: 'paused',
615+
plan_slug: 'team_per_credit_monthly',
616+
team_credit_stop: {
617+
id: 'team_700',
618+
credits_monthly: 700,
619+
stop_usd: 332
620+
}
621+
}
622+
623+
const { initialize, isTeamPlan } = useBillingContext()
624+
await initialize()
625+
626+
expect(isTeamPlan.value).toBe(true)
627+
})
628+
629+
it('stays true for a legacy team plan whose payment failed', async () => {
630+
mockTeamWorkspacesEnabled.value = true
631+
mockIsPersonal.value = false
632+
mockBillingStatus.value = {
633+
is_active: false,
634+
has_funds: true,
635+
billing_status: 'payment_failed',
636+
subscription_tier: 'STANDARD',
637+
plan_slug: 'team-standard-annual'
638+
}
639+
640+
const { initialize, isTeamPlan } = useBillingContext()
641+
await initialize()
642+
643+
expect(isTeamPlan.value).toBe(true)
644+
})
645+
646+
it('is false for a team workspace on a personal-tier plan', async () => {
647+
mockTeamWorkspacesEnabled.value = true
648+
mockIsPersonal.value = false
649+
mockBillingStatus.value = {
650+
is_active: true,
651+
has_funds: true,
652+
subscription_tier: 'PRO',
653+
plan_slug: 'pro-monthly'
654+
}
655+
656+
const { initialize, isTeamPlan } = useBillingContext()
657+
await initialize()
658+
659+
expect(isTeamPlan.value).toBe(false)
660+
})
661+
})
558662
})

src/composables/billing/useBillingContext.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
3535
*
3636
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
3737
* - Team workspaces enabled: workspace billing via /api/billing/* for team
38-
* workspaces, and for personal workspaces once consolidated billing is
39-
* enabled; personal workspaces otherwise stay on legacy billing
38+
* workspaces, and for personal workspaces once billing control is enabled;
39+
* personal workspaces otherwise stay on legacy billing
4040
*
4141
* The context automatically initializes when the workspace changes and provides
4242
* a unified interface for subscription status, balance, and billing actions.
@@ -141,6 +141,21 @@ function useBillingContextInternal(): BillingContext {
141141
false)
142142
)
143143

144+
// Plan identity, independent of subscription health: the per-credit Team plan
145+
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
146+
// isActiveSubscription on purpose — paused and payment_failed both force
147+
// is_active=false, which is exactly when callers still need to know this is a
148+
// team plan.
149+
const isTeamPlan = computed(
150+
() =>
151+
type.value === 'workspace' &&
152+
(currentTeamCreditStop.value !== null ||
153+
(currentPlanSlug.value
154+
?.toLowerCase()
155+
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
156+
false))
157+
)
158+
144159
const billingStatus = computed(() =>
145160
toValue(activeContext.value.billingStatus)
146161
)
@@ -191,9 +206,9 @@ function useBillingContextInternal(): BillingContext {
191206
error.value = null
192207
}
193208

194-
// type flips when the team-workspaces or consolidated-billing flag resolves
195-
// from authenticated config, swapping the active backend. Reset then reinit
196-
// on every workspace-id or type change.
209+
// type flips when the team-workspaces or billing-control flag resolves from
210+
// authenticated config, swapping the active backend. Reset then reinit on
211+
// every workspace-id or type change.
197212
watch(
198213
[() => store.activeWorkspace?.id, () => type.value],
199214
async ([newWorkspaceId]) => {
@@ -299,6 +314,7 @@ function useBillingContextInternal(): BillingContext {
299314
isActiveSubscription,
300315
isFreeTier,
301316
isLegacyTeamPlan,
317+
isTeamPlan,
302318
billingStatus,
303319
subscriptionStatus,
304320
tier,

0 commit comments

Comments
 (0)