Skip to content

Commit 4fb725c

Browse files
dante01yoonConnor Byrneampagent
authored
refactor(billing): use canonical status and preserve legacy rail (#14614)
## Summary Part 3/4 of the workspace/billing rollout retirement stack. Standardizes Cloud subscription discovery on canonical workspace billing status while preserving explicit `legacy_stripe` account operations. Predecessor: [#14613](#14613). Successor: [#14615](#14615). ## Root cause Cloud subscription state was read from both `/customers/cloud-subscription-status` and `/api/billing/status` with different response types and cancellation fields (`end_date` versus `cancel_at`). That allowed stale rail selection and could mix workspace status/balance with legacy top-up and management behavior. ## Changes - Read Cloud subscription status only from `/api/billing/status` using the canonical type and `cancel_at`. - Avoid ingress status requests for non-Cloud builds. - Preserve `legacy_stripe` balance, top-up, and management while unified checkout remains workspace-backed. - Keep `consolidated_billing_enabled` routing intact. - Dispatch the production pending-checkout event in the Playwright helper. - Strengthen the rail regression to prove canonical discovery, no old-status request, legacy balance/credits visibility, and rejection of stale workspace zero balance. ## AS IS Two status contracts can compete, and cancellation/rail state can be stale or translated inconsistently. ## TO BE Canonical status selects the rail; explicit legacy Stripe continues legacy account operations and unified checkout. No intended visual change, so no screenshot is required. ## Regression coverage - Canonical Cloud status and non-Cloud no-ingress behavior. - Canonical cancellation in watcher and legacy adapter. - Adapter switching and stale-rail failure. - Playwright regression serves `consolidated_billing_enabled: true` + `legacy_stripe`, observes canonical status and legacy balance, proves old status absent, and verifies credits/top-up UI without stale zero balance. ## Validation - 4 focused Vitest files: 79 tests passed. - `pnpm typecheck` and `pnpm typecheck:browser` passed via commit hooks. - targeted format/lint passed via commit hooks. - `git diff --check dante/retire-team-workspace-flag...HEAD` passed. - `pnpm knip --cache` passed via push hook. - Focused Playwright legacy-rail regression passed against local Cloud dev server (1 passed). The full two-test file had one transient initial boot timeout on its first run; its second pricing-flow test passed, and the focused failed case passed on rerun. ## Review focus Review only canonical status/cancellation semantics and the legacy-account/unified-checkout boundary. Consolidated flag retirement belongs to part 4. ## Stack/deployment order 1. [#14612](#14612) — recovery foundation 2. [#14613](#14613) — retire team workspace rollout flag 3. **This PR** — canonical status and legacy rail preservation 4. [#14615](#14615) — retire consolidated billing rollout flag Deploy the frontend stack before the backend removes `/api/features` compatibility keys. --------- Co-authored-by: Connor Byrne <c.byrne@comfy.org> Co-authored-by: Amp <amp@ampcode.com>
1 parent 5df05d5 commit 4fb725c

28 files changed

Lines changed: 505 additions & 480 deletions

browser_tests/fixtures/ComfyPage.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ import {
1414
EMPTY_BILLING_PLANS,
1515
LEGACY_PERSONAL_BILLING_STATUS
1616
} from '@e2e/fixtures/data/cloudWorkspace'
17-
import {
18-
UNSUBSCRIBED,
19-
ZERO_BALANCE
20-
} from '@e2e/fixtures/data/subscriptionFixtures'
17+
import { ZERO_BALANCE } from '@e2e/fixtures/data/subscriptionFixtures'
2118
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
2219
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
2320
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
@@ -587,9 +584,6 @@ export const comfyPageFixture = base.extend<{
587584
await context.route('**/api/billing/plans', (route) =>
588585
route.fulfill({ json: EMPTY_BILLING_PLANS })
589586
)
590-
await context.route('**/customers/cloud-subscription-status', (route) =>
591-
route.fulfill({ json: UNSUBSCRIBED })
592-
)
593587
await context.route('**/customers/balance', (route) =>
594588
route.fulfill({ json: ZERO_BALANCE })
595589
)

browser_tests/fixtures/data/cloudWorkspace.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import type {
1111
} from '@/platform/workspace/api/workspaceApi'
1212
import type { RemoteConfig } from '@/platform/remoteConfig/types'
1313

14-
export const CLOUD_REMOTE_CONFIG: RemoteConfig = {
15-
consolidated_billing_enabled: true
16-
}
14+
export const CLOUD_REMOTE_CONFIG: RemoteConfig = {}
1715

1816
export const LEGACY_PERSONAL_BILLING_STATUS = {
1917
billing_rail: 'legacy_stripe',

browser_tests/fixtures/data/subscriptionFixtures.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
import type { operations } from '@comfyorg/registry-types'
22

3-
export type SubscriptionStatusResponse =
4-
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
3+
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
54

65
export type BalanceResponse =
76
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
87

98
export function createSubscriptionStatus(
10-
overrides: Partial<SubscriptionStatusResponse> = {}
11-
): SubscriptionStatusResponse {
9+
overrides: Partial<BillingStatusResponse> = {}
10+
): BillingStatusResponse {
1211
return {
1312
is_active: false,
14-
subscription_id: null,
1513
subscription_tier: 'FREE',
16-
subscription_duration: null,
17-
has_fund: false,
18-
renewal_date: null,
19-
end_date: null,
14+
has_funds: false,
15+
billing_rail: 'legacy_stripe',
2016
...overrides
2117
}
2218
}
@@ -35,13 +31,10 @@ export function createBalance(
3531
}
3632
}
3733

38-
export const UNSUBSCRIBED: SubscriptionStatusResponse =
39-
createSubscriptionStatus({
40-
is_active: false,
41-
subscription_id: null,
42-
subscription_tier: 'FREE',
43-
end_date: null
44-
})
34+
export const UNSUBSCRIBED: BillingStatusResponse = createSubscriptionStatus({
35+
is_active: false,
36+
subscription_tier: 'FREE'
37+
})
4538

4639
export const ZERO_BALANCE: BalanceResponse = createBalance({
4740
amount_micros: 0,

browser_tests/fixtures/helpers/SubscriptionHelper.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import { expect } from '@playwright/test'
22
import type { Page, Route } from '@playwright/test'
33

4-
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
4+
import {
5+
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
6+
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
7+
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
8+
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
59
import {
610
createBalance,
711
createSubscriptionStatus,
812
UNSUBSCRIBED,
913
ZERO_BALANCE
1014
} from '@e2e/fixtures/data/subscriptionFixtures'
11-
import type {
12-
BalanceResponse,
13-
SubscriptionStatusResponse
14-
} from '@e2e/fixtures/data/subscriptionFixtures'
15+
import type { BalanceResponse } from '@e2e/fixtures/data/subscriptionFixtures'
1516
import { TestIds } from '@e2e/fixtures/selectors'
1617

1718
export interface SubscriptionConfig {
18-
status: SubscriptionStatusResponse
19+
status: BillingStatusResponse
1920
balance: BalanceResponse
2021
}
2122

@@ -31,7 +32,7 @@ export type SubscriptionOperator = (
3132
) => SubscriptionConfig
3233

3334
function withSubscriptionStatus(
34-
overrides: Partial<SubscriptionStatusResponse>
35+
overrides: Partial<BillingStatusResponse>
3536
): SubscriptionOperator {
3637
return (config) => ({
3738
...config,
@@ -40,35 +41,31 @@ function withSubscriptionStatus(
4041
}
4142

4243
export function withActiveSubscription(
43-
tier: NonNullable<SubscriptionStatusResponse['subscription_tier']> = 'CREATOR'
44+
tier: NonNullable<BillingStatusResponse['subscription_tier']> = 'CREATOR'
4445
): SubscriptionOperator {
4546
return withSubscriptionStatus({
4647
is_active: true,
4748
subscription_tier: tier,
48-
renewal_date: '2099-12-31T00:00:00.000Z',
49-
end_date: null
49+
renewal_date: '2099-12-31T00:00:00.000Z'
5050
})
5151
}
5252

5353
export function withFreeTier(): SubscriptionOperator {
5454
return withSubscriptionStatus({
5555
is_active: true,
56-
subscription_tier: 'FREE',
57-
end_date: null
56+
subscription_tier: 'FREE'
5857
})
5958
}
6059

6160
export function withUnsubscribed(): SubscriptionOperator {
6261
return withSubscriptionStatus({
6362
is_active: false,
64-
subscription_tier: 'FREE',
65-
end_date: null,
66-
renewal_date: null
63+
subscription_tier: 'FREE'
6764
})
6865
}
6966

7067
export class SubscriptionHelper {
71-
private statusResponse: SubscriptionStatusResponse
68+
private statusResponse: BillingStatusResponse
7269
private balanceResponse: BalanceResponse
7370
private routeHandlers: Array<{
7471
pattern: string
@@ -104,7 +101,7 @@ export class SubscriptionHelper {
104101
})
105102
await this.page.route(featuresPattern, featuresHandler)
106103

107-
const statusPattern = '**/customers/cloud-subscription-status'
104+
const statusPattern = '**/api/billing/status'
108105
const statusHandler = async (route: Route) => {
109106
await route.fulfill({ json: this.statusResponse })
110107
}
@@ -155,7 +152,7 @@ export class SubscriptionHelper {
155152
this.balanceResponse = { ...config.balance }
156153
}
157154

158-
setStatus(overrides: Partial<SubscriptionStatusResponse>): void {
155+
setStatus(overrides: Partial<BillingStatusResponse>): void {
159156
this.statusResponse = { ...this.statusResponse, ...overrides }
160157
}
161158

@@ -191,14 +188,14 @@ export class SubscriptionHelper {
191188
}
192189

193190
/**
194-
* Dispatch `visibilitychange` to simulate returning from Stripe checkout.
195-
* The app re-fetches subscription status when a pending checkout attempt
196-
* exists in localStorage (seeded via `seedPendingCheckout`).
191+
* Notify the app that a pending checkout attempt needs to be recovered.
197192
*/
198193
async triggerSubscriptionRefetch(): Promise<void> {
199-
await this.page.evaluate(() => {
200-
document.dispatchEvent(new Event('visibilitychange'))
201-
})
194+
const eventName = PENDING_SUBSCRIPTION_CHECKOUT_EVENT
195+
await this.page.evaluate(
196+
(name) => window.dispatchEvent(new Event(name)),
197+
eventName
198+
)
202199
}
203200

204201
/**

browser_tests/fixtures/utils/cloudAppSetup.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,6 @@ import { mockWorkspace } from '@e2e/fixtures/utils/workspaceMocks'
1414
export const APP_URL =
1515
process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
1616

17-
// consolidated_billing_enabled routes personal workspaces to the unified
18-
// billing surfaces the cloud specs assert; without it they fall back to the
19-
// legacy variants.
20-
const DEFAULT_FEATURES = {
21-
consolidated_billing_enabled: true
22-
} satisfies RemoteConfig
23-
2417
// Disable the experimental Asset API: with it on (cloud default) the unmocked
2518
// asset endpoints 403 and workflow restore throws uncaught, aborting the
2619
// GraphCanvas onMounted chain before the URL action loaders.
@@ -63,7 +56,7 @@ export async function setupCloudApp(
6356
{ workspace, members = [], features }: CloudAppSetupOptions
6457
) {
6558
await mockCloudBoot(page, {
66-
features: { ...DEFAULT_FEATURES, ...features },
59+
features: features ?? {},
6760
settings: DEFAULT_SETTINGS
6861
})
6962
await mockGraphBootExtras(page)

browser_tests/fixtures/utils/cloudBillingMocks.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ export async function mockBilling(page: Page) {
2929
await page.route('**/api/billing/plans', (r) =>
3030
r.fulfill(jsonRoute({ plans: [] }))
3131
)
32-
await page.route('**/customers/cloud-subscription-status', (r) =>
33-
r.fulfill(jsonRoute({ is_active: false }))
34-
)
3532
await page.route('**/customers/balance', (r) =>
3633
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
3734
)

browser_tests/tests/billingFacadeConsumers.spec.ts

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { expect } from '@playwright/test'
22
import type { Page } from '@playwright/test'
33

4-
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
54
import type { RemoteConfig } from '@/platform/remoteConfig/types'
5+
import {
6+
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
7+
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
8+
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
69
import type {
710
BillingBalanceResponse,
811
BillingStatusResponse
@@ -33,21 +36,6 @@ const jsonRoute = (body: unknown) => ({
3336
body: JSON.stringify(body)
3437
})
3538

36-
// The workspace `/api/billing/status` shape mirrors the legacy subscription
37-
// status; map the fields so a single test fixture drives both backends.
38-
const toWorkspaceStatus = (
39-
s: CloudSubscriptionStatusResponse
40-
): BillingStatusResponse => ({
41-
is_active: s.is_active ?? false,
42-
max_seats: 1,
43-
occupied_seats: 1,
44-
subscription_tier: s.subscription_tier ?? undefined,
45-
subscription_duration: s.subscription_duration ?? undefined,
46-
renewal_date: s.renewal_date ?? undefined,
47-
cancel_at: s.end_date ?? undefined,
48-
has_funds: s.has_fund ?? true
49-
})
50-
5139
const mockBalance: BillingBalanceResponse = {
5240
amount_micros: 6000, // -> 12,660 credits
5341
currency: 'usd',
@@ -62,7 +50,7 @@ const mockWorkspaceBalance: BillingBalanceResponse = {
6250

6351
async function mockCloudBoot(
6452
page: Page,
65-
subscriptionStatus: CloudSubscriptionStatusResponse,
53+
subscriptionStatus: BillingStatusResponse,
6654
remoteConfig: RemoteConfig = {},
6755
billingRail?: BillingStatusResponse['billing_rail']
6856
) {
@@ -117,22 +105,21 @@ async function mockCloudBoot(
117105
)
118106
)
119107

120-
// Legacy endpoints remain mocked for consumers that explicitly use them.
121-
await page.route('**/customers/cloud-subscription-status', (r) => {
122-
billingRequests.legacyStatus++
123-
return r.fulfill(jsonRoute(subscriptionStatus))
124-
})
125108
await page.route('**/customers/balance', (r) => {
126109
billingRequests.legacyBalance++
127110
return r.fulfill(jsonRoute(mockBalance))
128111
})
112+
await page.route('**/customers/cloud-subscription-status', (r) => {
113+
billingRequests.legacyStatus++
114+
return r.fulfill(jsonRoute(subscriptionStatus))
115+
})
129116

130117
// Cloud personal workspaces route through `/api/billing/*`.
131118
await page.route('**/api/billing/status', (r) => {
132119
billingRequests.workspaceStatus++
133120
return r.fulfill(
134121
jsonRoute({
135-
...toWorkspaceStatus(subscriptionStatus),
122+
...subscriptionStatus,
136123
billing_rail: billingRail
137124
})
138125
)
@@ -174,21 +161,42 @@ test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
174161
subscription_tier: 'PRO',
175162
subscription_duration: 'MONTHLY',
176163
renewal_date: '2099-02-20T10:00:00Z',
177-
end_date: null
164+
has_funds: true
178165
},
179-
{ consolidated_billing_enabled: true },
166+
{},
180167
'legacy_stripe'
181168
)
182169
await bootApp(page)
170+
await expect.poll(() => billingRequests.workspaceStatus).toBeGreaterThan(1)
183171

184172
await page.getByRole('button', { name: 'Current user' }).click()
185173
const popover = page.locator('.current-user-popover')
186174
await expect(popover).toBeVisible()
175+
await expect(popover.getByText('12,660')).toBeVisible()
176+
177+
await page.evaluate((storageKey) => {
178+
localStorage.setItem(
179+
storageKey,
180+
JSON.stringify({
181+
attempt_id: 'rail-selection-regression',
182+
started_at_ms: Date.now(),
183+
tier: 'pro',
184+
cycle: 'monthly',
185+
checkout_type: 'change'
186+
})
187+
)
188+
}, PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
189+
billingRequests.workspaceStatus = 0
190+
await page.evaluate(
191+
(eventName) => window.dispatchEvent(new Event(eventName)),
192+
PENDING_SUBSCRIPTION_CHECKOUT_EVENT
193+
)
187194

188195
await expect(popover.getByText('12,660')).toBeVisible()
196+
await expect(popover.getByText('0', { exact: true })).toHaveCount(0)
189197
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
190-
expect(billingRequests.workspaceStatus).toBeGreaterThan(0)
191-
expect(billingRequests.legacyStatus).toBeGreaterThan(0)
198+
await expect.poll(() => billingRequests.workspaceStatus).toBeGreaterThan(0)
199+
expect(billingRequests.legacyStatus).toBe(0)
192200
expect(billingRequests.legacyBalance).toBeGreaterThan(0)
193201
})
194202

@@ -211,12 +219,12 @@ test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
211219
subscription_duration: 'MONTHLY',
212220
// 10:00Z keeps the en-US calendar date stable across CI timezones.
213221
renewal_date: '2099-02-20T10:00:00Z',
214-
end_date: null
222+
has_funds: false
215223
},
216224
{
217-
subscription_required: true,
218-
consolidated_billing_enabled: true
219-
}
225+
subscription_required: true
226+
},
227+
'stripe'
220228
)
221229
await bootApp(page)
222230

0 commit comments

Comments
 (0)