Skip to content

Commit c9c3ca8

Browse files
authored
[backport cloud/1.47] fix: derive Plan & Credits plan identity from the plan, not the workspace type (#13805)
## Summary Backports #13785 to `cloud/1.47` after resolving the billing-context cherry-pick conflicts. ## Changes - **What**: Preserves subscription-derived plan identity and adapts the required `isTeamPlan` contract to the release branch. ## Review Focus Review the manual conflict resolution in the billing context, its type contract, Storybook mock, and behavioral tests. Validation: 63 focused tests, typecheck, lint, format, and knip checks passed.
1 parent aa0affb commit c9c3ca8

7 files changed

Lines changed: 187 additions & 13 deletions

File tree

src/composables/billing/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ 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+
isTeamPlan: ComputedRef<boolean>
115116
getMaxSeats: (tierKey: TierKey) => number
116117
canRunWorkflows: ComputedRef<boolean>
117118
}

src/composables/billing/useBillingContext.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,4 +555,119 @@ describe('useBillingContext', () => {
555555
expect(isLegacyTeamPlan.value).toBe(true)
556556
})
557557
})
558+
559+
describe('isTeamPlan', () => {
560+
it('is false for a personal workspace', () => {
561+
const { isTeamPlan } = useBillingContext()
562+
expect(isTeamPlan.value).toBe(false)
563+
})
564+
565+
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
566+
mockTeamWorkspacesEnabled.value = true
567+
mockIsPersonal.value = false
568+
mockBillingStatus.value = {
569+
is_active: true,
570+
has_funds: true,
571+
plan_slug: 'team_per_credit_monthly',
572+
team_credit_stop: {
573+
id: 'team_700',
574+
credits_monthly: 700,
575+
stop_usd: 332
576+
}
577+
}
578+
579+
const { initialize, isTeamPlan } = useBillingContext()
580+
await initialize()
581+
582+
expect(isTeamPlan.value).toBe(true)
583+
})
584+
585+
it('is true for a per-credit Team plan in a personal workspace before its credit stop is populated', async () => {
586+
mockTeamWorkspacesEnabled.value = true
587+
mockConsolidatedBillingEnabled.value = true
588+
mockIsPersonal.value = true
589+
mockBillingStatus.value = {
590+
is_active: true,
591+
has_funds: true,
592+
subscription_status: 'active',
593+
subscription_duration: 'ANNUAL',
594+
plan_slug: 'team_per_credit_annual'
595+
}
596+
597+
const { initialize, isTeamPlan } = useBillingContext()
598+
await initialize()
599+
600+
expect(isTeamPlan.value).toBe(true)
601+
})
602+
603+
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
604+
mockTeamWorkspacesEnabled.value = true
605+
mockIsPersonal.value = false
606+
mockBillingStatus.value = {
607+
is_active: true,
608+
has_funds: true,
609+
subscription_tier: 'STANDARD',
610+
plan_slug: 'team-standard-annual'
611+
}
612+
613+
const { initialize, isTeamPlan } = useBillingContext()
614+
await initialize()
615+
616+
expect(isTeamPlan.value).toBe(true)
617+
})
618+
619+
it('stays true for an inactive per-credit Team plan', async () => {
620+
mockTeamWorkspacesEnabled.value = true
621+
mockIsPersonal.value = false
622+
mockBillingStatus.value = {
623+
is_active: false,
624+
has_funds: true,
625+
billing_status: 'inactive',
626+
plan_slug: 'team_per_credit_monthly',
627+
team_credit_stop: {
628+
id: 'team_700',
629+
credits_monthly: 700,
630+
stop_usd: 332
631+
}
632+
}
633+
634+
const { initialize, isTeamPlan } = useBillingContext()
635+
await initialize()
636+
637+
expect(isTeamPlan.value).toBe(true)
638+
})
639+
640+
it('stays true for a legacy team plan whose payment failed', async () => {
641+
mockTeamWorkspacesEnabled.value = true
642+
mockIsPersonal.value = false
643+
mockBillingStatus.value = {
644+
is_active: false,
645+
has_funds: true,
646+
billing_status: 'payment_failed',
647+
subscription_tier: 'STANDARD',
648+
plan_slug: 'team-standard-annual'
649+
}
650+
651+
const { initialize, isTeamPlan } = useBillingContext()
652+
await initialize()
653+
654+
expect(isTeamPlan.value).toBe(true)
655+
})
656+
657+
it('is false for a team workspace on a personal-tier plan', async () => {
658+
mockTeamWorkspacesEnabled.value = true
659+
mockIsPersonal.value = false
660+
mockBillingStatus.value = {
661+
is_active: true,
662+
has_funds: true,
663+
subscription_tier: 'PRO',
664+
plan_slug: 'pro-monthly'
665+
}
666+
667+
const { initialize, isTeamPlan } = useBillingContext()
668+
await initialize()
669+
670+
expect(isTeamPlan.value).toBe(false)
671+
})
672+
})
558673
})

