Skip to content

Commit 1dc9857

Browse files
comfy-pr-botwei-haiclaude
authored
[backport cloud/1.47] feat(billing): disclose that changing plans on a cancelled subscription resumes it (#14283)
Backport of #14222 to `cloud/1.47` Automatically created by backport workflow. --------- Co-authored-by: Wei Hai <hai.vincent@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a20b874 commit 1dc9857

17 files changed

Lines changed: 2762 additions & 84 deletions

src/locales/en/main.json

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2575,6 +2575,8 @@
25752575
"downgrade": {
25762576
"title": "Change to {plan} plan?",
25772577
"body": "All other members of this workspace will be immediately removed.",
2578+
"bodyReactivation": "Your subscription is cancelled. Changing your plan will resume it, and you'll be charged {amount} today.",
2579+
"bodyRemovalAndReactivation": "All other members of this workspace will be immediately removed. Your subscription is cancelled, so changing your plan will also resume it, and you'll be charged {amount} today.",
25782580
"confirmationPhrase": "I understand",
25792581
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
25802582
"confirm": "Change plan",
@@ -2583,7 +2585,9 @@
25832585
"paymentMethodRequired": "A payment method is required to change plans",
25842586
"paymentPageBlocked": "Couldn't open the payment page — please try again",
25852587
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
2586-
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
2588+
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support",
2589+
"reactivationConfirmationRequired": "Please confirm the reactivation charge before continuing",
2590+
"reactivationAmountChanged": "The charge amount changed since you confirmed it — please review and try again"
25872591
},
25882592
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
25892593
"partnerNodesDescription": "For running commercial/proprietary models",
@@ -2821,7 +2825,21 @@
28212825
"confirm": "Confirm",
28222826
"subscribeToPlan": "Subscribe to {plan}",
28232827
"switchToPlan": "Switch to {plan}",
2824-
"backToAllPlans": "Back to all plans"
2828+
"backToAllPlans": "Back to all plans",
2829+
"reactivation": {
2830+
"title": "Reactivating your subscription",
2831+
"titleAnnual": "Reactivating your subscription — full year billed today",
2832+
"upgradeBody": "Your {plan} was set to end on {date}. Upgrading now reactivates it — you'll be charged {amount} today, and it will renew automatically on {nextDate} instead of ending.",
2833+
"downgradeBody": "Your {plan} was set to end on {date}. Switching to {newPlan} reactivates it — you won't be charged today, but it will now renew automatically on {nextDate} at the new price instead of ending.",
2834+
"durationChangeBody": "Your {plan} was set to end on {date}. Switching to annual billing reactivates it and charges the full year, {amount}, today. It will then renew annually on {nextDate} instead of ending.",
2835+
"durationChangeBodyMonthly": "Your {plan} was set to end on {date}. Switching to monthly billing reactivates it — you'll be charged {amount} today, and it will renew automatically on {nextDate} instead of ending.",
2836+
"confirmButton": "Confirm & reactivate",
2837+
"confirmButtonWithCharge": "Confirm & reactivate — {amount} today",
2838+
"checkboxLabel": "I understand I'll be charged {amount} today",
2839+
"confirmationRequired": "Your subscription is cancelled — please confirm the reactivation charge before continuing",
2840+
"amountChanged": "The charge amount changed since you confirmed it — please review and try again",
2841+
"unavailable": "We can't confirm this reactivation right now — please choose your plan again"
2842+
}
28252843
},
28262844
"success": {
28272845
"allSet": "You're all set",

src/platform/telemetry/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,8 @@ type BillingErrorCode =
640640
| 'missing_checkout_response'
641641
| 'missing_payment_method_url'
642642
| 'payment_popup_blocked'
643+
| 'reactivation_not_confirmed'
644+
| 'reactivation_amount_changed'
643645

644646
export interface BillingFailure {
645647
failure_category: BillingFailureCategory

src/platform/workspace/api/workspaceApi.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,25 @@ describe('workspaceApi', () => {
448448
expect(result).toEqual(data)
449449
})
450450

451+
it('subscribe() sends confirm_reactivation when reactivating a cancelled subscription', async () => {
452+
const data = { billing_op_id: 'op-1c', status: 'subscribed' }
453+
mockAxiosInstance.post.mockResolvedValue({ data })
454+
455+
const result = await workspaceApi.subscribe('pro-monthly', {
456+
confirmReactivation: true
457+
})
458+
459+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
460+
'/api/billing/subscribe',
461+
expect.objectContaining({
462+
plan_slug: 'pro-monthly',
463+
confirm_reactivation: true
464+
}),
465+
{ headers: AUTH_HEADER }
466+
)
467+
expect(result).toEqual(data)
468+
})
469+
451470
it('cancelSubscription() sends POST with idempotency_key', async () => {
452471
const data = { billing_op_id: 'op-2', cancel_at: '2026-05-01' }
453472
mockAxiosInstance.post.mockResolvedValue({ data })

src/platform/workspace/api/workspaceApi.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,16 @@ interface SubscribeRequest {
164164
/** Required for the per-credit Team plan; selects the slider stop. */
165165
team_credit_stop_id?: string
166166
billing_cycle?: SubscribeBillingCycle
167+
/** Required to change plans while the current subscription is cancelled; server rejects the change without it. */
168+
confirm_reactivation?: boolean
167169
}
168170

169171
export interface SubscribeOptions {
170172
returnUrl?: string
171173
cancelUrl?: string
172174
teamCreditStopId?: string
173175
billingCycle?: SubscribeBillingCycle
176+
confirmReactivation?: boolean
174177
}
175178

176179
export interface PreviewSubscribeOptions {
@@ -680,7 +683,8 @@ export const workspaceApi = {
680683
return_url: options.returnUrl,
681684
cancel_url: options.cancelUrl,
682685
team_credit_stop_id: options.teamCreditStopId,
683-
billing_cycle: options.billingCycle
686+
billing_cycle: options.billingCycle,
687+
confirm_reactivation: options.confirmReactivation
684688
} satisfies SubscribeRequest,
685689
{ headers }
686690
)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
2+
3+
import type { SubscriptionInfo } from '@/composables/billing/types'
4+
import { i18n } from '@/i18n'
5+
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
6+
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
7+
8+
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
9+
10+
type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']
11+
12+
/**
13+
* The reactivation-disclosure banner on the single-plan change preview: a
14+
* cancelled-but-not-lapsed subscription resumes silently on any plan change,
15+
* so the banner discloses that plus the exact charge (and, above the current
16+
* plan's monthly total, a consent checkbox gating the confirm button). The
17+
* banner reads `subscription`/`isInitialized` from `useBillingContext`, not
18+
* from `previewData`, so each story drives it through the Storybook stub
19+
* (`setBillingContextMock`) rather than props.
20+
*/
21+
const meta: Meta<typeof SubscriptionTransitionPreviewWorkspace> = {
22+
title: 'Components/SubscriptionTransitionPreviewWorkspace',
23+
component: SubscriptionTransitionPreviewWorkspace,
24+
tags: ['autodocs'],
25+
parameters: { layout: 'centered' },
26+
decorators: [
27+
(story) => ({
28+
components: { story },
29+
template:
30+
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12"><story /></div>'
31+
})
32+
]
33+
}
34+
35+
export default meta
36+
type Story = StoryObj<typeof meta>
37+
38+
const TODAY = '2026-08-01T00:00:00Z'
39+
// The date the pre-existing cancellation was set to lapse; still active until
40+
// then, which is what makes a plan change on this subscription a reactivation.
41+
const CANCEL_DATE = '2026-08-25T00:00:00Z'
42+
const NEXT_MONTHLY_RENEWAL = '2026-09-01T00:00:00Z'
43+
const NEXT_ANNUAL_RENEWAL = '2027-08-01T00:00:00Z'
44+
const NEXT_MONTHLY_AFTER_CANCEL = '2026-09-25T00:00:00Z'
45+
46+
function plan(
47+
tier: PreviewPlanInfo['tier'],
48+
duration: PreviewPlanInfo['duration'],
49+
priceCents: number,
50+
periodEnd: string
51+
): PreviewPlanInfo {
52+
return {
53+
slug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
54+
tier,
55+
duration,
56+
price_cents: priceCents,
57+
credits_cents: 0,
58+
seat_summary: {
59+
seat_count: 1,
60+
total_cost_cents: priceCents,
61+
total_credits_cents: 0
62+
},
63+
period_end: periodEnd
64+
}
65+
}
66+
67+
function cancelledSubscription(
68+
tier: NonNullable<SubscriptionInfo['tier']>,
69+
duration: NonNullable<SubscriptionInfo['duration']> = 'MONTHLY'
70+
): SubscriptionInfo {
71+
return {
72+
isActive: true,
73+
tier,
74+
duration,
75+
planSlug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
76+
renewalDate: null,
77+
endDate: CANCEL_DATE,
78+
isCancelled: true,
79+
hasFunds: true
80+
}
81+
}
82+
83+
const notCancelledSubscription: SubscriptionInfo = {
84+
isActive: true,
85+
tier: 'STANDARD',
86+
duration: 'MONTHLY',
87+
planSlug: 'standard-monthly',
88+
renewalDate: NEXT_MONTHLY_RENEWAL,
89+
endDate: null,
90+
isCancelled: false,
91+
hasFunds: true
92+
}
93+
94+
function story(
95+
previewData: PreviewSubscribeResponse,
96+
subscription: SubscriptionInfo
97+
): Story {
98+
return {
99+
args: { previewData },
100+
beforeEach() {
101+
// Dates render through both a local Intl formatter and vue-i18n's n(),
102+
// so pin the locale rather than inherit the developer's.
103+
i18n.global.locale.value = 'en'
104+
setBillingContextMock({ subscription })
105+
}
106+
}
107+
}
108+
109+
/**
110+
* Ordinary upgrade, subscription not cancelled — the reactivation banner
111+
* must not leak into a normal plan change.
112+
*/
113+
export const NotCancelled: Story = story(
114+
{
115+
allowed: true,
116+
transition_type: 'upgrade',
117+
effective_at: TODAY,
118+
is_immediate: true,
119+
cost_today_cents: 1500,
120+
cost_next_period_cents: 3500,
121+
credits_today_cents: 0,
122+
credits_next_period_cents: 0,
123+
current_plan: plan('STANDARD', 'MONTHLY', 2000, NEXT_MONTHLY_RENEWAL),
124+
new_plan: plan('CREATOR', 'MONTHLY', 3500, NEXT_MONTHLY_RENEWAL)
125+
} satisfies PreviewSubscribeResponse,
126+
notCancelledSubscription
127+
)
128+
129+
/**
130+
* Cancelled, immediate upgrade — exact-cents charge ($54.54), guarding the
131+
* regression where this path rounded the charge shown to the user.
132+
*/
133+
export const ReactivatingUpgrade: Story = story(
134+
{
135+
allowed: true,
136+
transition_type: 'upgrade',
137+
effective_at: TODAY,
138+
is_immediate: true,
139+
cost_today_cents: 5454,
140+
cost_next_period_cents: 10_000,
141+
credits_today_cents: 0,
142+
credits_next_period_cents: 0,
143+
current_plan: plan('CREATOR', 'MONTHLY', 3500, CANCEL_DATE),
144+
new_plan: plan('PRO', 'MONTHLY', 10_000, NEXT_MONTHLY_RENEWAL)
145+
} satisfies PreviewSubscribeResponse,
146+
cancelledSubscription('CREATOR')
147+
)
148+
149+
/**
150+
* Cancelled, scheduled downgrade — $0 today. The most important state: no
151+
* money moves, so the copy alone must make the reactivation unmissable.
152+
*/
153+
export const ReactivatingDowngrade: Story = story(
154+
{
155+
allowed: true,
156+
transition_type: 'downgrade',
157+
effective_at: CANCEL_DATE,
158+
is_immediate: false,
159+
cost_today_cents: 0,
160+
cost_next_period_cents: 3500,
161+
credits_today_cents: 0,
162+
credits_next_period_cents: 0,
163+
current_plan: plan('PRO', 'MONTHLY', 10_000, CANCEL_DATE),
164+
new_plan: plan('CREATOR', 'MONTHLY', 3500, NEXT_MONTHLY_AFTER_CANCEL)
165+
} satisfies PreviewSubscribeResponse,
166+
cancelledSubscription('PRO')
167+
)
168+
169+
/** Cancelled, monthly-to-annual — the full year is billed today. */
170+
export const ReactivatingMonthlyToAnnual: Story = story(
171+
{
172+
allowed: true,
173+
transition_type: 'duration_change',
174+
effective_at: TODAY,
175+
is_immediate: true,
176+
cost_today_cents: 33_600,
177+
cost_next_period_cents: 33_600,
178+
credits_today_cents: 0,
179+
credits_next_period_cents: 0,
180+
current_plan: plan('CREATOR', 'MONTHLY', 3500, CANCEL_DATE),
181+
new_plan: plan('CREATOR', 'ANNUAL', 33_600, NEXT_ANNUAL_RENEWAL)
182+
} satisfies PreviewSubscribeResponse,
183+
cancelledSubscription('CREATOR')
184+
)
185+
186+
/**
187+
* Cancelled, annual-to-monthly — the other cadence direction, which used to
188+
* wrongly show the annual copy and now has its own wording.
189+
*/
190+
export const ReactivatingAnnualToMonthly: Story = story(
191+
{
192+
allowed: true,
193+
transition_type: 'duration_change',
194+
effective_at: TODAY,
195+
is_immediate: true,
196+
cost_today_cents: 1234,
197+
cost_next_period_cents: 3500,
198+
credits_today_cents: 0,
199+
credits_next_period_cents: 0,
200+
current_plan: plan('CREATOR', 'ANNUAL', 33_600, CANCEL_DATE),
201+
new_plan: plan('CREATOR', 'MONTHLY', 3500, NEXT_MONTHLY_RENEWAL)
202+
} satisfies PreviewSubscribeResponse,
203+
cancelledSubscription('CREATOR', 'ANNUAL')
204+
)
205+
206+
/**
207+
* Cancelled, charge above the current plan's monthly total — the consent
208+
* checkbox renders and the confirm button stays disabled until it's ticked.
209+
*/
210+
export const ReactivatingAboveThreshold: Story = story(
211+
{
212+
allowed: true,
213+
transition_type: 'upgrade',
214+
effective_at: TODAY,
215+
is_immediate: true,
216+
cost_today_cents: 8000,
217+
cost_next_period_cents: 10_000,
218+
credits_today_cents: 0,
219+
credits_next_period_cents: 0,
220+
current_plan: plan('STANDARD', 'MONTHLY', 2000, CANCEL_DATE),
221+
new_plan: plan('PRO', 'MONTHLY', 10_000, NEXT_MONTHLY_RENEWAL)
222+
} satisfies PreviewSubscribeResponse,
223+
cancelledSubscription('STANDARD')
224+
)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ vi.mock('vue-i18n', () => ({
1616
})
1717
}))
1818

19+
// Not cancelled: keeps the reactivation banner out of these baseline scenarios.
20+
vi.mock('@/composables/billing/useBillingContext', () => ({
21+
useBillingContext: () => ({
22+
subscription: { value: { isCancelled: false, endDate: null } },
23+
isInitialized: { value: true }
24+
})
25+
}))
26+
1927
const globalOptions = {
2028
mocks: { $t: (key: string) => key },
2129
stubs: {

0 commit comments

Comments
 (0)