Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
20 changes: 18 additions & 2 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2637,6 +2637,8 @@
"downgrade": {
"title": "Change to {plan} plan?",
"body": "All other members of this workspace will be immediately removed.",
"bodyReactivation": "Your subscription is cancelled. Changing your plan will resume it, and you'll be charged {amount} today.",
"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.",
"confirmationPhrase": "I understand",
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
"confirm": "Change plan",
Expand All @@ -2645,7 +2647,9 @@
"paymentMethodRequired": "A payment method is required to change plans",
"paymentPageBlocked": "Couldn't open the payment page — please try again",
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support",
"reactivationConfirmationRequired": "Please confirm the reactivation charge before continuing",
"reactivationAmountChanged": "The charge amount changed since you confirmed it — please review and try again"
},
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
Expand Down Expand Up @@ -2898,7 +2902,19 @@
"confirm": "Confirm",
"subscribeToPlan": "Subscribe to {plan}",
"switchToPlan": "Switch to {plan}",
"backToAllPlans": "Back to all plans"
"backToAllPlans": "Back to all plans",
"reactivation": {
"title": "Reactivating your subscription",
"titleAnnual": "Reactivating your subscription — full year billed today",
"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.",
"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.",
"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.",
"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.",
"confirmButton": "Confirm & reactivate",
"confirmButtonWithCharge": "Confirm & reactivate — {amount} today",
"checkboxLabel": "I understand I'll be charged {amount} today",
"confirmationRequired": "Your subscription is cancelled — please confirm the reactivation charge before continuing"
}
},
"success": {
"allSet": "You're all set",
Expand Down
2 changes: 2 additions & 0 deletions src/platform/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,8 @@ type BillingErrorCode =
| 'missing_checkout_response'
| 'missing_payment_method_url'
| 'payment_popup_blocked'
| 'reactivation_not_confirmed'
| 'reactivation_amount_changed'

export interface BillingFailure {
failure_category: BillingFailureCategory
Expand Down
19 changes: 19 additions & 0 deletions src/platform/workspace/api/workspaceApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,25 @@ describe('workspaceApi', () => {
expect(result).toEqual(data)
})

it('subscribe() sends confirm_reactivation when reactivating a cancelled subscription', async () => {
const data = { billing_op_id: 'op-1c', status: 'subscribed' }
mockAxiosInstance.post.mockResolvedValue({ data })

const result = await workspaceApi.subscribe('pro-monthly', {
confirmReactivation: true
})

expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/subscribe',
expect.objectContaining({
plan_slug: 'pro-monthly',
confirm_reactivation: true
}),
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)
})

it('cancelSubscription() sends POST with idempotency_key', async () => {
const data = { billing_op_id: 'op-2', cancel_at: '2026-05-01' }
mockAxiosInstance.post.mockResolvedValue({ data })
Expand Down
6 changes: 5 additions & 1 deletion src/platform/workspace/api/workspaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,16 @@ interface SubscribeRequest {
/** Required for the per-credit Team plan; selects the slider stop. */
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
/** Required to change plans while the current subscription is cancelled; server rejects the change without it. */
confirm_reactivation?: boolean
}

export interface SubscribeOptions {
returnUrl?: string
cancelUrl?: string
teamCreditStopId?: string
billingCycle?: SubscribeBillingCycle
confirmReactivation?: boolean
}

export interface PreviewSubscribeOptions {
Expand Down Expand Up @@ -687,7 +690,8 @@ export const workspaceApi = {
return_url: options.returnUrl,
cancel_url: options.cancelUrl,
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
billing_cycle: options.billingCycle,
confirm_reactivation: options.confirmReactivation
} satisfies SubscribeRequest,
{ headers }
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import type { SubscriptionInfo } from '@/composables/billing/types'
import { i18n } from '@/i18n'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'

import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'

type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']

/**
* The reactivation-disclosure banner on the single-plan change preview: a
* cancelled-but-not-lapsed subscription resumes silently on any plan change,
* so the banner discloses that plus the exact charge (and, above the current
* plan's monthly total, a consent checkbox gating the confirm button). The
* banner reads `subscription`/`isInitialized` from `useBillingContext`, not
* from `previewData`, so each story drives it through the Storybook stub
* (`setBillingContextMock`) rather than props.
*/
const meta: Meta<typeof SubscriptionTransitionPreviewWorkspace> = {
title: 'Components/SubscriptionTransitionPreviewWorkspace',
component: SubscriptionTransitionPreviewWorkspace,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12"><story /></div>'
})
]
}

export default meta
type Story = StoryObj<typeof meta>

const TODAY = '2026-08-01T00:00:00Z'
// The date the pre-existing cancellation was set to lapse; still active until
// then, which is what makes a plan change on this subscription a reactivation.
const CANCEL_DATE = '2026-08-25T00:00:00Z'
const NEXT_MONTHLY_RENEWAL = '2026-09-01T00:00:00Z'
const NEXT_ANNUAL_RENEWAL = '2027-08-01T00:00:00Z'
const NEXT_MONTHLY_AFTER_CANCEL = '2026-09-25T00:00:00Z'

function plan(
tier: PreviewPlanInfo['tier'],
duration: PreviewPlanInfo['duration'],
priceCents: number,
periodEnd: string
): PreviewPlanInfo {
return {
slug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
tier,
duration,
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
seat_count: 1,
total_cost_cents: priceCents,
total_credits_cents: 0
},
period_end: periodEnd
}
}

