diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index 4be2bed424c..29ee1e9d47c 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -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)) @@ -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, @@ -86,7 +108,8 @@ const createI18nInstance = () => tiers: { standard: { name: 'Standard' }, creator: { name: 'Creator' }, - pro: { name: 'Pro' } + pro: { name: 'Pro' }, + founder: { name: 'Founder' } } } } @@ -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 () => { @@ -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( @@ -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 () => { @@ -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' }) + }) ) }) @@ -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() }) }) diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue index 4859a501f90..d59ead8edb0 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -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' @@ -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 { + 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(null) @@ -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) @@ -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 + }) }, reportError) onMounted(() => { diff --git a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts index dbee2617a80..91d3d12b8c9 100644 --- a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts +++ b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts @@ -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') } ] }, diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts index 8a6447fc106..f1ee4bbfbfd 100644 --- a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts @@ -51,7 +51,7 @@ function getTeamCheckoutRequest( return { stop, billingCycle: cycle } } -function getCheckoutSelection( +export function getPricingCheckoutSelection( pricing: string, stop: unknown, cycle: unknown, @@ -164,7 +164,7 @@ export function usePricingTableUrlLoader() { } } - const initialCheckout = getCheckoutSelection( + const initialCheckout = getPricingCheckoutSelection( param, query.stop, query.cycle,