Skip to content

Commit 10c56a1

Browse files
authored
fix(workspace): let ended subscriptions resubscribe to the same plan (#14607)
After cancelling a subscription on Stripe's side, access is correctly revoked, but Plans & Pricing still showed the lost plan as "Current Plan" with a disabled button, so the user could not resubscribe to it. The other tiers also mislabelled as "Change to X" instead of "Subscribe to X". ## Root cause An ended subscription still reports its `plan_slug` and `subscription_tier` on `GET /api/billing/status` (only `is_active` flips to false). The pricing table modelled just two states for the plan you hold — active (disabled "Current Plan") and cancel-scheduled (enabled "Resubscribe") — and `isCancelled` is derived from `subscription_status === 'canceled'` only, so an `ended` subscription fell into the active bucket. This is the residual half of #14504, which fixed the same `ended` blind spot in the subscription panel but not here. ## Change `UnifiedPricingTable.vue` treats `subscription_status: 'ended'` as holding no current plan, so the CTA becomes an enabled "Subscribe to <plan>". Applies to both the personal tier cards (`isCurrentPlan`, `currentTierKey`) and the team credit stop (`isTeamSubscribed`). Backend needs no change — the subscribe path already treats an ended subscription as "no subscription". ## Verification - Two regression tests added; both fail on `main` and pass with the fix. - `UnifiedPricingTable` suite: 12 passed, incl. under `--coverage` (matching CI). - Workspace + billing + subscription suites: 84 files, 1339 tests passed. - `vue-tsc --noEmit`, eslint, oxfmt clean.
1 parent 617545c commit 10c56a1

2 files changed

Lines changed: 74 additions & 4 deletions

File tree

src/platform/workspace/components/UnifiedPricingTable.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createI18n } from 'vue-i18n'
66

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