src/composables/billing/useBillingContext.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
3030
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
3131
// new sub is never misrouted even before its credit stop is populated.
3232
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
33+
const PER_CREDIT_TEAM_PLAN_SLUG_PREFIX = 'team_per_credit_'
34+
35+
function isTeamPlanSlug(planSlug: string | null | undefined): boolean {
36+
const normalizedSlug = planSlug?.toLowerCase()
37+
return (
38+
normalizedSlug?.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) === true ||
39+
normalizedSlug?.startsWith(PER_CREDIT_TEAM_PLAN_SLUG_PREFIX) === true
40+
)
41+
}
3342

3443
/**
3544
* Unified billing context that selects the billing implementation by build/flag.
@@ -152,6 +161,13 @@ function useBillingContextInternal(): BillingContext {
152161
false)
153162
)
154163

164+
const isTeamPlan = computed(
165+
() =>
166+
type.value === 'workspace' &&
167+
(currentTeamCreditStop.value !== null ||
168+
isTeamPlanSlug(currentPlanSlug.value))
169+
)
170+
155171
const billingStatus = computed(() =>
156172
toValue(activeContext.value.billingStatus)
157173
)
@@ -311,6 +327,7 @@ function useBillingContextInternal(): BillingContext {
311327
canRunWorkflows,
312328
isFreeTier,
313329
isLegacyTeamPlan,
330+
isTeamPlan,
314331
billingStatus,
315332
subscriptionStatus,
316333
tier,

src/platform/workspace/components/SubscriptionPanelContentWorkspace.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,20 +105,27 @@ const personalUiConfig: MenuUiConfig = {
105105
}
106106
const mockUiConfig = ref<MenuUiConfig>(ownerUiConfig)
107107

108+
const mockSubscriptionTier = ref<SubscriptionInfo['tier']>('PRO')
109+
const mockPlanSlug = ref('team-monthly')
110+
const mockHasTeamPlan = ref(true)
111+
108112
const mockSubscription = computed<SubscriptionInfo | null>(() =>
109113
mockHasSubscription.value
110114
? {
111115
isActive: true,
112-
tier: 'PRO',
116+
tier: mockSubscriptionTier.value,
113117
duration: mockSubscriptionDuration.value,
114-
planSlug: 'team-monthly',
118+
planSlug: mockPlanSlug.value,
115119
renewalDate: RENEWAL_DATE_ISO,
116120
endDate: END_DATE_ISO,
117121
isCancelled: mockSubscriptionStatus.value === 'canceled',
118122
hasFunds: true
119123
}
120124
: null
121125
)
126+
const mockIsTeamPlan = computed(
127+
() => mockHasSubscription.value && mockHasTeamPlan.value
128+
)
122129

123130
const mockInitialize = vi.fn()
124131
const mockIsLoading = ref(false)
@@ -128,6 +135,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
128135
useBillingContext: () => ({
129136
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
130137
isFreeTier: computed(() => false),
138+
isTeamPlan: mockIsTeamPlan,
131139
subscription: mockSubscription,
132140
teamCreditStops: mockTeamCreditStops,
133141
currentTeamCreditStop: mockCurrentTeamCreditStop,
@@ -284,6 +292,9 @@ describe('SubscriptionPanelContentWorkspace', () => {
284292
]
285293
mockUserEmail.value = 'me@example.com'
286294
mockUiConfig.value = ownerUiConfig
295+
mockSubscriptionTier.value = 'PRO'
296+
mockPlanSlug.value = 'team-monthly'
297+
mockHasTeamPlan.value = true
287298
mockSubscriptionDuration.value = 'MONTHLY'
288299
mockTeamCreditStops.value = teamCreditStops
289300
mockCurrentTeamCreditStop.value = {
@@ -556,6 +567,40 @@ describe('SubscriptionPanelContentWorkspace', () => {
556567
).not.toBeInTheDocument()
557568
})
558569

570+
it('shows the personal plan identity when a team workspace holds a personal subscription', () => {
571+
mockSubscriptionTier.value = 'STANDARD'
572+
mockPlanSlug.value = 'standard-annual'
573+
mockHasTeamPlan.value = false
574+
mockSubscriptionDuration.value = 'ANNUAL'
575+
mockCurrentTeamCreditStop.value = null
576+
renderComponent()
577+
578+
expect(screen.getByText('Standard Yearly')).toBeInTheDocument()
579+
expect(screen.queryByText('Team')).not.toBeInTheDocument()
580+
expect(screen.getByText('$16')).toBeInTheDocument()
581+
expect(screen.getByText('USD / mo')).toBeInTheDocument()
582+
expect(screen.queryByText('USD / mo / member')).not.toBeInTheDocument()
583+
expect(screen.getByText('RTX 6000 Pro (96GB VRAM)')).toBeInTheDocument()
584+
expect(screen.queryByText('Invite members')).not.toBeInTheDocument()
585+
})
586+
587+
it('shows the Team plan identity when a personal workspace holds a Team subscription', () => {
588+
mockIsInPersonalWorkspace.value = true
589+
renderComponent()
590+
591+
expect(screen.getByRole('heading', { name: 'Team' })).toBeInTheDocument()
592+
expect(
593+
screen.queryByRole('heading', { name: 'Pro' })
594+
).not.toBeInTheDocument()
595+
expect(screen.getByText('$665')).toBeInTheDocument()
596+
expect(screen.getByText('USD / mo')).toBeInTheDocument()
597+
expect(screen.queryByText('USD / mo / member')).not.toBeInTheDocument()
598+
expect(screen.getByText('Invite members')).toBeInTheDocument()
599+
expect(
600+
screen.queryByText('RTX 6000 Pro (96GB VRAM)')
601+
).not.toBeInTheDocument()
602+
})
603+
559604
it('lists the four team perks under the Pro-inclusive heading', () => {
560605
renderComponent()
561606

src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ const isSettingUp = computed(() => billingOperationStore.isSettingUp)
344344
const {
345345
isActiveSubscription,
346346
isFreeTier: isFreeTierPlan,
347+
isTeamPlan,
347348
subscription,
348349
isLoading,
349350
error,
@@ -376,7 +377,7 @@ const isPersonalFree = computed(
376377
)
377378
378379
const isTeamActive = computed(
379-
() => !isInPersonalWorkspace.value && isActiveSubscription.value
380+
() => isTeamPlan.value && isActiveSubscription.value
380381
)
381382
382383
const isMemberView = computed(
@@ -440,9 +441,7 @@ const subscriptionTierName = computed(() => {
440441
})
441442
442443
const planDisplayName = computed(() =>
443-
isInPersonalWorkspace.value
444-
? subscriptionTierName.value
445-
: t('subscription.teamPlanName')
444+
isTeamPlan.value ? t('subscription.teamPlanName') : subscriptionTierName.value
446445
)
447446
448447
const tierKey = computed(() => {

src/platform/workspace/composables/useWorkspacePlanPricing.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { storeToRefs } from 'pinia'
21
import { computed, watch } from 'vue'
32
import { useI18n } from 'vue-i18n'
43

@@ -8,7 +7,6 @@ import {
87
getTierPrice
98
} from '@/platform/cloud/subscription/constants/tierPricing'
109
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
11-
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
1210
import { formatUsdCents } from '@/utils/numberUtil'
1311

1412
/**
@@ -24,8 +22,7 @@ import { formatUsdCents } from '@/utils/numberUtil'
2422
*/
2523
export function useWorkspacePlanPricing() {
2624
const { t, locale } = useI18n()
27-
const { isInPersonalWorkspace } = storeToRefs(useTeamWorkspaceStore())
28-
const { subscription, teamCreditStops, currentTeamCreditStop } =
25+
const { subscription, teamCreditStops, currentTeamCreditStop, isTeamPlan } =
2926
useBillingContext()
3027

3128
const isYearly = computed(() => subscription.value?.duration === 'ANNUAL')
@@ -39,7 +36,7 @@ export function useWorkspacePlanPricing() {
3936
})
4037

