Skip to content

Commit 0d7f74b

Browse files
authored
[backport core/1.48] fix(workspace): let ended subscriptions resubscribe to the same plan (#14610)
Manual backport of #14607 — the auto-backport workflow failed. Cherry-pick of `10c56a1` applied cleanly; no changes beyond the original PR. ## Why this is manual The backport run ([30839173649](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/30839173649)) hit a conflict on `core/1.47` only. That failed the whole "Backport commits" step, which skipped "Create PR for each successful backport" for the targets that *did* apply cleanly, and the cleanup step then deleted their branches. Filed as a follow-up on the workflow. ## Original fix After a Stripe-side cancellation, access is revoked but Plans & Pricing still showed the lost plan as a disabled "Current Plan", so the user could not resubscribe to it. An ended subscription still reports its `plan_slug` and `subscription_tier`, and the pricing table only treated a cancel-scheduled plan as re-buyable. `subscription_status: 'ended'` now counts as holding no current plan. ## Verification Unit tests pass on this branch; CI covers the rest.
1 parent c2a749f commit 0d7f74b

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)