Skip to content
Merged
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
55 changes: 55 additions & 0 deletions src/platform/workspace/components/UnifiedPricingTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createI18n } from 'vue-i18n'

import Button from '@/components/ui/button/Button.vue'
import enMessages from '@/locales/en/main.json'
import type { BillingSubscriptionStatus } from '@/platform/workspace/api/workspaceApi'
import UnifiedPricingTable from '@/platform/workspace/components/UnifiedPricingTable.vue'

interface MockSubscription {
Expand All @@ -21,6 +22,7 @@ interface MockTeamStop {
}

const mockSubscription = ref<MockSubscription | null>(null)
const mockSubscriptionStatus = ref<BillingSubscriptionStatus | null>(null)
const mockCurrentPlanSlug = ref<string | null>(null)
const mockCurrentTeamCreditStop = ref<MockTeamStop | null>(null)
const mockTeamFlag = ref(false)
Expand All @@ -35,6 +37,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
fetchPlans: vi.fn(),
isTeamPlan: computed(() => mockIsTeamPlan.value),
subscription: computed(() => mockSubscription.value),
subscriptionStatus: computed(() => mockSubscriptionStatus.value),
currentTeamCreditStop: computed(() => mockCurrentTeamCreditStop.value)
})
}))
Expand Down Expand Up @@ -83,6 +86,7 @@ function renderComponent(props: Record<string, unknown> = {}) {
describe('UnifiedPricingTable plan CTA labels', () => {
beforeEach(() => {
mockSubscription.value = null
mockSubscriptionStatus.value = null
mockCurrentPlanSlug.value = null
mockCurrentTeamCreditStop.value = null
mockTeamFlag.value = false
Expand Down Expand Up @@ -121,6 +125,36 @@ describe('UnifiedPricingTable plan CTA labels', () => {
).toBeTruthy()
})

it('offers a fresh subscribe on the plan an ended subscription used to hold', async () => {
const user = userEvent.setup()
// An ended subscription still reports its tier and plan slug.
mockSubscription.value = {
tier: 'CREATOR',
duration: 'ANNUAL',
isCancelled: false
}
mockSubscriptionStatus.value = 'ended'

const { emitted } = renderComponent()

expect(screen.queryByRole('button', { name: 'Current Plan' })).toBeNull()
expect(screen.queryByRole('button', { name: /^Change to/ })).toBeNull()

const cta = screen.getByRole('button', {
name: 'Subscribe to Creator Yearly'
})
expect(cta).toBeEnabled()
await user.click(cta)
const [payload] = emitted().subscribe![0] as [
{ tierKey: string; billingCycle: string }
]
expect(payload).toMatchObject({
tierKey: 'creator',
billingCycle: 'yearly'
})
expect(emitted().resubscribe).toBeFalsy()
})

it('keeps personal tier cards actionable for the original owner of a team plan', () => {
mockSubscription.value = { tier: 'TEAM', duration: 'ANNUAL' }
mockCurrentTeamCreditStop.value = {
Expand Down Expand Up @@ -166,6 +200,7 @@ describe('UnifiedPricingTable team plan CTA', () => {

beforeEach(() => {
mockSubscription.value = null
mockSubscriptionStatus.value = null
mockCurrentPlanSlug.value = null
mockCurrentTeamCreditStop.value = null
mockTeamFlag.value = true
Expand Down Expand Up @@ -266,6 +301,26 @@ describe('UnifiedPricingTable team plan CTA', () => {
expect(emitted().resubscribe).toBeFalsy()
})

it('prompts a fresh subscribe for an ended team subscription', async () => {
const user = userEvent.setup()
mockSubscription.value = {
tier: 'TEAM',
duration: 'ANNUAL',
isCancelled: false
}
mockSubscriptionStatus.value = 'ended'
mockCurrentTeamCreditStop.value = TEAM_STOP

const { emitted } = renderComponent({ initialPlanMode: 'team' })

const cta = screen.getByRole('button', { name: 'Subscribe to Team Yearly' })
expect(cta).toBeEnabled()
await user.click(cta)
const [teamPayload] = emitted().subscribeTeam![0] as [{ isChange: boolean }]
expect(teamPayload).toMatchObject({ isChange: false })
expect(emitted().resubscribe).toBeFalsy()
})

it('prompts a fresh subscribe when on no team plan', () => {
renderComponent({ initialPlanMode: 'team' })

Expand Down
23 changes: 19 additions & 4 deletions src/platform/workspace/components/UnifiedPricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ const {
fetchPlans,
isTeamPlan,
subscription,
subscriptionStatus,
currentTeamCreditStop
} = useBillingContext()

Expand All @@ -640,6 +641,10 @@ const { teamCreditStops } = useBillingPlans()

const isCancelled = computed(() => subscription.value?.isCancelled ?? false)

// An ended subscription still reports its plan slug and tier, so the plan it
// held must not read as current — it is buyable again.
const isEnded = computed(() => subscriptionStatus.value === 'ended')

const currentBillingCycle = ref<BillingCycle>('yearly')

// Team credit stops: backend-sourced when the API supplies them, otherwise the
Expand Down Expand Up @@ -676,7 +681,9 @@ const teamVideoEstimate = computed(() =>

// The team's currently-subscribed stop (null when on no team plan). Matched to
// the slider stops by list price so the current stop can be disabled.
const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
const isTeamSubscribed = computed(
() => currentTeamCreditStop.value !== null && !isEnded.value
)

// `teamUsd` is seeded at mount from the fallback default; when the API stops
// resolve afterwards with different breakpoints that seed can match no stop,
Expand Down Expand Up @@ -780,14 +787,17 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
}

const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
subscription.value?.tier && !isEnded.value
? TIER_TO_KEY[subscription.value.tier]
: null
)

const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)

const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
if (isEnded.value) return false
if (currentPlanSlug.value) {
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
return plan?.slug === currentPlanSlug.value
Expand Down Expand Up @@ -881,8 +891,13 @@ function handleSubscribe(tierKey: CheckoutTierKey) {
function handleSubscribeTeam() {
if (isTeamButtonDisabled.value) return
// Re-subscribe only when keeping the exact current plan; any other stop or
// cycle is a change.
if (isCancelled.value && isTeamCurrentPlanSelected.value) {
// cycle is a change. An ended subscription still reports its credit stop, so
// the stop alone is no proof there is a subscription left to resume.
if (
isTeamSubscribed.value &&
isCancelled.value &&
isTeamCurrentPlanSelected.value
) {
emit('resubscribe')
return
}
Expand Down
Loading