Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
25 changes: 25 additions & 0 deletions browser_tests/tests/dialogs/creditsTile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ const endedPersonalBillingStatus: BillingStatusResponse = {
has_funds: true
}

const paymentFailedBillingStatus: BillingStatusResponse = {
...mockBillingStatus,
is_active: false,
billing_status: 'payment_failed'
}

async function mockCloudBoot(
page: Page,
billingControlEnabled = true,
Expand Down Expand Up @@ -276,6 +282,25 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
.toBe('https://billing.example/portal')
})

test('keeps billing management available when payment fails', async ({
page
}) => {
test.setTimeout(60_000)

await mockCloudBoot(page, true, paymentFailedBillingStatus)

const content = await openPlanAndCredits(page)
await expect(
content.getByRole('button', { name: 'Billing & invoices' })
).toBeVisible()
await expect(
content.getByRole('button', { name: 'Change plan' })
).toHaveCount(0)

await content.getByRole('button', { name: 'More Options' }).click()
await expect(page.getByText('Cancel plan', { exact: true })).toBeVisible()
})

test('keeps the legacy Workspace UX when billing controls are disabled', async ({
page
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import enMessages from '@/locales/en/main.json'
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'

const state = vi.hoisted(() => ({
billingStatus: 'paid',
canAccessSubscriptionFeatures: true,
isFreeTier: false,
isCancelled: false,
Expand Down Expand Up @@ -53,6 +54,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
billingStatus: computed(() => state.billingStatus),
canAccessSubscriptionFeatures: computed(
() => state.canAccessSubscriptionFeatures
),
Expand Down Expand Up @@ -157,6 +159,7 @@ function renderComponent(

describe('CurrentUserPopoverWorkspace', () => {
beforeEach(() => {
state.billingStatus = 'paid'
state.canAccessSubscriptionFeatures = true
state.isFreeTier = false
state.isCancelled = false
Expand Down Expand Up @@ -249,6 +252,22 @@ describe('CurrentUserPopoverWorkspace', () => {
).not.toBeInTheDocument()
})

it.for(['payment_failed', 'paused'])(
'keeps Manage plan available instead of Subscribe when billing is %s',
(billingStatus) => {
state.billingStatus = billingStatus
state.canAccessSubscriptionFeatures = false
state.canManageSubscription = true

renderComponent('team')

expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Subscribe' })
).not.toBeInTheDocument()
}
)

it.for([
{
name: 'allows a lifecycle manager to resubscribe a cancelled plan',
Expand Down Expand Up @@ -335,10 +354,13 @@ describe('CurrentUserPopoverWorkspace', () => {
state.canManageSubscription = true
const { emitted } = renderComponent(workspaceType)

const menuItem = screen.getByTestId('manage-plan-menu-item')
const menuItem = screen.getByRole('button', {
name: enMessages.subscription.managePlan
})
expect(menuItem).toHaveTextContent(enMessages.subscription.managePlan)

await user.click(menuItem)
menuItem.focus()
await user.keyboard('{Enter}')

expect(state.showSettingsDialog).toHaveBeenCalledWith('workspace')
expect(emitted('close')).toHaveLength(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@
}}</span>
</div>

<div
<button
v-if="!accountActionsOnly && showManagePlan"
type="button"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"
Expand All @@ -153,7 +154,7 @@
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.managePlan')
}}</span>
</div>
</button>

<!-- Partner Nodes Pricing (always shown) -->
<div
Expand Down Expand Up @@ -271,6 +272,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
billingStatus,
canAccessSubscriptionFeatures,
isFreeTier,
subscription,
Expand Down Expand Up @@ -307,12 +309,16 @@ const showPlansAndPricing = computed(
const showManagePlan = computed(
() =>
permissions.value.canManageSubscription &&
canAccessSubscriptionFeatures.value
(canAccessSubscriptionFeatures.value ||
billingStatus.value === 'payment_failed' ||
billingStatus.value === 'paused')
)
const showSubscribeAction = computed(
() =>
(isCancelled.value && permissions.value.canManageSubscriptionLifecycle) ||
(!canAccessSubscriptionFeatures.value &&
billingStatus.value !== 'payment_failed' &&
billingStatus.value !== 'paused' &&
Comment thread
dante01yoon marked this conversation as resolved.
Outdated
permissions.value.canManageSubscription)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
})

it.for(['paid', 'payment_failed', 'paused'] as BillingStatus[])(
'keeps a %s personal plan visible until it is terminal',
'keeps billing access for a non-terminal %s personal plan',
(billingStatus) => {
mockIsInPersonalWorkspace.value = true
mockIsActiveSubscription.value = false
Expand All @@ -598,6 +598,12 @@ describe('SubscriptionPanelContentWorkspace', () => {
expect(
screen.queryByRole('button', { name: 'Subscribe' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Billing & invoices' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Change plan' })
).not.toBeInTheDocument()
}
)

Expand Down Expand Up @@ -866,7 +872,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
{ state: 'never-subscribed', hasSubscription: false, tier: 'PRO' },
{ state: 'Free', hasSubscription: true, tier: 'FREE' }
] as const)(
'hides legacy billing access from $state personal workspaces',
'keeps billing access for $state personal workspaces',
({ hasSubscription, tier }) => {
mockBillingType.value = 'legacy'
mockBillingStatus.value = 'inactive'
Expand All @@ -881,8 +887,8 @@ describe('SubscriptionPanelContentWorkspace', () => {

expect(screen.getByRole('heading', { name: 'Free' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Billing & invoices' })
).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Billing & invoices' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Subscribe' })
).toBeInTheDocument()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,7 @@
</div>
<div class="flex flex-wrap gap-2 md:ml-auto">
<Button
v-if="
isCloud &&
permissions.canManageSubscription &&
(billingType === 'workspace' ||
(isSubscriptionEnded &&
!isFreeTierPlan &&
subscription !== null))
"
v-if="isCloud && permissions.canManageSubscription"
size="lg"
variant="secondary"
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
Expand Down Expand Up @@ -242,7 +235,10 @@
</div>

<div
v-if="canAccessSubscriptionFeatures"
v-if="
canAccessSubscriptionFeatures ||
(isCloud && permissions.canManageSubscription)
"
class="flex flex-wrap gap-2 md:ml-auto"
>
<Button
Expand Down Expand Up @@ -279,6 +275,7 @@
<Button
v-else-if="
!isSubscriptionCancelled &&
canAccessSubscriptionFeatures &&
permissions.canManageSubscription
"
size="lg"
Expand Down Expand Up @@ -452,7 +449,6 @@ function openSubscriptionVerification() {
}

const {
type: billingType,
canAccessSubscriptionFeatures,
isFreeTier: isFreeTierPlan,
isTeamPlan,
Expand Down
27 changes: 27 additions & 0 deletions src/platform/workspace/composables/useWorkspaceMenuItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceMenuItems } from './useWorkspaceMenuItems'

const state = vi.hoisted(() => ({
billingStatus: 'paid',
canLeaveWorkspace: false,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
Expand All @@ -27,6 +28,7 @@ vi.mock('vue-i18n', () => ({

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
billingStatus: computed(() => state.billingStatus),
isFreeTier: computed(() => state.isFreeTier),
subscription: computed(() => ({ endDate: '2026-08-01T00:00:00Z' }))
})
Expand Down Expand Up @@ -66,6 +68,7 @@ vi.mock('@/services/dialogService', () => ({

describe('useWorkspaceMenuItems', () => {
beforeEach(() => {
state.billingStatus = 'paid'
state.canLeaveWorkspace = false
state.canManageSubscription = false
state.canManageSubscriptionLifecycle = false
Expand Down Expand Up @@ -113,6 +116,30 @@ describe('useWorkspaceMenuItems', () => {
)
})

it('allows cancellation while a payment_failed plan needs payment recovery', () => {
state.billingStatus = 'payment_failed'
state.canManageSubscriptionLifecycle = true
state.isActiveSubscription = false

const { menuItems } = useWorkspaceMenuItems()

expect(menuItems.value.map((item) => item.label)).toContain(
'subscription.cancelPlan'
)
})

it('withholds cancellation while a plan is paused', () => {
state.billingStatus = 'paused'
state.canManageSubscriptionLifecycle = true
state.isActiveSubscription = false

const { menuItems } = useWorkspaceMenuItems()

expect(menuItems.value.map((item) => item.label)).not.toContain(
'subscription.cancelPlan'
)
})

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it('rechecks eligibility before opening the cancellation dialog', () => {
state.canManageSubscriptionLifecycle = true
const { menuItems } = useWorkspaceMenuItems()
Expand Down
5 changes: 3 additions & 2 deletions src/platform/workspace/composables/useWorkspaceMenuItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useDialogService } from '@/services/dialogService'
*/
export function useWorkspaceMenuItems() {
const { t } = useI18n()
const { isFreeTier, subscription } = useBillingContext()
const { billingStatus, isFreeTier, subscription } = useBillingContext()
const {
permissions,
uiConfig,
Expand Down Expand Up @@ -65,7 +65,8 @@ export function useWorkspaceMenuItems() {
const canCancelPlan = computed(
() =>
permissions.value.canManageSubscriptionLifecycle &&
isActiveSubscription.value &&
(isActiveSubscription.value ||
billingStatus.value === 'payment_failed') &&
!isSubscriptionCancelled.value &&
!isFreeTier.value
)
Expand Down
Loading