Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
149 changes: 111 additions & 38 deletions src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { createI18n } from 'vue-i18n'

import type { TeamCreditStops } from '@/platform/workspace/api/workspaceApi'

import CloudSubscriptionRedirectView from './CloudSubscriptionRedirectView.vue'

const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
Expand Down Expand Up @@ -41,33 +43,53 @@ vi.mock('@/composables/useErrorHandling', () => ({
const subscriptionMocks = vi.hoisted(() => ({
canAccessSubscriptionFeatures: { value: false },
isInitialized: { value: true },
subscriptionStatus: { value: null }
}))

vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => subscriptionMocks
teamCreditStops: { value: null as TeamCreditStops | null },
initialize: vi.fn(),
fetchPlans: vi.fn(),
manageSubscription: vi.fn()
}))

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => subscriptionMocks
}))

// Avoid real network / isCloud behavior
const mockPerformSubscriptionCheckout = vi.fn()
const mockShowPricingTable = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ showPricingTable: mockShowPricingTable })
})
)

const legacyCheckoutMocks = vi.hoisted(() => ({
performSubscriptionCheckout: vi.fn(),
performTeamSubscriptionCheckout: vi.fn()
}))

vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
performSubscriptionCheckout: (...args: unknown[]) =>
mockPerformSubscriptionCheckout(...args)
performSubscriptionCheckout: legacyCheckoutMocks.performSubscriptionCheckout
}))

const mockPerformTeamSubscriptionCheckout = vi.fn()
vi.mock(
'@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil',
() => ({
performTeamSubscriptionCheckout: (...args: unknown[]) =>
mockPerformTeamSubscriptionCheckout(...args)
performTeamSubscriptionCheckout:
legacyCheckoutMocks.performTeamSubscriptionCheckout
})
)

const TEAM_CREDIT_STOPS = {
default_stop_index: 0,
stops: [
{
id: 'team_700',
credits: 147700,
monthly: { list_price_cents: 70000, price_cents: 69000 },
yearly: { list_price_cents: 70000, price_cents: 63000 }
}
]
} satisfies TeamCreditStops

const createI18nInstance = () =>
createI18n({
legacy: false,
Expand All @@ -86,7 +108,8 @@ const createI18nInstance = () =>
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
pro: { name: 'Pro' }
pro: { name: 'Pro' },
founder: { name: 'Founder' }
}
}
}
Expand All @@ -113,6 +136,10 @@ describe('CloudSubscriptionRedirectView', () => {
mockQuery = {}
subscriptionMocks.canAccessSubscriptionFeatures.value = false
subscriptionMocks.isInitialized.value = true
subscriptionMocks.teamCreditStops.value = TEAM_CREDIT_STOPS
subscriptionMocks.initialize.mockResolvedValue(undefined)
subscriptionMocks.fetchPlans.mockResolvedValue(undefined)
subscriptionMocks.manageSubscription.mockResolvedValue(undefined)
})

test('redirects to home when subscriptionType is missing', async () => {
Expand All @@ -136,15 +163,18 @@ describe('CloudSubscriptionRedirectView', () => {
// Shows copy under logo
expect(screen.getByText('Subscribe to Creator')).toBeInTheDocument()

// Triggers checkout flow
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: 'personal',
initialCheckout: {
planMode: 'personal',
tierKey: 'creator',
billingCycle: 'monthly'
}
)
})
expect(
legacyCheckoutMocks.performSubscriptionCheckout
).not.toHaveBeenCalled()

// Shows loading affordances
expect(
Expand All @@ -158,8 +188,9 @@ describe('CloudSubscriptionRedirectView', () => {
await mountView({ tier: 'creator' })

expect(mockRouterPush).not.toHaveBeenCalledWith('/')
expect(authActionMocks.accessBillingPortal).toHaveBeenCalledTimes(1)
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
expect(subscriptionMocks.manageSubscription).toHaveBeenCalledTimes(1)
expect(authActionMocks.accessBillingPortal).not.toHaveBeenCalled()
expect(mockShowPricingTable).not.toHaveBeenCalled()
})

test('uses first value when subscriptionType is an array', async () => {
Expand All @@ -169,13 +200,10 @@ describe('CloudSubscriptionRedirectView', () => {

expect(mockRouterPush).not.toHaveBeenCalledWith('/')
expect(screen.getByText('Subscribe to Creator')).toBeInTheDocument()
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
expect(mockShowPricingTable).toHaveBeenCalledWith(
expect.objectContaining({
initialCheckout: expect.objectContaining({ tierKey: 'creator' })
})
)
})

Expand All @@ -184,19 +212,64 @@ describe('CloudSubscriptionRedirectView', () => {

expect(mockRouterPush).not.toHaveBeenCalledWith('/')
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly',
{ paymentIntentSource: 'deep_link' }
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: 'team',
initialCheckout: {
planMode: 'team',
stop: {
id: 'team_700',
credits: 147700,
usd: 700,
discountedUsd: 630
},
billingCycle: 'yearly'
}
})
expect(
legacyCheckoutMocks.performTeamSubscriptionCheckout
).not.toHaveBeenCalled()
})

test('redirects to home for a team link with no stop', async () => {
await mountView({ tier: 'team', cycle: 'yearly' })

expect(mockRouterPush).toHaveBeenCalledWith('/')
expect(mockPerformTeamSubscriptionCheckout).not.toHaveBeenCalled()
expect(mockShowPricingTable).not.toHaveBeenCalled()
})

test('routes a personal tier in an active Team workspace to workspace subscription management', async () => {
subscriptionMocks.canAccessSubscriptionFeatures.value = true

await mountView({ tier: 'creator', cycle: 'yearly' })

expect(subscriptionMocks.manageSubscription).toHaveBeenCalledTimes(1)
expect(authActionMocks.accessBillingPortal).not.toHaveBeenCalled()
expect(
legacyCheckoutMocks.performSubscriptionCheckout
).not.toHaveBeenCalled()
expect(mockShowPricingTable).not.toHaveBeenCalled()
})

test('routes an active founder subscription to facade management', async () => {
subscriptionMocks.canAccessSubscriptionFeatures.value = true

await mountView({ tier: 'founder' })

expect(subscriptionMocks.manageSubscription).toHaveBeenCalledTimes(1)
expect(mockRouterPush).not.toHaveBeenCalled()
expect(mockShowPricingTable).not.toHaveBeenCalled()
})

test('opens personal pricing without unsupported direct checkout for an inactive founder link', async () => {
await mountView({ tier: 'founder' })

expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: 'personal'
})
expect(
legacyCheckoutMocks.performSubscriptionCheckout
).not.toHaveBeenCalled()
})
})
82 changes: 57 additions & 25 deletions src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { performTeamSubscriptionCheckout } from '@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { getPricingCheckoutSelection } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import type { CheckoutTierKey } from '@/platform/workspace/composables/useSubscriptionCheckout'

