Skip to content

Commit 147988b

Browse files
Connor Byrnedante01yoon
andcommitted
refactor(billing): use canonical status and preserve legacy rail
Rebased onto main after #14613 landed as a squash commit. One conflict, in performFetchSubscriptionStatus. main's #12826 converged error parsing on parseErrorResponse(response); this PR removes that raw fetch path in favour of workspaceApi.getBillingStatus(). Resolved in favour of this PR because workspaceApi's handleAxiosError already runs errorResponseFromBody, the same canonical parsing #12826 introduced, so the intent is preserved at the axios layer. parseErrorResponse is still used elsewhere in the file, so the import stays. Verified after rebase: 17 files / +439 / -336, identical to the reviewed diff; pnpm typecheck clean; 45 test files and 762 tests pass across platform/cloud/subscription, composables/billing and workspace/composables. Co-Authored-By: dante01yoon <bunggl@naver.com>
1 parent a614d21 commit 147988b

17 files changed

Lines changed: 439 additions & 336 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/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/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: 37 additions & 28 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
},
179166
{ consolidated_billing_enabled: true },
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,13 @@ 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
{
217225
subscription_required: true,
218226
consolidated_billing_enabled: true
219-
}
227+
},
228+
'stripe'
220229
)
221230
await bootApp(page)
222231

0 commit comments

Comments
 (0)