Skip to content

Commit fc4afc7

Browse files
authored
fix(billing): keep billing feature flags independent (#13820)
## Summary Restores `consolidated_billing_enabled` alongside `billing_control_enabled` without combining their responsibilities. ## Changes - **What**: Keeps both Remote Config keys, caches, enum values, and accessors. Personal-workspace billing routing continues to use only `consolidatedBillingEnabled`; `billingControlEnabled` remains the billing controls/banner gate. - **Tests**: Covers both flag contracts independently and runs Members and pricing E2E with the consolidated billing rollout. ## Root cause Adding `billing_control_enabled` replaced the existing consolidated flag plumbing. A follow-up then incorrectly treated the two flags as interchangeable billing-routing gates. This restores both flags while preserving their existing responsibilities. ## Screenshots (if applicable) Not applicable; no visual behavior changes.
1 parent 3f27f57 commit fc4afc7

13 files changed

Lines changed: 93 additions & 50 deletions

File tree

browser_tests/fixtures/data/cloudWorkspace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
1010
// workspaces flag from it (the `ff:` localStorage override is dev-only).
1111
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
1212
team_workspaces_enabled: true,
13-
billing_control_enabled: true
13+
consolidated_billing_enabled: true
1414
}
1515

1616
export const TEAM_WORKSPACE: WorkspaceWithRole = {

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-
// billing_control_enabled routes personal workspaces to the unified pricing
32-
// table asserted here; without it they fall back to the legacy table.
31+
// consolidated_billing_enabled routes personal workspaces to the unified
32+
// pricing table asserted here; without it they fall back to the legacy table.
3333
const BOOT_FEATURES = {
3434
team_workspaces_enabled: true,
35-
billing_control_enabled: true
35+
consolidated_billing_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/useBillingContext.test.ts

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

2020
const {
2121
mockTeamWorkspacesEnabled,
22-
mockBillingControlEnabled,
22+
mockConsolidatedBillingEnabled,
2323
mockIsPersonal,
2424
mockPlans,
2525
mockPurchaseCredits,
2626
mockUpdateActiveWorkspace,
2727
mockBillingStatus
2828
} = vi.hoisted(() => ({
2929
mockTeamWorkspacesEnabled: { value: false },
30-
mockBillingControlEnabled: { value: false },
30+
mockConsolidatedBillingEnabled: { value: false },
3131
mockIsPersonal: { value: true },
3232
mockPlans: { value: [] as Plan[] },
3333
mockPurchaseCredits: vi.fn(),
@@ -59,11 +59,13 @@ vi.mock('@/composables/useFeatureFlags', async () => {
5959
teamWorkspacesEnabledRef.value = value
6060
}
6161
})
62-
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
63-
Object.defineProperty(mockBillingControlEnabled, 'value', {
64-
get: () => billingControlEnabledRef.value,
62+
const consolidatedBillingEnabledRef = ref(
63+
mockConsolidatedBillingEnabled.value
64+
)
65+
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
66+
get: () => consolidatedBillingEnabledRef.value,
6567
set: (value: boolean) => {
66-
billingControlEnabledRef.value = value
68+
consolidatedBillingEnabledRef.value = value
6769
}
6870
})
6971
return {
@@ -72,8 +74,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
7274
get teamWorkspacesEnabled() {
7375
return mockTeamWorkspacesEnabled.value
7476
},
75-
get billingControlEnabled() {
76-
return mockBillingControlEnabled.value
77+
get consolidatedBillingEnabled() {
78+
return mockConsolidatedBillingEnabled.value
7779
}
7880
}
7981
})
@@ -163,7 +165,7 @@ describe('useBillingContext', () => {
163165
setActivePinia(createPinia())
164166
vi.clearAllMocks()
165167
mockTeamWorkspacesEnabled.value = false
166-
mockBillingControlEnabled.value = false
168+
mockConsolidatedBillingEnabled.value = false
167169
mockIsPersonal.value = true
168170
mockPlans.value = []
169171
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -175,27 +177,27 @@ describe('useBillingContext', () => {
175177
expect(type.value).toBe('legacy')
176178
})
177179

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

183185
const { type } = useBillingContext()
184186
expect(type.value).toBe('legacy')
185187
})
186188

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

192194
const { type } = useBillingContext()
193195
expect(type.value).toBe('workspace')
194196
})
195197

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

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

298300
// Authenticated remote config resolves the flag on for the same workspace
299-
mockBillingControlEnabled.value = true
301+
mockConsolidatedBillingEnabled.value = true
300302
mockTeamWorkspacesEnabled.value = true
301303

302304
await vi.waitFor(() => {
@@ -305,16 +307,16 @@ describe('useBillingContext', () => {
305307
})
306308
})
307309

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

313315
const { type } = useBillingContext()
314316
await nextTick()
315317
expect(type.value).toBe('legacy')
316318

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

319321
await vi.waitFor(() => {
320322
expect(type.value).toBe('workspace')
@@ -323,9 +325,9 @@ describe('useBillingContext', () => {
323325
})
324326

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

331333
const { initialize } = useBillingContext()
@@ -587,7 +589,7 @@ describe('useBillingContext', () => {
587589

588590
it('is true for a per-credit Team plan in a personal workspace before its credit stop is populated', async () => {
589591
mockTeamWorkspacesEnabled.value = true
590-
mockBillingControlEnabled.value = true
592+
mockConsolidatedBillingEnabled.value = true
591593
mockIsPersonal.value = true
592594
mockBillingStatus.value = {
593595
is_active: true,

src/composables/billing/useBillingContext.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ function isTeamPlanSlug(planSlug: string | null | undefined): boolean {
4545
*
4646
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
4747
* - Team workspaces enabled: workspace billing via /api/billing/* for team
48-
* workspaces, and for personal workspaces once billing control is enabled;
49-
* personal workspaces otherwise stay on legacy billing
48+
* workspaces, and for personal workspaces once consolidated billing is
49+
* enabled; personal workspaces otherwise stay on legacy billing
5050
*
5151
* The context automatically initializes when the workspace changes and provides
5252
* a unified interface for subscription status, balance, and billing actions.
@@ -223,9 +223,9 @@ function useBillingContextInternal(): BillingContext {
223223
error.value = null
224224
}
225225

226-
// type flips when the team-workspaces or billing-control flag resolves from
227-
// authenticated config, swapping the active backend. Reset then reinit on
228-
// every workspace-id or type change.
226+
// type flips when the team-workspaces or consolidated-billing flag resolves
227+
// from authenticated config, swapping the active backend. Reset then reinit
228+
// on every workspace-id or type change.
229229
watch(
230230
[() => store.activeWorkspace?.id, () => type.value],
231231
async ([newWorkspaceId]) => {

src/composables/billing/useBillingRouting.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
55
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
66
mockFlags: {
77
teamWorkspacesEnabled: false,
8-
billingControlEnabled: false
8+
consolidatedBillingEnabled: false
99
},
1010
mockActiveWorkspace: {
1111
value: null as { id: string; type: 'personal' | 'team' } | null
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
3030
describe('useBillingRouting', () => {
3131
beforeEach(() => {
3232
mockFlags.teamWorkspacesEnabled = false
33-
mockFlags.billingControlEnabled = false
33+
mockFlags.consolidatedBillingEnabled = false
3434
mockActiveWorkspace.value = personal
3535
})
3636

@@ -44,19 +44,19 @@ describe('useBillingRouting', () => {
4444
expect(shouldUseWorkspaceBilling.value).toBe(false)
4545
})
4646

47-
it('keeps personal on legacy when billing control is disabled', () => {
47+
it('keeps personal on legacy when consolidated billing is disabled', () => {
4848
mockFlags.teamWorkspacesEnabled = true
49-
mockFlags.billingControlEnabled = false
49+
mockFlags.consolidatedBillingEnabled = false
5050
mockActiveWorkspace.value = personal
5151

5252
const { type } = useBillingRouting()
5353

5454
expect(type.value).toBe('legacy')
5555
})
5656

57-
it('moves personal to workspace billing when billing control is enabled', () => {
57+
it('moves personal to workspace billing when consolidated billing is enabled', () => {
5858
mockFlags.teamWorkspacesEnabled = true
59-
mockFlags.billingControlEnabled = true
59+
mockFlags.consolidatedBillingEnabled = true
6060
mockActiveWorkspace.value = personal
6161

6262
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
6565
expect(shouldUseWorkspaceBilling.value).toBe(true)
6666
})
6767

68-
it('uses workspace billing for team workspaces regardless of billing control', () => {
68+
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
6969
mockFlags.teamWorkspacesEnabled = true
70-
mockFlags.billingControlEnabled = false
70+
mockFlags.consolidatedBillingEnabled = false
7171
mockActiveWorkspace.value = team
7272

7373
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
7676
expect(shouldUseWorkspaceBilling.value).toBe(true)
7777
})
7878

79-
it('uses workspace billing for team workspaces with billing control enabled', () => {
79+
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
8080
mockFlags.teamWorkspacesEnabled = true
81-
mockFlags.billingControlEnabled = true
81+
mockFlags.consolidatedBillingEnabled = true
8282
mockActiveWorkspace.value = team
8383

8484
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
8989

9090
it('defaults to legacy while the workspace has not loaded', () => {
9191
mockFlags.teamWorkspacesEnabled = true
92-
mockFlags.billingControlEnabled = true
92+
mockFlags.consolidatedBillingEnabled = true
9393
mockActiveWorkspace.value = null
9494

9595
const { type } = useBillingRouting()

src/composables/billing/useBillingRouting.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { BillingType } from './types'
88
/**
99
* Selects the billing backend for the active workspace: legacy user-scoped
1010
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
11-
* stay legacy until `billingControlEnabled`; team workspaces are always
11+
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
1212
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
1313
*/
1414
export function useBillingRouting() {
@@ -23,7 +23,7 @@ export function useBillingRouting() {
2323
const workspaceType = workspaceStore.activeWorkspace?.type
2424
if (!workspaceType) return 'legacy'
2525

26-
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
26+
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
2727
return 'legacy'
2828
}
2929

src/composables/useFeatureFlags.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import * as distributionTypes from '@/platform/distribution/types'
99
import {
1010
cachedBillingControlEnabled,
11+
cachedConsolidatedBillingEnabled,
1112
cachedTeamWorkspacesEnabled,
1213
remoteConfig,
1314
remoteConfigState
@@ -258,11 +259,20 @@ describe('useFeatureFlags', () => {
258259
expect(flags.billingControlEnabled).toBe(true)
259260
})
260261

262+
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
263+
vi.mocked(distributionTypes).isCloud = false
264+
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
265+
266+
const { flags } = useFeatureFlags()
267+
expect(flags.consolidatedBillingEnabled).toBe(true)
268+
})
269+
261270
it('billingControlEnabled is false off-cloud even without an override', () => {
262271
vi.mocked(distributionTypes).isCloud = false
263272

264273
const { flags } = useFeatureFlags()
265274
expect(flags.billingControlEnabled).toBe(false)
275+
expect(flags.consolidatedBillingEnabled).toBe(false)
266276
})
267277
})
268278

@@ -272,6 +282,7 @@ describe('useFeatureFlags', () => {
272282
remoteConfigState.value = 'unloaded'
273283
remoteConfig.value = {}
274284
cachedTeamWorkspacesEnabled.value = undefined
285+
cachedConsolidatedBillingEnabled.value = undefined
275286
cachedBillingControlEnabled.value = undefined
276287
localStorage.clear()
277288
})
@@ -281,36 +292,42 @@ describe('useFeatureFlags', () => {
281292
remoteConfigState.value = 'unloaded'
282293
remoteConfig.value = {}
283294
cachedTeamWorkspacesEnabled.value = undefined
295+
cachedConsolidatedBillingEnabled.value = undefined
284296
cachedBillingControlEnabled.value = undefined
285297
localStorage.clear()
286298
})
287299

288300
it('returns the cached session value during the auth window', () => {
289301
cachedTeamWorkspacesEnabled.value = false
302+
cachedConsolidatedBillingEnabled.value = true
290303
cachedBillingControlEnabled.value = true
291304

292305
const { flags } = useFeatureFlags()
293306
expect(flags.teamWorkspacesEnabled).toBe(false)
307+
expect(flags.consolidatedBillingEnabled).toBe(true)
294308
expect(flags.billingControlEnabled).toBe(true)
295309
})
296310

297311
it('defaults to false during the auth window when nothing is cached', () => {
298312
const { flags } = useFeatureFlags()
299313
expect(flags.teamWorkspacesEnabled).toBe(false)
314+
expect(flags.consolidatedBillingEnabled).toBe(false)
300315
expect(flags.billingControlEnabled).toBe(false)
301316
})
302317

303318
it('prefers authenticated remoteConfig over the server feature fallback', () => {
304319
remoteConfigState.value = 'authenticated'
305320
remoteConfig.value = {
306321
team_workspaces_enabled: true,
307-
billing_control_enabled: true
322+
consolidated_billing_enabled: true,
323+
billing_control_enabled: false
308324
}
309325
vi.mocked(api.getServerFeature).mockReturnValue(false)
310326

311327
const { flags } = useFeatureFlags()
312328
expect(flags.teamWorkspacesEnabled).toBe(true)
313-
expect(flags.billingControlEnabled).toBe(true)
329+
expect(flags.consolidatedBillingEnabled).toBe(true)
330+
expect(flags.billingControlEnabled).toBe(false)
314331
})
315332

316333
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
@@ -319,13 +336,16 @@ describe('useFeatureFlags', () => {
319336
vi.mocked(api.getServerFeature).mockImplementation(
320337
(path, defaultValue) => {
321338
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
339+
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
340+
return true
322341
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
323342
return defaultValue
324343
}
325344
)
326345

327346
const { flags } = useFeatureFlags()
328347
expect(flags.teamWorkspacesEnabled).toBe(true)
348+
expect(flags.consolidatedBillingEnabled).toBe(true)
329349
expect(flags.billingControlEnabled).toBe(true)
330350
})
331351
})

0 commit comments

Comments
 (0)