-
Notifications
You must be signed in to change notification settings - Fork 673
feat(billing): flag legacy billing migration #15046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b269311
bb3e9bb
91b27ec
4092463
2dbf2bc
013bba4
758925f
e179c4f
0a66186
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,7 +57,8 @@ async function mockCloudBoot( | |
| const billingRequests = { | ||
| legacyStatus: 0, | ||
| legacyBalance: 0, | ||
| workspaceStatus: 0 | ||
| workspaceStatus: 0, | ||
| workspaceBalance: 0 | ||
| } | ||
|
|
||
| await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig))) | ||
|
|
@@ -125,6 +126,7 @@ async function mockCloudBoot( | |
| ) | ||
| }) | ||
| await page.route('**/api/billing/balance', (r) => { | ||
| billingRequests.workspaceBalance++ | ||
| return r.fulfill(jsonRoute(mockWorkspaceBalance)) | ||
| }) | ||
| await page.route('**/api/billing/plans', (r) => | ||
|
|
@@ -200,6 +202,31 @@ test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => { | |
| expect(billingRequests.legacyBalance).toBeGreaterThan(0) | ||
| }) | ||
|
|
||
| test('rollout flag migrates a legacy Stripe workspace to workspace billing', async ({ | ||
| page | ||
| }) => { | ||
| test.setTimeout(60_000) | ||
|
|
||
| const billingRequests = await mockCloudBoot( | ||
| page, | ||
| { | ||
| is_active: true, | ||
| subscription_tier: 'PRO', | ||
| subscription_duration: 'MONTHLY', | ||
| has_funds: true | ||
| }, | ||
| { legacy_billing_migration_enabled: true }, | ||
| 'legacy_stripe' | ||
| ) | ||
| await bootApp(page) | ||
|
|
||
| await expect | ||
| .poll(() => billingRequests.workspaceBalance, { timeout: 30_000 }) | ||
| .toBeGreaterThan(0) | ||
| expect(billingRequests.legacyStatus).toBe(0) | ||
| expect(billingRequests.legacyBalance).toBe(0) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking):
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 91b27ec by giving the workspace-balance poll an explicit 30-second timeout. |
||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): the new flag-on test checks
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 91b27ec. The migrated browser test now asserts legacyStatus === 0 in addition to workspace balance activity and no legacy balance request. |
||
|
|
||
| test('subscribe-to-run routes an inactive FREE user to the pricing table', async ({ | ||
| page | ||
| }) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,10 @@ import type { | |
| BillingStatusResponse, | ||
| Plan | ||
| } from '@/platform/workspace/api/workspaceApi' | ||
| import { | ||
| remoteConfig, | ||
| remoteConfigState | ||
| } from '@/platform/remoteConfig/remoteConfig' | ||
|
|
||
| import { useBillingContext } from './useBillingContext' | ||
|
|
||
|
|
@@ -166,12 +170,15 @@ vi.mock('@/platform/workspace/api/workspaceApi', () => ({ | |
| currency: 'usd' | ||
| })), | ||
| subscribe: vi.fn(async () => ({ status: 'subscribed' })), | ||
| previewSubscribe: vi.fn(async () => ({ allowed: true })) | ||
| previewSubscribe: vi.fn(async () => ({ allowed: true })), | ||
| createTopup: vi.fn(async () => undefined) | ||
| } | ||
| })) | ||
|
|
||
| describe('useBillingContext', () => { | ||
| beforeEach(() => { | ||
| remoteConfig.value = {} | ||
| remoteConfigState.value = 'unloaded' | ||
| mockIsPersonal.value = true | ||
| mockBillingRail.value = undefined | ||
| mockSetWorkspaceBillingRail.mockImplementation( | ||
|
|
@@ -340,6 +347,22 @@ describe('useBillingContext', () => { | |
| expect(mockPurchaseCredits).toHaveBeenCalledWith(5) | ||
| }) | ||
|
|
||
| it('routes migrated legacy Stripe topups through workspace billing', async () => { | ||
| remoteConfig.value = { legacy_billing_migration_enabled: true } | ||
| remoteConfigState.value = 'authenticated' | ||
| mockBillingRail.value = 'legacy_stripe' | ||
|
|
||
| const context = useBillingContext() | ||
| await nextTick() | ||
| vi.clearAllMocks() | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking):
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 91b27ec by awaiting nextTick() after constructing the billing context and before clearing initialization calls. |
||
| expect(context.type.value).toBe('workspace') | ||
| await context.topup(500) | ||
|
|
||
| expect(workspaceApi.createTopup).toHaveBeenCalledWith(500) | ||
| expect(mockPurchaseCredits).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('switches billing adapters before refreshing a migrated balance', async () => { | ||
| mockBillingRail.value = 'legacy_stripe' | ||
| mockBillingStatus.value = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,16 +4,31 @@ import type { BillingRail } from '@/platform/workspace/api/workspaceApi' | |
|
|
||
| import { useBillingRouting } from './useBillingRouting' | ||
|
|
||
| const { mockIsCloud, mockActiveWorkspace, mockActiveWorkspaceBillingRail } = | ||
| vi.hoisted(() => ({ | ||
| mockIsCloud: { value: true }, | ||
| mockActiveWorkspace: { | ||
| value: null as { id: string; type: 'personal' | 'team' } | null | ||
| }, | ||
| mockActiveWorkspaceBillingRail: { | ||
| value: null as BillingRail | null | ||
| const { | ||
| mockIsCloud, | ||
| mockLegacyBillingMigrationEnabled, | ||
| mockActiveWorkspace, | ||
| mockActiveWorkspaceBillingRail | ||
| } = vi.hoisted(() => ({ | ||
| mockIsCloud: { value: true }, | ||
| mockLegacyBillingMigrationEnabled: { value: false }, | ||
| mockActiveWorkspace: { | ||
| value: null as { id: string; type: 'personal' | 'team' } | null | ||
| }, | ||
| mockActiveWorkspaceBillingRail: { | ||
| value: null as BillingRail | null | ||
| } | ||
| })) | ||
|
|
||
| vi.mock('@/composables/useFeatureFlags', () => ({ | ||
| useFeatureFlags: () => ({ | ||
| flags: { | ||
| get legacyBillingMigrationEnabled() { | ||
| return mockLegacyBillingMigrationEnabled.value | ||
| } | ||
| } | ||
| })) | ||
| }) | ||
| })) | ||
|
|
||
| vi.mock('@/platform/distribution/types', () => ({ | ||
| get isCloud() { | ||
|
|
@@ -38,6 +53,7 @@ const team = { id: 'w-team', type: 'team' as const } | |
| describe('useBillingRouting', () => { | ||
| beforeEach(() => { | ||
| mockIsCloud.value = true | ||
| mockLegacyBillingMigrationEnabled.value = false | ||
| mockActiveWorkspace.value = personal | ||
| mockActiveWorkspaceBillingRail.value = null | ||
| }) | ||
|
|
@@ -71,6 +87,16 @@ describe('useBillingRouting', () => { | |
| expect(shouldUseUnifiedPricing.value).toBe(true) | ||
| }) | ||
|
|
||
| it('migrates legacy Stripe personal workspaces behind the rollout flag', () => { | ||
| mockLegacyBillingMigrationEnabled.value = true | ||
| mockActiveWorkspaceBillingRail.value = 'legacy_stripe' | ||
|
|
||
| const { type, shouldUseWorkspaceBilling } = useBillingRouting() | ||
|
|
||
| expect(type.value).toBe('workspace') | ||
| expect(shouldUseWorkspaceBilling.value).toBe(true) | ||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): no test covers
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I left this unchanged after verifying the branch. The existing team + legacy_stripe test uses flag=false and expects workspace billing; removing the workspaceType === personal guard would make that condition select legacy billing, so the current test already fails on the stated regression. A flag=true variant would still return workspace with or without the personal guard and would not add regression sensitivity. |
||
|
|
||
| it('uses workspace billing for migrated Stripe personal workspaces', () => { | ||
| mockActiveWorkspace.value = personal | ||
| mockActiveWorkspaceBillingRail.value = 'stripe' | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { computed } from 'vue' | ||
|
|
||
| import { useFeatureFlags } from '@/composables/useFeatureFlags' | ||
| import { isCloud } from '@/platform/distribution/types' | ||
| import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' | ||
|
|
||
|
|
@@ -9,10 +10,11 @@ import type { BillingType } from './types' | |
| * Selects the billing backend for the active workspace: legacy user-scoped | ||
| * (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces | ||
| * use workspace billing unless an explicit legacy Stripe rail selects legacy | ||
| * account operations. An unloaded workspace remains legacy during bootstrap, | ||
| * and OSS always uses legacy billing. | ||
| * account operations and its migration flag is off. An unloaded workspace | ||
| * remains legacy during bootstrap, and OSS always uses legacy billing. | ||
| */ | ||
| export function useBillingRouting() { | ||
| const { flags } = useFeatureFlags() | ||
| const workspaceStore = useTeamWorkspaceStore() | ||
|
|
||
| const shouldUseUnifiedPricing = computed(() => { | ||
|
|
@@ -29,7 +31,8 @@ export function useBillingRouting() { | |
|
|
||
| if ( | ||
| workspaceType === 'personal' && | ||
| workspaceStore.activeWorkspaceBillingRail === 'legacy_stripe' | ||
| workspaceStore.activeWorkspaceBillingRail === 'legacy_stripe' && | ||
| !flags.legacyBillingMigrationEnabled | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (observability): this adds a second dimension to rail selection — a The design doc's own Risks and observability section asks for exactly this: "Instrument The practical consequence for the rollout: with no dimension separating migrated from unmigrated users, a cohort enable cannot be evaluated. If migrated top-ups start failing, the failures land in the same undifferentiated bucket as everyone else's, and the flag's effect is invisible until someone correlates it by hand. That is the same shape as the rail-asymmetry blindness we already have on the billing dashboards, and this PR is the moment it gets cheap to fix — one dimension on the billing actions that already emit, tagged with the resolved Not asking for it in this PR if you'd rather keep the diff to the routing decision. But I'd want it landed before the first cohort, not after — otherwise the rollout has no success signal, only a failure signal that arrives via support.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. I am keeping observability out of this routing-only diff as suggested, but treating it as a rollout gate: the flag will remain at 0% and no cohort will be enabled until billing events expose both the backend |
||
| ) { | ||
| return 'legacy' | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import type { Ref } from 'vue' | |
| import { isCloud, isNightly } from '@/platform/distribution/types' | ||
| import { | ||
| cachedBillingControlEnabled, | ||
| cachedLegacyBillingMigrationEnabled, | ||
| cachedV1PaymentRecovery, | ||
| isAuthenticatedConfigLoaded, | ||
| remoteConfig | ||
|
|
@@ -34,6 +35,7 @@ export enum ServerFeatureFlag { | |
| SHOW_SIGNIN_BUTTON = 'show_signin_button', | ||
| UNIFIED_CLOUD_AUTH = 'unified_cloud_auth', | ||
| BILLING_CONTROL_ENABLED = 'billing_control_enabled', | ||
| LEGACY_BILLING_MIGRATION_ENABLED = 'legacy_billing_migration_enabled', | ||
| V1_PAYMENT_RECOVERY = 'v1_payment_recovery', | ||
| FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled', | ||
| CHURNKEY_APP_ID = 'churnkey_app_id', | ||
|
|
@@ -204,6 +206,13 @@ export function useFeatureFlags() { | |
| cachedBillingControlEnabled | ||
| ) | ||
| }, | ||
| get legacyBillingMigrationEnabled() { | ||
| return resolveAuthGatedFlag( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (rollout blocker, not a merge blocker): moving to
That gives two reachable paths once a cohort is enabled:
Why it matters specifically here: the design doc records that Today the blast radius is genuinely zero — at 0% rollout the cached value is Worth noting Cheapest option that keeps your fix: clear the three cached flags on sign-out/user change. Alternative: scope the key by user id. Either is fine; I'd just like the decision recorded before a cohort is enabled rather than discovered from a chargeback.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 758925f. The migration eligibility is no longer persisted in localStorage: it is now an in-memory value populated only by an authenticated |
||
| ServerFeatureFlag.LEGACY_BILLING_MIGRATION_ENABLED, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Suggested fix: add
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 91b27ec. The migration flag now uses resolveAuthGatedFlag with a dedicated cached value. Authenticated refreshes persist the cache, anonymous refreshes leave it untouched, and tests cover cached bootstrap, authenticated remote config, server fallback, and the false default. |
||
| remoteConfig.value.legacy_billing_migration_enabled, | ||
| cachedLegacyBillingMigrationEnabled | ||
| ) | ||
| }, | ||
| get v1PaymentRecovery() { | ||
| return resolveAuthGatedFlag( | ||
| ServerFeatureFlag.V1_PAYMENT_RECOVERY, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (non-blocking): the flag-off baseline asserts
legacyBalance > 0but never assertsworkspaceBalance === 0. Without that, the flag-on test (workspaceBalance > 0) and this test do not form a true mutual-exclusivity pair -- cross-contamination of/api/billing/balancein the flag-off path goes undetected. Consider addingexpect(billingRequests.workspaceBalance).toBe(0)here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested this assertion in the cloud Playwright spec, and it fails because bootstrap legitimately makes one workspace-balance request before /api/billing/status returns legacy_stripe and the facade switches to legacy billing. I therefore did not retain workspaceBalance === 0; the stable post-selection behavior remains covered by legacyBalance > 0.