import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'

Expand All @@ -19,18 +20,31 @@ function isBillingCycle(value: string): value is BillingCycle {
}

// Only paid personal tiers can be checked out via this redirect.
function isCheckoutTierKey(value: string): value is TierKey {
return ['standard', 'creator', 'pro', 'founder'].includes(value)
function isCheckoutTierKey(value: string): value is CheckoutTierKey {
return ['standard', 'creator', 'pro'].includes(value)
}

function isPaidPersonalTierKey(
value: string
): value is Exclude<TierKey, 'free'> {
return isCheckoutTierKey(value) || value === 'founder'
}

const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { reportError, accessBillingPortal } = useAuthActions()
const { reportError } = useAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { showPricingTable } = useSubscriptionDialog()

const { canAccessSubscriptionFeatures, isInitialized, initialize } =
useBillingContext()
const {
canAccessSubscriptionFeatures,
isInitialized,
teamCreditStops,
initialize,
fetchPlans,
manageSubscription
} = useBillingContext()

const selectedTierKey = ref<TierKey | null>(null)

Expand Down Expand Up @@ -79,12 +93,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
? cycleParam
: 'monthly'

// Team is a per-credit plan picked on a slider, so it carries a `stop` (the
// chosen credit commitment) instead of a tier and checks out through the
// workspace billing endpoint rather than the personal one.
let stopId: string | null = null
if (tierKeyParam === 'team') {
const rawStop = route.query.stop
const stopId =
stopId =
typeof rawStop === 'string'
? rawStop
: Array.isArray(rawStop)
Expand All @@ -95,31 +107,51 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle, {
paymentIntentSource: 'deep_link'
})
return
}

if (!isCheckoutTierKey(tierKeyParam)) {
} else if (!isPaidPersonalTierKey(tierKeyParam)) {
await router.push('/')
return
} else {
selectedTierKey.value = tierKeyParam
}

selectedTierKey.value = tierKeyParam

if (!isInitialized.value) {
await initialize()
}

if (canAccessSubscriptionFeatures.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
openInNewTab: false,
paymentIntentSource: 'deep_link'
await manageSubscription()
return
}

if (isPaidPersonalTierKey(tierKeyParam)) {
if (!isCheckoutTierKey(tierKeyParam)) {
showPricingTable({ reason: 'deep_link', planMode: 'personal' })
return
}
showPricingTable({
reason: 'deep_link',
planMode: 'personal',
initialCheckout: {
planMode: 'personal',
tierKey: tierKeyParam,
billingCycle
}
})
return
}

if (!teamCreditStops.value) await fetchPlans()
const initialCheckout = getPricingCheckoutSelection(
tierKeyParam,
stopId,
billingCycle,
teamCreditStops.value
)
showPricingTable({
reason: 'deep_link',
planMode: 'team',
initialCheckout
})
Comment on lines +143 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle Team plan-load failures before opening checkout.

If fetchPlans() rejects, runRedirect exits before it calls
showPricingTable. The Team deep link then cannot continue to a pricing flow.

Catch this failure and open the generic Team pricing table when plan data remains
unavailable. Add a regression test where fetchPlans() rejects.

This differs from
src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts Lines
148-164, which catches this failure and opens the generic Team table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue` around lines
143 - 154, Update runRedirect in CloudSubscriptionRedirectView.vue to catch
fetchPlans() failures during Team deep-link handling; when plan data remains
unavailable, call showPricingTable with the generic Team configuration instead
of allowing the rejection to abort redirect processing. Preserve the existing
initialCheckout flow when plans load successfully, and add a regression test
covering a rejected fetchPlans() call.

}, reportError)

onMounted(() => {
Expand Down
14 changes: 10 additions & 4 deletions src/platform/cloud/onboarding/onboardingCloudRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,19 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudAuthTimeoutView.vue'),
props: true
},
}
]
},
{
path: '/cloud/subscribe',
component: () => import('@/views/layouts/LayoutDefault.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'subscribe',
path: '',
name: 'cloud-subscribe',
component: () =>
import('@/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue'),
meta: { requiresAuth: true }
import('@/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue')
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function getTeamCheckoutRequest(
return { stop, billingCycle: cycle }
}

function getCheckoutSelection(
export function getPricingCheckoutSelection(
pricing: string,
stop: unknown,
cycle: unknown,
Expand Down Expand Up @@ -164,7 +164,7 @@ export function usePricingTableUrlLoader() {
}
}

const initialCheckout = getCheckoutSelection(
const initialCheckout = getPricingCheckoutSelection(
param,
query.stop,
query.cycle,
Expand Down
Loading