Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7209fe4
feat(billing): render the Enterprise tier and hide its self-serve pri…
comfydesigner Aug 18, 2026
5ee9b28
fix(billing): stop the billing policy state crashing on runtime-only …
comfydesigner Aug 18, 2026
ca29a67
fix(billing): close enterprise gaps from review — slug-only credits f…
comfydesigner Aug 18, 2026
fe0a575
feat(billing): route enterprise plan cancellation through sales
comfydesigner Aug 18, 2026
1d9543b
fix(billing): keep ended enterprise plans off the self-serve subscrib…
comfydesigner Aug 18, 2026
b849cd0
test(billing): cover the enterprise-tier path for cancellation gating
comfydesigner Aug 19, 2026
af2f2a8
fix(billing): close the enterprise fail-open paths — lapsed classific…
comfydesigner Aug 19, 2026
2b8fe82
test(billing): local-distribution tripwire for unrecognised tiers
comfydesigner Aug 19, 2026
844e4c6
fix: cover enterprise billing states
dante01yoon Aug 19, 2026
5e9d79b
Merge branch 'main' into comfydesigner/enterprise-tier-rendering
dante01yoon Aug 19, 2026
d296689
test: give the dialog-renderer billing mock a subscription ref
comfydesigner Aug 20, 2026
d222055
test: cover enterprise billing journeys
dante01yoon Aug 19, 2026
bb0bc30
test: close enterprise e2e regression gaps
dante01yoon Aug 19, 2026
8743dc5
test: separate unrecognized billing tier regression
dante01yoon Aug 20, 2026
8bf9f76
feat: render unrecognized tiers as Current plan with no catalog content
comfydesigner Aug 20, 2026
b38acfa
fix(billing): align enterprise workspace actions with design
dante01yoon Aug 20, 2026
32c7f10
Merge origin/main into comfydesigner/enterprise-tier-rendering
dante01yoon Aug 21, 2026
2e89c37
Merge remote-tracking branch 'origin/main' into dante/resolve-pr-1540…
dante01yoon Aug 21, 2026
9fd27ea
test: provide subscription in billing context mock
dante01yoon Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2864,6 +2864,9 @@
"free": {
"name": "Free"
},
"enterprise": {
"name": "Enterprise"
},
"founder": {
"name": "Founder's Edition"
},
Expand Down
5 changes: 4 additions & 1 deletion src/platform/cloud/subscription/components/CreditsTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
import { useBillingPolicyCapabilities } from '@/platform/cloud/subscription/composables/useBillingPolicyCapabilities'
import {
DEFAULT_TIER_KEY,
isEnterpriseTier,
toTierKey,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
Expand Down Expand Up @@ -283,7 +284,9 @@ const tierKey = computed(() => {
const creditPoolTotalCredits = computed<number | null>(() => {
const monthlyCredits =
currentTeamCreditStop.value?.credits_monthly ??
getTierCredits(tierKey.value)
(isEnterpriseTier(subscription.value?.tier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — This is the only Enterprise check in the PR that tests tier alone; every other site (CurrentUserPopoverWorkspace, SubscriptionPanelContentWorkspace, usePricingTableUrlLoader) uses isEnterpriseTier(tier) || isEnterprisePlanSlug(planSlug). The PR's own test fixture models Enterprise as { slug: 'enterprise_monthly', tier: 'PRO' }, and for that shape the guard misses, tierKey falls back through toTierKey(tier) ?? DEFAULT_TIER_KEY, and the tile renders a fabricated monthly allowance plus a usage bar computed against it. Raised by 5 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case, gemini-3.1-pro edge-case, kimi-k3-max edge-case).

? null
: getTierCredits(tierKey.value))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (monthlyCredits === null) return null
return subscription.value?.duration === 'ANNUAL'
? monthlyCredits * 12
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useRoute, useRouter } from 'vue-router'

import { useBillingContext } from '@/composables/billing/useBillingContext'
import {
isEnterprisePlanSlug,
isEnterpriseTier
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
clearPreservedQuery,
Expand Down Expand Up @@ -102,7 +106,7 @@
const route = useRoute()
const router = useRouter()
const subscriptionDialog = useSubscriptionDialog()
const { teamCreditStops, fetchPlans } = useBillingContext()
const { subscription, teamCreditStops, fetchPlans } = useBillingContext()
const { permissions } = useWorkspaceUI()

/** Reads `?pricing=`, strips it, and opens the table when the gate allows. */
Expand Down Expand Up @@ -139,6 +143,14 @@
if (typeof param !== 'string' || !param) return

if (!permissions.value.canManageSubscription) return
// Enterprise is sales-managed: the pricing table never opens for it, even
// from a deep link. The param was already stripped above.
if (
isEnterpriseTier(subscription.value?.tier) ||

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens the selected plan confirmation from a marketing deep link

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:137:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens on the personal tab for ?pricing=personal

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:126:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens on the personal tab for ?pricing=personal

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:126:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens on the personal tab for ?pricing=personal

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:126:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens on the team tab for ?pricing=team

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:115:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens on the team tab for ?pricing=team

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:115:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens on the team tab for ?pricing=team

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:115:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens the pricing table for any owner capability

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:103:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens the pricing table for any owner capability

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:103:11

Check failure on line 149 in src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts > usePricingTableUrlLoader > opens the pricing table for any owner capability

TypeError: Cannot read properties of undefined (reading 'value') ❯ loadPricingTableFromUrl src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts:149:37 ❯ src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts:103:11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — The Enterprise gate reads subscription.value synchronously, but this loader runs from GraphCanvas's onMounted via runUrlActionLoaders() with nothing awaiting billing initialization, and the ?pricing= param is replayed right after a login redirect via the preserved-query system — exactly when status is still in flight — so the gate fails open. Unlike canManageSubscription, it is also never re-evaluated after the later await fetchPlans(); either await/watch the subscription before deciding or re-check after the fetch. Raised by 5 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case, kimi-k3-max adversarial, kimi-k3-max edge-case).

isEnterprisePlanSlug(subscription.value?.planSlug)
) {
return
}

const teamCheckoutRequest = getTeamCheckoutRequest(
param,
Expand Down
16 changes: 15 additions & 1 deletion src/platform/cloud/subscription/constants/tierPricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,21 @@ export const DEFAULT_TIER_KEY: TierKey = 'standard'

// TEAM is workspace-level, so it maps to no key in this personal plan catalog.
export function toTierKey(tier: IngestSubscriptionTier): TierKey | null {
return tier === 'TEAM' ? null : TIER_TO_KEY[tier]
return tier === 'TEAM' ? null : (TIER_TO_KEY[tier] ?? null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 LowTIER_TO_KEY[tier] ?? null still resolves inherited object properties, so a runtime tier string of __proto__ or constructor returns an object/function instead of TierKey | null, violating the declared return type and throwing in downstream pricing lookups. Use a Map, Object.hasOwn guard, or a null-prototype record. Raised by 1 of 8 reviewers (gpt-5.6-sol-max adversarial).

}

// ENTERPRISE is a sales-managed workspace tier: absent from the personal
// catalog and (until the ingest enum ships it) from the generated types, so it
// is matched as a runtime string. It never self-serves plan changes.
const ENTERPRISE_TIER = 'ENTERPRISE'
const ENTERPRISE_PLAN_SLUG_PREFIX = 'enterprise'

export function isEnterpriseTier(tier: string | null | undefined): boolean {
return tier?.toUpperCase() === ENTERPRISE_TIER
}

export function isEnterprisePlanSlug(slug: string | null | undefined): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 LowisEnterprisePlanSlug is an unanchored prefix match on a server-supplied slug, so any future slug starting with "enterprise" (e.g. a self-serve enterprise_trial) is silently classified as sales-managed and loses its price display, benefits list, and every plan-change control with no diagnostic. An exact-match set or a delimiter-anchored enterprise_ check would avoid the over-capture. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, kimi-k3-max adversarial).

return slug?.toLowerCase().startsWith(ENTERPRISE_PLAN_SLUG_PREFIX) === true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This predicate encodes server-owned business policy in the frontend. tier === 'ENTERPRISE' || planSlug.startsWith('enterprise') means every consumer must independently combine potentially stale/conflicting fields, and a future slug can silently inherit sales-managed behavior. Please have the billing API return an explicit capability/policy contract (for example subscriptionManagementMode: 'sales_managed', canChangePlan, canCancel, canReactivate, canOpenPricing) and make the UI consume that instead. The backend should enforce those capabilities as well; hiding controls here is not sufficient.

}

// Includes the workspace-level TEAM, which toTierKey maps to null: a catalog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,17 @@ describe('CurrentUserPopoverWorkspace', () => {
expect(state.showPricingTable).toHaveBeenCalledOnce()
})

it('hides Plans & pricing for an enterprise workspace but keeps Manage plan', () => {
state.planSlug = 'enterprise_monthly'
state.canManageSubscription = true
renderComponent('team')

expect(
screen.queryByTestId('plans-pricing-menu-item')
).not.toBeInTheDocument()
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
})

for (const workspaceType of ['personal', 'team'] as const) {
it(`opens workspace plan management for a ${workspaceType} owner`, async () => {
const user = userEvent.setup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'

import { useExternalLink } from '@/composables/useExternalLink'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import {
isEnterprisePlanSlug,
isEnterpriseTier
} from '@/platform/cloud/subscription/constants/tierPricing'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
Expand Down Expand Up @@ -303,8 +307,13 @@ const displayedCredits = computed(() => {
})
})

const isEnterprisePlan = computed(
() =>
isEnterpriseTier(subscription.value?.tier) ||
isEnterprisePlanSlug(subscription.value?.planSlug)
)
const showPlansAndPricing = computed(
() => permissions.value.canManageSubscription
() => permissions.value.canManageSubscription && !isEnterprisePlan.value
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — Hiding plans-pricing-menu-item does not close the path it guards: the subscribe/resubscribe button earlier in this same popover (showSubscribeAction && !isPersonalWorkspace) calls the identical handleOpenPlansAndPricing, and showSubscribeAction is true whenever the subscription is cancelled or inactive. An existing test already asserts that clicking Resubscribe calls showPricingTable, so a cancelled Enterprise workspace still reaches the self-serve table one click away. Raised by 4 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

)
const hasDelinquentSubscription = computed(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,56 @@ describe('SubscriptionPanelContentWorkspace', () => {
)
})

it('renders Enterprise without price, benefits, or a plan-change action', () => {
mockHasTeamPlan.value = false
mockPlanSlug.value = 'enterprise_monthly'
mockCurrentTeamCreditStop.value = null
renderComponent()

expect(screen.getByText('Enterprise')).toBeInTheDocument()
expect(screen.queryByText('$665')).not.toBeInTheDocument()
expect(screen.queryByText('USD / mo')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /change plan|upgrade plan/i })
).not.toBeInTheDocument()
expect(
screen.getByText(`Renews on ${formatPanelDate(RENEWAL_DATE_ISO)}`)
).toBeInTheDocument()
})

it('labels a scheduled change to Enterprise', () => {
const basePlans = mockPlans.value
mockScheduledPlanSlug.value = 'enterprise_monthly'
mockChangeAt.value = END_DATE_ISO
mockPlans.value = [
...basePlans,
{
slug: 'enterprise_monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 0,
credits_cents: 0,
max_seats: 1,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 0,
total_credits_cents: 0
}
}
]
try {
renderComponent()
expect(
screen.getByText(
`Changes to Enterprise on ${formatPanelDate(END_DATE_ISO)}`
)
).toBeInTheDocument()
} finally {
mockPlans.value = basePlans
}
})

it('shows a scheduled plan change instead of the renewal date', () => {
mockScheduledPlanSlug.value = 'pro-annual'
mockChangeAt.value = END_DATE_ISO
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,10 @@
severity="warn"
/>
</div>
<div class="flex items-baseline gap-1 font-inter">
<div
v-if="!isEnterprisePlan"
class="flex items-baseline gap-1 font-inter"
>
<span class="text-2xl font-semibold">{{ displayPrice }}</span>
<span class="text-base">{{ priceUnitLabel }}</span>
</div>
Expand Down Expand Up @@ -276,7 +279,8 @@
v-else-if="
!isSubscriptionCancelled &&
canAccessSubscriptionFeatures &&
permissions.canManageSubscription
permissions.canManageSubscription &&
!isEnterprisePlan

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — Only the "Change plan" branch is gated on !isEnterprisePlan. The sibling Reactivate/Subscribe branches (shown when the subscription is cancelled or ended) and the overflow menu's canCancelPlan entry are ungated, so a cancelled, lapsed, or active Enterprise workspace still gets self-serve lifecycle actions on a sales-managed contract. Raised by 3 of 8 reviewers (gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

"
size="lg"
variant="secondary"
Expand Down Expand Up @@ -417,6 +421,10 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import {
isEnterprisePlanSlug,
isEnterpriseTier
} from '@/platform/cloud/subscription/constants/tierPricing'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { isCloud } from '@/platform/distribution/types'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
Expand Down Expand Up @@ -566,6 +574,12 @@ const scheduledPlanName = computed(() => {
(plan) => plan.slug === subscription.value?.scheduledPlanSlug
)
if (!scheduledPlan) return ''
if (
isEnterprisePlanSlug(scheduledPlan.slug) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — Enterprise classification happens only after the scheduled plan is found in the self-service plans catalog. If a sales-managed Enterprise plan is absent from that catalog or the fetch fails, the earlier if (!scheduledPlan) return '' suppresses the entire scheduled-change label and date even though scheduledPlanSlug alone identifies it — check the slug before the catalog lookup. Raised by 2 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case).

isEnterpriseTier(scheduledPlan.tier)
) {
return t('subscription.tiers.enterprise.name')
}
if (scheduledPlan.slug.startsWith('team')) {
return t('subscription.teamPlanName')
}
Expand Down Expand Up @@ -627,10 +641,19 @@ const subscriptionTierName = computed(() => {
: baseName
})

const planDisplayName = computed(() =>
isTeamPlan.value ? t('subscription.teamPlanName') : subscriptionTierName.value
const isEnterprisePlan = computed(
() =>
isEnterpriseTier(subscription.value?.tier) ||
isEnterprisePlanSlug(subscription.value?.planSlug)
)

const planDisplayName = computed(() => {
if (isEnterprisePlan.value) return t('subscription.tiers.enterprise.name')
return isTeamPlan.value
? t('subscription.teamPlanName')
: subscriptionTierName.value
})

const tierKey = computed(() =>
resolveSubscriptionTierKey(subscription.value?.tier)
)
Expand All @@ -643,6 +666,7 @@ const TEAM_PERK_KEYS = [
] as const

const tierBenefits = computed((): TierBenefit[] => {
if (isEnterprisePlan.value) return []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (isTeamActive.value || showInactiveTeamSubscription.value) {
return TEAM_PERK_KEYS.map((key) => ({
key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ defineEmits<{
back: []
}>()

const { t, n } = useI18n()
const { t, te, n } = useI18n()
const { subscription } = useBillingContext()

function openVerification() {
Expand All @@ -266,7 +266,10 @@ function openVerification() {
}

function formatTierName(tier: string): string {
return t(`subscription.tiers.${tier.toLowerCase()}.name`)
const nameKey = `subscription.tiers.${tier.toLowerCase()}.name`
if (te(nameKey)) return t(nameKey)
const lower = tier.toLowerCase()
return lower.charAt(0).toUpperCase() + lower.slice(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumformatTierName is hardened to tolerate tiers outside the catalog, but tierMonthlyCredits in this same component still calls getTierCredits(tier.toLowerCase() as PersonalTierKey), which dereferences TIER_PRICING[tierKey].credits for anything not free/founder. An ENTERPRISE (or unmapped) tier therefore throws TypeError: Cannot read properties of undefined inside the refillCredits computed and crashes the checkout preview render. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

}

function isTeamTier(tier: string): boolean {
Expand Down
14 changes: 12 additions & 2 deletions src/platform/workspace/composables/useWorkspaceTierLabel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ vi.mock('vue-i18n', () => ({
'subscription.tiers.standard.name': 'Standard',
'subscription.tiers.creator.name': 'Creator',
'subscription.tiers.pro.name': 'Pro',
'subscription.tiers.founder.name': "Founder's Edition"
'subscription.tiers.founder.name': "Founder's Edition",
'subscription.tiers.enterprise.name': 'Enterprise'
}
return tierNames[key] ?? key
})
Expand Down Expand Up @@ -103,12 +104,21 @@ describe('useWorkspaceTierLabel', () => {
expect(result).toBeNull()
})

it('returns null when plan slug does not match any known tier', () => {
it('labels enterprise plan slugs', () => {
const result = getTierLabel({
isSubscribed: true,
subscriptionPlan: 'ENTERPRISE_CUSTOM',
subscriptionTier: null
})
expect(result).toBe('Enterprise')
})

it('returns null when plan slug does not match any known tier', () => {
const result = getTierLabel({
isSubscribed: true,
subscriptionPlan: 'ULTRA_CUSTOM',
subscriptionTier: null
})
expect(result).toBeNull()
})

Expand Down
3 changes: 2 additions & 1 deletion src/platform/workspace/composables/useWorkspaceTierLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const tierKeyMap: Record<string, string> = {
CREATOR: 'creator',
PRO: 'pro',
FOUNDER: 'founder',
FOUNDERS_EDITION: 'founder'
FOUNDERS_EDITION: 'founder',
ENTERPRISE: 'enterprise'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The new ENTERPRISE entry only helps the subscriptionTier path. getTierLabel returns a non-null PRO tier before inspecting the slug (so the PR's own tier: 'PRO' + enterprise_monthly shape is labeled "Pro"), and the slug fallback compares case-sensitively against these uppercase keys, so a null tier plus the lowercase enterprise_monthly that billing context actually produces yields no label. The new test passes only because it feeds 'ENTERPRISE_CUSTOM', a slug casing the backend never sends. Raised by 3 of 8 reviewers (gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

}

interface WorkspaceSubscriptionInfo {
Expand Down
Loading