4138
const subscribedStop = computed(() => {
42-
if (isInPersonalWorkspace.value) return null
39+
if (!isTeamPlan.value) return null
4340
const id = currentTeamCreditStop.value?.id
4441
const stops = teamCreditStops.value?.stops
4542
if (!id || !stops) return null
@@ -48,7 +45,6 @@ export function useWorkspacePlanPricing() {
4845

4946
const hasStaleCreditStop = computed(
5047
() =>
51-
!isInPersonalWorkspace.value &&
5248
!!currentTeamCreditStop.value &&
5349
!!teamCreditStops.value &&
5450
subscribedStop.value === null
@@ -68,7 +64,7 @@ export function useWorkspacePlanPricing() {
6864
})
6965

7066
const priceUnitLabel = computed(() =>
71-
teamMonthlyCostCents.value !== null || isInPersonalWorkspace.value
67+
teamMonthlyCostCents.value !== null || !isTeamPlan.value
7268
? t('subscription.usdPerMonth')
7369
: t('subscription.usdPerMonthPerMember')
7470
)

src/storybook/mocks/useBillingContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function useBillingContext(): BillingContext {
3030
canRunWorkflows: computed(() => false),
3131
isFreeTier: computed(() => false),
3232
isLegacyTeamPlan: computed(() => false),
33+
isTeamPlan: computed(() => false),
3334
billingStatus: computed(() => null),
3435
subscriptionStatus: computed(() => null),
3536
tier: computed(() => null),

0 commit comments

Comments
 (0)