1112
interface MockSubscription {
@@ -21,6 +22,7 @@ interface MockTeamStop {
2122
}
2223

2324
const mockSubscription = ref<MockSubscription | null>(null)
25+
const mockSubscriptionStatus = ref<BillingSubscriptionStatus | null>(null)
2426
const mockCurrentPlanSlug = ref<string | null>(null)
2527
const mockCurrentTeamCreditStop = ref<MockTeamStop | null>(null)
2628
const mockTeamFlag = ref(false)
@@ -35,6 +37,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
3537
fetchPlans: vi.fn(),
3638
isTeamPlan: computed(() => mockIsTeamPlan.value),
3739
subscription: computed(() => mockSubscription.value),
40+
subscriptionStatus: computed(() => mockSubscriptionStatus.value),
3841
currentTeamCreditStop: computed(() => mockCurrentTeamCreditStop.value)
3942
})
4043
}))
@@ -83,6 +86,7 @@ function renderComponent(props: Record<string, unknown> = {}) {
8386
describe('UnifiedPricingTable plan CTA labels', () => {
8487
beforeEach(() => {
8588
mockSubscription.value = null
89+
mockSubscriptionStatus.value = null
8690
mockCurrentPlanSlug.value = null
8791
mockCurrentTeamCreditStop.value = null
8892
mockTeamFlag.value = false
@@ -121,6 +125,36 @@ describe('UnifiedPricingTable plan CTA labels', () => {
121125
).toBeTruthy()
122126
})
123127

128+
it('offers a fresh subscribe on the plan an ended subscription used to hold', async () => {
129+
const user = userEvent.setup()
130+
// An ended subscription still reports its tier and plan slug.
131+
mockSubscription.value = {
132+
tier: 'CREATOR',
133+
duration: 'ANNUAL',
134+
isCancelled: false
135+
}
136+
mockSubscriptionStatus.value = 'ended'
137+
138+
const { emitted } = renderComponent()
139+
140+
expect(screen.queryByRole('button', { name: 'Current Plan' })).toBeNull()
141+
expect(screen.queryByRole('button', { name: /^Change to/ })).toBeNull()
142+
143+
const cta = screen.getByRole('button', {
144+
name: 'Subscribe to Creator Yearly'
145+
})
146+
expect(cta).toBeEnabled()
147+
await user.click(cta)
148+
const [payload] = emitted().subscribe![0] as [
149+
{ tierKey: string; billingCycle: string }
150+
]
151+
expect(payload).toMatchObject({
152+
tierKey: 'creator',
153+
billingCycle: 'yearly'
154+
})
155+
expect(emitted().resubscribe).toBeFalsy()
156+
})
157+
124158
it('keeps personal tier cards actionable for the original owner of a team plan', () => {
125159
mockSubscription.value = { tier: 'TEAM', duration: 'ANNUAL' }
126160
mockCurrentTeamCreditStop.value = {
@@ -166,6 +200,7 @@ describe('UnifiedPricingTable team plan CTA', () => {
166200

167201
beforeEach(() => {
168202
mockSubscription.value = null
203+
mockSubscriptionStatus.value = null
169204
mockCurrentPlanSlug.value = null
170205
mockCurrentTeamCreditStop.value = null
171206
mockTeamFlag.value = true
@@ -266,6 +301,26 @@ describe('UnifiedPricingTable team plan CTA', () => {
266301
expect(emitted().resubscribe).toBeFalsy()
267302
})
268303

304+
it('prompts a fresh subscribe for an ended team subscription', async () => {
305+
const user = userEvent.setup()
306+
mockSubscription.value = {
307+
tier: 'TEAM',
308+
duration: 'ANNUAL',
309+
isCancelled: false
310+
}
311+
mockSubscriptionStatus.value = 'ended'
312+
mockCurrentTeamCreditStop.value = TEAM_STOP
313+
314+
const { emitted } = renderComponent({ initialPlanMode: 'team' })
315+
316+
const cta = screen.getByRole('button', { name: 'Subscribe to Team Yearly' })
317+
expect(cta).toBeEnabled()
318+
await user.click(cta)
319+
const [teamPayload] = emitted().subscribeTeam![0] as [{ isChange: boolean }]
320+
expect(teamPayload).toMatchObject({ isChange: false })
321+
expect(emitted().resubscribe).toBeFalsy()
322+
})
323+
269324
it('prompts a fresh subscribe when on no team plan', () => {
270325
renderComponent({ initialPlanMode: 'team' })
271326

src/platform/workspace/components/UnifiedPricingTable.vue

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,7 @@ const {
615615
fetchPlans,
616616
isTeamPlan,
617617
subscription,
618+
subscriptionStatus,
618619
currentTeamCreditStop
619620
} = useBillingContext()
620621
@@ -640,6 +641,10 @@ const { teamCreditStops } = useBillingPlans()
640641
641642
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
642643
644+
// An ended subscription still reports its plan slug and tier, so the plan it
645+
// held must not read as current — it is buyable again.
646+
const isEnded = computed(() => subscriptionStatus.value === 'ended')
647+
643648
const currentBillingCycle = ref<BillingCycle>('yearly')
644649
645650
// Team credit stops: backend-sourced when the API supplies them, otherwise the
@@ -676,7 +681,9 @@ const teamVideoEstimate = computed(() =>
676681
677682
// The team's currently-subscribed stop (null when on no team plan). Matched to
678683
// the slider stops by list price so the current stop can be disabled.
679-
const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
684+
const isTeamSubscribed = computed(
685+
() => currentTeamCreditStop.value !== null && !isEnded.value
686+
)
680687
681688
// `teamUsd` is seeded at mount from the fallback default; when the API stops
682689
// resolve afterwards with different breakpoints that seed can match no stop,
@@ -780,14 +787,17 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
780787
}
781788
782789
const currentTierKey = computed<TierKey | null>(() =>
783-
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
790+
subscription.value?.tier && !isEnded.value
791+
? TIER_TO_KEY[subscription.value.tier]
792+
: null
784793
)
785794
786795
const isYearlySubscription = computed(
787796
() => subscription.value?.duration === 'ANNUAL'
788797
)
789798
790799
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
800+
if (isEnded.value) return false
791801
if (currentPlanSlug.value) {
792802
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
793803
return plan?.slug === currentPlanSlug.value
@@ -881,8 +891,13 @@ function handleSubscribe(tierKey: CheckoutTierKey) {
881891
function handleSubscribeTeam() {
882892
if (isTeamButtonDisabled.value) return
883893
// Re-subscribe only when keeping the exact current plan; any other stop or
884-
// cycle is a change.
885-
if (isCancelled.value && isTeamCurrentPlanSelected.value) {
894+
// cycle is a change. An ended subscription still reports its credit stop, so
895+
// the stop alone is no proof there is a subscription left to resume.
896+
if (
897+
isTeamSubscribed.value &&
898+
isCancelled.value &&
899+
isTeamCurrentPlanSelected.value
900+
) {
886901
emit('resubscribe')
887902
return
888903
}

0 commit comments

Comments
 (0)