function cancelledSubscription(
tier: NonNullable<SubscriptionInfo['tier']>
): SubscriptionInfo {
return {
isActive: true,
tier,
duration: 'MONTHLY',
planSlug: `${tier.toLowerCase()}-monthly`,
renewalDate: null,
endDate: CANCEL_DATE,
isCancelled: true,
hasFunds: true
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const notCancelledSubscription: SubscriptionInfo = {
isActive: true,
tier: 'STANDARD',
duration: 'MONTHLY',
planSlug: 'standard-monthly',
renewalDate: NEXT_MONTHLY_RENEWAL,
endDate: null,
isCancelled: false,
hasFunds: true
}

function story(
previewData: PreviewSubscribeResponse,
subscription: SubscriptionInfo
): Story {
return {
args: { previewData },
beforeEach() {
// Dates render through both a local Intl formatter and vue-i18n's n(),
// so pin the locale rather than inherit the developer's.
i18n.global.locale.value = 'en'
setBillingContextMock({ subscription })
}
}
}

/**
* Ordinary upgrade, subscription not cancelled — the reactivation banner
* must not leak into a normal plan change.
*/
export const NotCancelled: Story = story(
{
allowed: true,
transition_type: 'upgrade',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 1500,
cost_next_period_cents: 3500,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('STANDARD', 'MONTHLY', 2000, NEXT_MONTHLY_RENEWAL),
new_plan: plan('CREATOR', 'MONTHLY', 3500, NEXT_MONTHLY_RENEWAL)
} satisfies PreviewSubscribeResponse,
notCancelledSubscription
)

/**
* Cancelled, immediate upgrade — exact-cents charge ($54.54), guarding the
* regression where this path rounded the charge shown to the user.
*/
export const ReactivatingUpgrade: Story = story(
{
allowed: true,
transition_type: 'upgrade',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 5454,
cost_next_period_cents: 10_000,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('CREATOR', 'MONTHLY', 3500, CANCEL_DATE),
new_plan: plan('PRO', 'MONTHLY', 10_000, NEXT_MONTHLY_RENEWAL)
} satisfies PreviewSubscribeResponse,
cancelledSubscription('CREATOR')
)

/**
* Cancelled, scheduled downgrade — $0 today. The most important state: no
* money moves, so the copy alone must make the reactivation unmissable.
*/
export const ReactivatingDowngrade: Story = story(
{
allowed: true,
transition_type: 'downgrade',
effective_at: CANCEL_DATE,
is_immediate: false,
cost_today_cents: 0,
cost_next_period_cents: 3500,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('PRO', 'MONTHLY', 10_000, CANCEL_DATE),
new_plan: plan('CREATOR', 'MONTHLY', 3500, NEXT_MONTHLY_AFTER_CANCEL)
} satisfies PreviewSubscribeResponse,
cancelledSubscription('PRO')
)

/** Cancelled, monthly-to-annual — the full year is billed today. */
export const ReactivatingMonthlyToAnnual: Story = story(
{
allowed: true,
transition_type: 'duration_change',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 33_600,
cost_next_period_cents: 33_600,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('CREATOR', 'MONTHLY', 3500, CANCEL_DATE),
new_plan: plan('CREATOR', 'ANNUAL', 33_600, NEXT_ANNUAL_RENEWAL)
} satisfies PreviewSubscribeResponse,
cancelledSubscription('CREATOR')
)

/**
* Cancelled, annual-to-monthly — the other cadence direction, which used to
* wrongly show the annual copy and now has its own wording.
*/
export const ReactivatingAnnualToMonthly: Story = story(
{
allowed: true,
transition_type: 'duration_change',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 1234,
cost_next_period_cents: 3500,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('CREATOR', 'ANNUAL', 33_600, CANCEL_DATE),
new_plan: plan('CREATOR', 'MONTHLY', 3500, NEXT_MONTHLY_RENEWAL)
} satisfies PreviewSubscribeResponse,
cancelledSubscription('CREATOR')
)

/**
* Cancelled, charge above the current plan's monthly total — the consent
* checkbox renders and the confirm button stays disabled until it's ticked.
*/
export const ReactivatingAboveThreshold: Story = story(
{
allowed: true,
transition_type: 'upgrade',
effective_at: TODAY,
is_immediate: true,
cost_today_cents: 8000,
cost_next_period_cents: 10_000,
credits_today_cents: 0,
credits_next_period_cents: 0,
current_plan: plan('STANDARD', 'MONTHLY', 2000, CANCEL_DATE),
new_plan: plan('PRO', 'MONTHLY', 10_000, NEXT_MONTHLY_RENEWAL)
} satisfies PreviewSubscribeResponse,
cancelledSubscription('STANDARD')
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ vi.mock('vue-i18n', () => ({
})
}))

// Not cancelled: keeps the reactivation banner out of these baseline scenarios.
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
subscription: { value: { isCancelled: false, endDate: null } },
isInitialized: { value: true }
})
}))

const globalOptions = {
mocks: { $t: (key: string) => key },
stubs: {
Expand Down
Loading
Loading