Compare commits

...

9 Commits

Author SHA1 Message Date
pythongosssss
0d16721eb9 wip 2026-06-25 10:09:18 -07:00
pythongosssss
8957d61a32 test(churnkey): add e2e coverage for cancellation flow routing
- Cover flag-disabled, auth-404 fallback, and embed-launch routing in a
  @cloud Playwright spec with a stubbed window.churnkey
- Add __CHURNKEY_APP_ID_OVERRIDE__ window hook so built bundles can be
  configured per test (the app ID is otherwise a compile-time define)
- Add FeatureFlagHelper.setServerFeatures for production builds where
  localStorage flag overrides are dead-code-eliminated
- Route CancelSubscriptionDialog.open through launchCancellationFlow and
  allow skipping the visibility wait when Churnkey handles the flow
- Move window.churnkey declaration into churnkey/types.ts and type init
  against ChurnkeyInitConfig
- Document auth-404 fallback and failure_reason telemetry semantics
2026-06-12 13:46:38 -07:00
pythongosssss
2f652aab92 wip: churnkey impl
- handle legacy vs workspace, via cancel in Stripe
- move telemetry
- show cancelled date (fix? to check)
2026-06-12 13:16:01 -07:00
pythongosssss
10b89ee889 merge: origin/main into pysssss/churnkey 2026-06-12 13:09:34 -07:00
Glary-Bot
4f33013411 style(churnkey): scope disclaimer + use focus-visible for inputs
- Scope '.active-discount-disclaimer' to '#ck-app' so it can't leak
  into non-Churnkey UI that happens to use the same class name.
- Switch the embed input/select/textarea focus rules to ':focus-visible'
  with a higher-contrast ring, so keyboard users get a clear focus
  indicator instead of the dropped outline.
2026-05-14 19:22:04 +00:00
pythongosssss
78421d9990 wip: update churnkey cancellation flow
- add styling to churnkey ui elements
- handle missing 404 endpoint
- extract dialog display to platform layer to prevent circular dep
- update env var
2026-05-14 12:11:04 -07:00
Glary-Bot
b02d2fea85 test(billing): tighten Churnkey routing test cleanup
- Move console.warn restore into afterEach so it survives assertion
  failures, and assert the warning carries VITE_CHURNKEY_APP_ID rather
  than just any string.
- Drop the redundant import.meta.env restore in useChurnkey.test.ts;
  vi.unstubAllEnvs() already handles it.
- Flatten launchCancellationFlow with early returns to reduce nesting.
2026-05-09 02:52:46 +00:00
Glary-Bot
8fb5f49505 fix(billing): address Churnkey review feedback
- Fall back to the legacy cancellation dialog when the feature flag is
  enabled but VITE_CHURNKEY_APP_ID is not set, so a partially configured
  rollout never breaks cancellation.
- Emit cancellation_flow_closed exactly once per session (in onClose),
  carrying the survey_response captured in onCancel.
- Move the canceled/survey state into the launcher closure to avoid
  leaking module-level state across concurrent sessions.
- Stop rethrowing after the user-facing toast is shown; the only caller
  uses void launchCancellationFlow(...) and would otherwise produce an
  unhandled rejection on top of the toast.
- Add tests covering: routing fallback when not configured, single
  flow_closed emission on cancel, reconsidered tracking on plain close,
  and toast on embed failure.
2026-05-08 23:31:08 +00:00
Glary-Bot
218b3cb260 feat(billing): add Churnkey cancellation flow integration
Replaces the simple cancel-subscription confirmation dialog with the
Churnkey-hosted cancel flow when the churnkey_cancellation_enabled
feature flag is on, so we can collect survey data and run cancellation
A/B tests through the Churnkey dashboard.

- Vite plugin (cloud-only) injects the Churnkey embed loader using
  VITE_CHURNKEY_APP_ID; non-cloud builds and missing env var no-op.
- useChurnkey composable wraps window.churnkey.init; launcher fetches
  HMAC credentials from /api/billing/churnkey/auth and delegates
  cancellation to the existing /api/billing/subscription/cancel via
  handleCancel so the backend stays the source of truth.
- Feature-flag gating via useFeatureFlags + remoteConfig falls back to
  the legacy dialog whenever the flag is off, Churnkey is unconfigured,
  or the embed script fails to load.
- New PostHog events: cancellation_flow_opened, cancellation_flow_closed
  (with outcome), and cancellation_reconsidered (sets a user-level
  property for downstream cohort analysis).
2026-05-08 23:13:03 +00:00
29 changed files with 2153 additions and 57 deletions

View File

@@ -41,6 +41,14 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Churnkey cancellation flow App ID (cloud distribution only).
# Injected into the bundle at build time as `__CHURNKEY_APP_ID__`.
# Find it in the Churnkey dashboard: Settings > Organization > Cancel Flow API Keys.
# The embed script is loaded on demand when the cancellation flow launches.
# When unset, the legacy cancellation flow is used regardless of feature
# flag state.
# CHURNKEY_APP_ID=
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org

View File

@@ -21,12 +21,18 @@ export class CancelSubscriptionDialog extends BaseDialog {
})
}
async open(cancelAt?: string) {
/** Launches the cancellation flow without waiting for the legacy dialog
* (e.g. when the Churnkey embed is expected to handle it instead). */
async launch(cancelAt?: string) {
await this.page.evaluate((date) => {
void (
window.app!.extensionManager as WorkspaceStore
).dialog.showCancelSubscriptionDialog(date)
).dialog.launchCancellationFlow(date)
}, cancelAt)
}
async open(cancelAt?: string) {
await this.launch(cancelAt)
await this.waitForVisible()
}
}

View File

@@ -1,5 +1,7 @@
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
export class FeatureFlagHelper {
private featuresRouteHandler: ((route: Route) => void) | null = null
@@ -51,6 +53,68 @@ export class FeatureFlagHelper {
})
}
/**
* Set server feature flags at runtime by mutating the reactive
* `api.serverFeatureFlags` ref. Use this when `setFlags()` (localStorage)
* won't work — namely in production builds, where the dev-override
* reader is gated on `import.meta.env.DEV` and dead-code-eliminated.
*
* Note: server features are the LOWEST-priority flag source. If the
* backend's remote config (`/api/features`) defines the same key, the
* remote-config value wins — use `overrideFlags()` to control flags
* deterministically regardless of what the backend serves.
*/
async setServerFeatures(features: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, features)
}
/**
* Deterministically override flags resolved via `useFeatureFlags()` in
* production cloud builds, where dev overrides (the highest-priority
* source) are compiled out. Covers both remaining sources:
*
* 1. Remote config — mutates the live config object in place
* (`window.__CONFIG__` is the same object held by the `remoteConfig`
* ref, whose consumers read keys lazily on access) and intercepts
* `/api/features` so any later refresh (auth change, 10-minute poll)
* re-applies the overrides instead of clobbering them.
* 2. Server features — mutates `api.serverFeatureFlags` as a fallback
* for environments where remote config never loaded.
*/
async overrideFlags(features: Record<string, unknown>): Promise<void> {
await this.page.route('**/api/features', async (route) => {
const response = await route.fetch()
let config: RemoteConfig = {}
try {
config = (await response.json()) as RemoteConfig
} catch {
// Non-JSON response (e.g. backend without the endpoint); serve
// just the overrides.
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...config, ...features })
})
})
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const config = (window as { __CONFIG__?: Record<string, unknown> })
.__CONFIG__
if (config) Object.assign(config, flagMap)
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, features)
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -0,0 +1,144 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
import type { ChurnkeyInitConfig } from '@/platform/cloud/churnkey/types'
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
const CANCEL_AT = '2026-12-31T12:00:00Z'
const STUB_APP_ID = 'e2e-stub'
const VALID_AUTH_RESPONSE = {
customer_id: 'cus_e2e_test',
auth_hash: 'fake-hmac',
mode: 'test'
} satisfies ChurnkeyAuthResponse
// The production router's catch-all body for undeployed routes (verified
// against cloud.comfy.org) — what the frontend sees until the backend
// ships the endpoint.
const NOT_DEPLOYED_RESPONSE = {
error: { message: 'Not Found', type: 'not_found' }
}
interface ChurnkeyInitCall {
action: string
config: ChurnkeyInitConfig
}
interface ChurnkeyStubWindow extends Window {
__churnkeyCalls?: ChurnkeyInitCall[]
__CHURNKEY_APP_ID_OVERRIDE__?: string
}
async function stubChurnkey(page: Page): Promise<void> {
await page.evaluate((appId) => {
const w = window as ChurnkeyStubWindow
w.__CHURNKEY_APP_ID_OVERRIDE__ = appId
w.__churnkeyCalls = []
// Defining `init` up front also makes the client skip injecting the
// real embed script.
w.churnkey = {
created: true,
init: (action, config) => {
w.__churnkeyCalls!.push({ action, config })
},
clearState: () => {}
}
}, STUB_APP_ID)
}
const AUTH_ROUTE_GLOB = '**/api/billing/churnkey/auth'
async function mockAuthEndpoint(
page: Page,
fulfill:
| { status: 200; body: ChurnkeyAuthResponse }
| { status: 404; body: typeof NOT_DEPLOYED_RESPONSE }
): Promise<void> {
await page.route(AUTH_ROUTE_GLOB, (route) =>
route.fulfill({
status: fulfill.status,
contentType: 'application/json',
body: JSON.stringify(fulfill.body)
})
)
}
async function getChurnkeyInitCalls(page: Page): Promise<ChurnkeyInitCall[]> {
return page.evaluate(
() => (window as ChurnkeyStubWindow).__churnkeyCalls ?? []
)
}
test.describe('Cancellation flow routing', { tag: '@cloud' }, () => {
let dialog: CancelSubscriptionDialog
test.beforeEach(async ({ comfyPage }) => {
dialog = new CancelSubscriptionDialog(comfyPage.page)
})
test.describe('flag disabled', () => {
test('routes to the legacy cancel dialog', async ({ comfyPage }) => {
await comfyPage.featureFlags.overrideFlags({
churnkey_cancellation_enabled: false
})
await dialog.open(CANCEL_AT)
await expect(dialog.heading).toBeVisible()
await expect(dialog.root).toContainText('December 31, 2026')
})
})
test.describe('flag enabled', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.overrideFlags({
churnkey_cancellation_enabled: true
})
await stubChurnkey(comfyPage.page)
})
test('routes to the legacy dialog when auth endpoint 404s', async ({
comfyPage
}) => {
await mockAuthEndpoint(comfyPage.page, {
status: 404,
body: NOT_DEPLOYED_RESPONSE
})
await dialog.open(CANCEL_AT)
await expect(dialog.heading).toBeVisible()
expect(await getChurnkeyInitCalls(comfyPage.page)).toEqual([])
})
test('launches the Churnkey embed when auth returns valid credentials', async ({
comfyPage
}) => {
await mockAuthEndpoint(comfyPage.page, {
status: 200,
body: VALID_AUTH_RESPONSE
})
await dialog.launch(CANCEL_AT)
await expect
.poll(() => getChurnkeyInitCalls(comfyPage.page).then((c) => c.length))
.toBeGreaterThan(0)
const [firstCall] = await getChurnkeyInitCalls(comfyPage.page)
expect(firstCall.action).toBe('show')
expect(firstCall.config).toMatchObject({
authHash: 'fake-hmac',
customerId: 'cus_e2e_test',
mode: 'test',
provider: 'stripe'
})
await expect(dialog.root).toBeHidden()
})
})
})

1
global.d.ts vendored
View File

@@ -4,6 +4,7 @@ declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __CHURNKEY_APP_ID__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {

View File

@@ -28,6 +28,7 @@ export enum ServerFeatureFlag {
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
CHURNKEY_CANCELLATION_ENABLED = 'churnkey_cancellation_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
}
@@ -161,6 +162,14 @@ export function useFeatureFlags() {
false
)
},
get churnkeyCancellationEnabled() {
if (!isCloud) return false
return resolveFlag(
ServerFeatureFlag.CHURNKEY_CANCELLATION_ENABLED,
remoteConfig.value.churnkey_cancellation_enabled,
false
)
},
get showSignInButton(): boolean | undefined {
return api.getServerFeature<boolean | undefined>(
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,

View File

@@ -2439,7 +2439,7 @@
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
},
"cancelSuccess": "Subscription cancelled successfully",
"cancelSuccess": "Subscription canceled successfully",
"cancelDialog": {
"title": "Cancel subscription",
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
@@ -2458,6 +2458,7 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsIncluded": "Included",
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",

View File

@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
import type { ChurnkeyWindow } from './types'
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getChurnkeyAuth: vi.fn()
}
}))
const { workspaceApi } = await import('@/platform/workspace/api/workspaceApi')
const { isChurnkeyConfigured, prepareChurnkey } =
await import('./churnkeyClient')
const getChurnkeyAuth = vi.mocked(workspaceApi.getChurnkeyAuth)
type ChurnkeyInit = NonNullable<ChurnkeyWindow['init']>
type GlobalWithChurnkey = typeof globalThis & {
__CHURNKEY_APP_ID__: string
}
const globalWithChurnkey = globalThis as GlobalWithChurnkey
const windowWithOverride = window as { __CHURNKEY_APP_ID_OVERRIDE__?: string }
const AUTH_RESPONSE = {
customer_id: 'cus_123',
auth_hash: 'hash_abc',
mode: 'test'
} as const
describe('churnkeyClient', () => {
let originalAppId: string
beforeEach(() => {
originalAppId = globalWithChurnkey.__CHURNKEY_APP_ID__
globalWithChurnkey.__CHURNKEY_APP_ID__ = 'app-test-123'
getChurnkeyAuth.mockReset()
})
afterEach(() => {
globalWithChurnkey.__CHURNKEY_APP_ID__ = originalAppId
delete windowWithOverride.__CHURNKEY_APP_ID_OVERRIDE__
delete window.churnkey
vi.restoreAllMocks()
})
it('reports isConfigured=false when CHURNKEY_APP_ID is unset', () => {
globalWithChurnkey.__CHURNKEY_APP_ID__ = ''
expect(isChurnkeyConfigured()).toBe(false)
})
it('uses the window app ID override when set, falling back to __CHURNKEY_APP_ID__', () => {
globalWithChurnkey.__CHURNKEY_APP_ID__ = ''
windowWithOverride.__CHURNKEY_APP_ID_OVERRIDE__ = 'override-id'
expect(isChurnkeyConfigured()).toBe(true)
delete windowWithOverride.__CHURNKEY_APP_ID_OVERRIDE__
globalWithChurnkey.__CHURNKEY_APP_ID__ = 'build-id'
expect(isChurnkeyConfigured()).toBe(true)
})
it('rejects when CHURNKEY_APP_ID is unset', async () => {
globalWithChurnkey.__CHURNKEY_APP_ID__ = ''
await expect(prepareChurnkey()).rejects.toThrow(
'Churnkey is not configured'
)
})
it('rejects with ChurnkeyAuthUnavailableError when getChurnkeyAuth returns null', async () => {
window.churnkey = { init: vi.fn<ChurnkeyInit>() }
getChurnkeyAuth.mockResolvedValue(null)
await expect(prepareChurnkey()).rejects.toBeInstanceOf(
ChurnkeyAuthUnavailableError
)
})
it('uses the dev auth override instead of the backend endpoint when set', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
const windowWithAuth = window as {
__CHURNKEY_AUTH_OVERRIDE__?: ChurnkeyAuthResponse
}
windowWithAuth.__CHURNKEY_AUTH_OVERRIDE__ = {
customer_id: 'cus_dev',
auth_hash: 'dev-hash',
mode: 'sandbox'
}
try {
const session = await prepareChurnkey()
void session.show({})
expect(getChurnkeyAuth).not.toHaveBeenCalled()
expect(init.mock.calls[0][1]).toMatchObject({
customerId: 'cus_dev',
authHash: 'dev-hash',
mode: 'sandbox'
})
} finally {
delete windowWithAuth.__CHURNKEY_AUTH_OVERRIDE__
}
})
it('forwards customer credentials and provider config to churnkey.init', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const onCancel = vi.fn()
const session = await prepareChurnkey()
const shown = session.show({
onCancel,
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
})
expect(init).toHaveBeenCalledTimes(1)
const [action, config] = init.mock.calls[0]
expect(action).toBe('show')
expect(config).toMatchObject({
appId: 'app-test-123',
authHash: 'hash_abc',
customerId: 'cus_123',
provider: 'stripe',
mode: 'test',
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
})
// No handleCancel - Churnkey handles the Stripe cancellation itself.
expect(config.handleCancel).toBeUndefined()
config.onCancel?.('cus_123', 'too_expensive')
expect(onCancel).toHaveBeenCalledWith('too_expensive')
config.onClose?.({ status: 'closed' })
await expect(shown).resolves.toEqual({ status: 'closed' })
})
it('adapts handleCancel to drop the customer argument', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const handleCancel = vi.fn(async () => ({ message: 'ok' }))
const session = await prepareChurnkey()
void session.show({ handleCancel })
const [, config] = init.mock.calls[0]
await config.handleCancel?.('cus_123', 'too_expensive', 'feedback')
expect(handleCancel).toHaveBeenCalledWith('too_expensive', 'feedback')
})
it('clears Churnkey session state when the modal closes', async () => {
const init = vi.fn<ChurnkeyInit>()
const clearState = vi.fn()
window.churnkey = { init, clearState }
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const session = await prepareChurnkey()
const shown = session.show({})
init.mock.calls[0][1].onClose?.({ status: 'closed' })
await shown
expect(clearState).toHaveBeenCalledTimes(1)
})
it('rejects show() when churnkey.init throws', async () => {
window.churnkey = {
init: vi.fn<ChurnkeyInit>(() => {
throw new Error('init exploded')
})
}
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const session = await prepareChurnkey()
await expect(session.show({})).rejects.toThrow('init exploded')
})
it('window app ID override wins over __CHURNKEY_APP_ID__ in init config', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
globalWithChurnkey.__CHURNKEY_APP_ID__ = 'build-id'
windowWithOverride.__CHURNKEY_APP_ID_OVERRIDE__ = 'override-id'
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const session = await prepareChurnkey()
void session.show({})
expect(init.mock.calls[0][1]).toMatchObject({ appId: 'override-id' })
})
describe('embed script loading', () => {
function interceptInjectedScripts(): HTMLScriptElement[] {
const scripts: HTMLScriptElement[] = []
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
scripts.push(...(nodes as HTMLScriptElement[]))
})
return scripts
}
it('rejects with ChurnkeyEmbedLoadError when the script fails to load', async () => {
const scripts = interceptInjectedScripts()
const prepare = prepareChurnkey()
expect(scripts).toHaveLength(1)
scripts[0].onerror?.(new Event('error'))
await expect(prepare).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
expect(getChurnkeyAuth).not.toHaveBeenCalled()
})
it('retries the script load on the next launch after a failure', async () => {
const scripts = interceptInjectedScripts()
const first = prepareChurnkey()
scripts[0].onerror?.(new Event('error'))
await expect(first).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
const second = prepareChurnkey()
expect(scripts).toHaveLength(2)
scripts[1].onerror?.(new Event('error'))
await expect(second).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
})
it('rejects with ChurnkeyEmbedLoadError when the script loads without defining init', async () => {
const scripts = interceptInjectedScripts()
const prepare = prepareChurnkey()
scripts[0].onload?.call(scripts[0], new Event('load'))
await expect(prepare).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
})
it('proceeds to auth once the loaded script provides init', async () => {
const scripts = interceptInjectedScripts()
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const prepare = prepareChurnkey()
expect(scripts[0].src).toContain('appId=app-test-123')
window.churnkey!.init = vi.fn<ChurnkeyInit>()
scripts[0].onload?.call(scripts[0], new Event('load'))
const session = await prepare
expect(getChurnkeyAuth).toHaveBeenCalledTimes(1)
expect(session.show).toBeTypeOf('function')
})
})
})

View File

@@ -0,0 +1,143 @@
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import './embed-theme.css'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
import type {
ChurnkeyHandlerResult,
ChurnkeyInitConfig,
ChurnkeySessionResults
} from './types'
const EMBED_SCRIPT_URL = 'https://assets.churnkey.co/js/app.js'
function readAppId(): string {
// E2e hook: `__CHURNKEY_APP_ID__` is a compile-time define, so a built
// bundle can't be reconfigured per test. Playwright sets this global to
// exercise the Churnkey routing without a build-time app ID.
const override = (window as { __CHURNKEY_APP_ID_OVERRIDE__?: string })
.__CHURNKEY_APP_ID_OVERRIDE__
return override || __CHURNKEY_APP_ID__
}
function readAuthOverride(): ChurnkeyAuthResponse | null {
// Dev-only manual-testing hook: set `window.__CHURNKEY_AUTH_OVERRIDE__` to
// exercise the embed before the backend `/billing/churnkey/auth` endpoint
// is deployed. Unlike the app-id override (used by e2e against a built
// bundle), this forges credentials, so it is gated to dev and stripped
// from production builds via import.meta.env.DEV tree-shaking.
if (!import.meta.env.DEV) return null
return (
(window as { __CHURNKEY_AUTH_OVERRIDE__?: ChurnkeyAuthResponse })
.__CHURNKEY_AUTH_OVERRIDE__ ?? null
)
}
export function isChurnkeyConfigured(): boolean {
return !!readAppId()
}
let embedScriptPromise: Promise<void> | null = null
function injectEmbedScript(appId: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
window.churnkey ??= { created: true }
const script = document.createElement('script')
script.src = `${EMBED_SCRIPT_URL}?appId=${encodeURIComponent(appId)}`
script.async = true
script.onload = () => {
if (window.churnkey?.init) resolve()
else reject(new ChurnkeyEmbedLoadError())
}
script.onerror = () => {
script.remove()
reject(new ChurnkeyEmbedLoadError())
}
document.head.append(script)
})
}
function loadEmbedScript(appId: string): Promise<void> {
if (window.churnkey?.init) return Promise.resolve()
embedScriptPromise ??= injectEmbedScript(appId).catch((err: unknown) => {
// Clear the cached attempt so the next launch can retry the load.
embedScriptPromise = null
throw err
})
return embedScriptPromise
}
interface ChurnkeyShowOptions {
handleCancel?: (
surveyResponse: string,
freeformFeedback?: string
) => Promise<ChurnkeyHandlerResult>
onCancel?: (surveyResponse: string) => void
customerAttributes?: Record<string, string | number>
}
export interface ChurnkeySession {
/**
* Opens the Churnkey modal. Resolves with the session results when the
* modal closes; rejects only if `churnkey.init` itself throws.
*/
show: (options: ChurnkeyShowOptions) => Promise<ChurnkeySessionResults>
}
/**
* Loads the Churnkey embed script (on demand, cached) and fetches signed
* auth credentials. Throws {@link ChurnkeyEmbedLoadError} or
* {@link ChurnkeyAuthUnavailableError} so callers can fall back to the
* legacy cancel dialog before any cancellation-funnel telemetry fires.
*/
export async function prepareChurnkey(): Promise<ChurnkeySession> {
const appId = readAppId()
if (!appId) {
throw new Error('Churnkey is not configured (missing CHURNKEY_APP_ID)')
}
await loadEmbedScript(appId)
const init = window.churnkey?.init
if (!init) throw new ChurnkeyEmbedLoadError()
const override = readAuthOverride()
const auth = override ?? (await workspaceApi.getChurnkeyAuth())
if (auth === null) {
throw new ChurnkeyAuthUnavailableError()
}
// Arrow assignment (not a hoisted declaration) so the narrowing of
// `init` and `auth` above carries into the closure.
const show = (options: ChurnkeyShowOptions) =>
new Promise<ChurnkeySessionResults>((resolve, reject) => {
const config: ChurnkeyInitConfig = {
appId,
authHash: auth.auth_hash,
customerId: auth.customer_id,
provider: 'stripe',
mode: auth.mode,
record: true,
customerAttributes: options.customerAttributes,
onCancel: (_customer, surveyResponse) =>
options.onCancel?.(surveyResponse),
onClose: (results) => {
// Reset Churnkey's cached session state so the next launch
// restarts at step 1 (e.g. user visited Stripe but did not cancel).
window.churnkey?.clearState?.()
resolve(results)
}
}
if (options.handleCancel) {
const userHandleCancel = options.handleCancel
config.handleCancel = (_customer, surveyResponse, freeformFeedback) =>
userHandleCancel(surveyResponse, freeformFeedback)
}
try {
init('show', config)
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)))
}
})
return { show }
}

View File

@@ -0,0 +1,199 @@
#ck-app .ck-modal-container {
z-index: 10000 !important;
}
.ck-style,
.ck-style * {
font-family: inherit !important;
}
.ck-background-overlay,
#ck-cf-modal-overlay {
background: rgb(0 0 0 / 0.7) !important;
}
/* Churnkey uses var(--color-brand-black) for primary text (titles, etc.).
Remap it to our light foreground so that text is readable on the dark
modal. Background utilities that also use it (bg-brand-black) are guarded
below so they don't turn light. */
#ck-app,
.ck-style {
--color-brand-black: var(--base-foreground) !important;
}
.ck-modal,
#ck-cf-modal {
background: var(--base-background) !important;
border: 1px solid var(--border-default) !important;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
border-radius: 16px !important;
}
.ck-step,
.ck-survey-step,
.ck-confirm-step,
.ck-freeform-step,
.ck-pause-step,
.ck-discount-step,
.ck-contact-step,
.ck-redirect-step,
.ck-complete-step,
.ck-progress-step,
.ck-error-step {
background: var(--base-background) !important;
}
.ck-step-header {
background: var(--base-background) !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.ck-step-header-text {
color: var(--base-foreground) !important;
}
.ck-step-description-text,
.ck-description,
.ck-style .subtitle {
color: var(--muted-foreground) !important;
}
.ck-step-body {
background: var(--base-background) !important;
color: var(--muted-foreground) !important;
}
.ck-step-footer {
background: var(--base-background) !important;
border-top: 1px solid var(--border-subtle) !important;
}
.ck-style select,
.ck-style input,
.ck-style textarea {
background-color: var(--secondary-background) !important;
color: var(--base-foreground) !important;
border-color: var(--border-default) !important;
}
.ck-style select:focus-visible,
.ck-style input:focus-visible,
.ck-style textarea:focus-visible {
border-color: var(--primary-background) !important;
box-shadow: 0 0 0 2px var(--primary-background) !important;
outline: 2px solid transparent !important;
outline-offset: 2px !important;
}
.ck-style ::placeholder {
color: var(--muted-foreground) !important;
opacity: 0.6 !important;
}
.ck-style option {
background-color: var(--secondary-background) !important;
color: var(--base-foreground) !important;
}
/* Churnkey injects its compiled utility CSS at runtime, AFTER this bundled
sheet, so `.ck-style`-scoped overrides tie on specificity and lose on
source order — leaving dark brand/gray text on the dark modal. The
`#ck-app` id prefix raises specificity above Churnkey's `.ck-style`
utilities so these win regardless of injection order. */
#ck-app .text-gray-900,
#ck-app .text-gray-800,
#ck-app .text-brand-black {
color: var(--base-foreground) !important;
}
#ck-app .text-gray-700,
#ck-app .text-gray-600,
#ck-app .text-gray-500,
#ck-app .text-gray-400 {
color: var(--muted-foreground) !important;
}
#ck-app .border-gray-100,
#ck-app .border-gray-200,
#ck-app .border-gray-300 {
border-color: var(--border-default) !important;
}
#ck-app .bg-gray-100,
#ck-app .bg-gray-200,
#ck-app .bg-gray-300 {
background-color: var(--secondary-background) !important;
}
/* Guard: brand-black is remapped to a light foreground for text, so force
its background usage to a dark surface. Primary buttons override this to
the accent via their own rule below. */
#ck-app .bg-brand-black {
background-color: var(--secondary-background) !important;
}
#ck-app .text-opacity-60,
#ck-app .text-opacity-80,
#ck-app .text-opacity-90 {
--tw-text-opacity: 1 !important;
}
#ck-app [class*='bg-client-primary-light'] {
background-color: var(--secondary-background) !important;
}
#ck-app .bg-opacity-5 {
--tw-bg-opacity: 1 !important;
}
.ck-pause-subscription-details {
border-color: var(--border-default) !important;
color: var(--muted-foreground) !important;
}
.ck-pause-subscription-details b {
color: var(--base-foreground) !important;
}
#ck-app .active-discount-disclaimer {
color: var(--muted-foreground) !important;
background-color: var(--secondary-background) !important;
}
.ck-step-body li,
.ck-step-body label {
color: var(--muted-foreground) !important;
}
#ck-app .h-14.rounded-t-lg {
background-color: var(--secondary-background) !important;
}
#ck-app .bg-client-primary {
background-color: var(--primary-background) !important;
}
#ck-app .text-client-primary,
#ck-app .text-client-primary-light {
color: var(--muted-foreground) !important;
}
#ck-app .text-client-primary-middle {
color: var(--muted-foreground) !important;
opacity: 0.4 !important;
}
#ck-app .border-client-primary {
border-color: var(--primary-background) !important;
}
#ck-app .border-client-primary-light,
#ck-app .border-text-client-primary {
border-color: var(--border-default) !important;
}
/* Buttons carry both Churnkey component classes (.ck-*-button) and raw
utilities (bg-brand-black, bg-gray-200, text-white, text-brand-black).
Scope under the #ck-app id so these win over Churnkey's runtime-injected
`.ck-style` utilities — otherwise the utility bg/text colors leak through
and produce light-on-light / dark-on-dark buttons. */
#ck-app .ck-primary-button,
#ck-app .ck-black-primary-button {
background: var(--primary-background) !important;
color: var(--base-foreground) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 600 !important;
}
#ck-app .ck-primary-button:hover,
#ck-app .ck-black-primary-button:hover {
background: var(--primary-background-hover) !important;
}
#ck-app .ck-gray-primary-button {
background: var(--secondary-background) !important;
color: var(--base-foreground) !important;
border: 1px solid var(--border-default) !important;
border-radius: 8px !important;
}
#ck-app .ck-text-button,
#ck-app .ck-black-text-button {
color: var(--muted-foreground) !important;
}

View File

@@ -0,0 +1,25 @@
/**
* Thrown when the backend's `/billing/churnkey/auth` endpoint is missing
* (e.g. backend hasn't been deployed yet). Callers should treat this the
* same as Churnkey not being configured at all and fall back to the
* legacy cancel dialog rather than surfacing a toast.
*/
export class ChurnkeyAuthUnavailableError extends Error {
constructor() {
super('Churnkey auth endpoint not available')
this.name = 'ChurnkeyAuthUnavailableError'
}
}
/**
* Thrown when the Churnkey embed script fails to load — network failure or,
* more likely, an ad blocker (churn-prevention scripts are on common
* blocklists). Callers must fall back to the legacy cancel dialog so the
* user always has a way to cancel.
*/
export class ChurnkeyEmbedLoadError extends Error {
constructor() {
super('Churnkey embed script failed to load')
this.name = 'ChurnkeyEmbedLoadError'
}
}

View File

@@ -0,0 +1,394 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
const mocks = vi.hoisted(() => ({
fetchStatus: vi.fn(),
cancelSubscription: vi.fn(),
trackCancellationFlowOpened: vi.fn(),
trackCancellationFlowClosed: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn(),
toastAdd: vi.fn(),
prepareChurnkey: vi.fn(),
show: vi.fn(),
billingType: { value: 'workspace' as 'legacy' | 'workspace' },
subscription: {
value: null as {
tier: string | null
duration: string | null
planSlug: string | null
} | null
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mocks.toastAdd })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
type: {
get value() {
return mocks.billingType.value
}
},
fetchStatus: mocks.fetchStatus,
cancelSubscription: mocks.cancelSubscription,
subscription: {
get value() {
return mocks.subscription.value
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackCancellationFlowOpened: mocks.trackCancellationFlowOpened,
trackCancellationFlowClosed: mocks.trackCancellationFlowClosed,
trackMonthlySubscriptionCancelled: mocks.trackMonthlySubscriptionCancelled
})
}))
vi.mock('./churnkeyClient', () => ({
prepareChurnkey: mocks.prepareChurnkey
}))
const { launchChurnkeyCancellation } =
await import('./launchChurnkeyCancellation')
interface CapturedShowOptions {
customerAttributes?: Record<string, string>
handleCancel?: () => Promise<{ message?: string }>
onCancel: (surveyResponse: string) => void
}
type SessionResults = Record<string, unknown>
/**
* Mirrors the real client contract: show() captures the session callbacks
* and resolves with the session results when the modal closes.
*/
function openDeferredSession() {
let resolveShow!: (results: SessionResults) => void
let rejectShow!: (err: unknown) => void
let options: CapturedShowOptions | undefined
mocks.show.mockImplementation((opts: CapturedShowOptions) => {
options = opts
return new Promise<SessionResults>((resolve, reject) => {
resolveShow = resolve
rejectShow = reject
})
})
return {
options: () => {
if (!options) throw new Error('churnkey session.show was not called')
return options
},
close: (results: SessionResults) => resolveShow(results),
fail: (err: unknown) => rejectShow(err)
}
}
async function waitForShow() {
await vi.waitFor(() => expect(mocks.show).toHaveBeenCalled())
}
describe('launchChurnkeyCancellation', () => {
beforeEach(() => {
mocks.billingType.value = 'workspace'
mocks.subscription.value = null
mocks.prepareChurnkey.mockReset()
mocks.prepareChurnkey.mockResolvedValue({ show: mocks.show })
mocks.show.mockReset()
mocks.show.mockResolvedValue({ status: 'closed' })
mocks.fetchStatus.mockReset()
mocks.fetchStatus.mockResolvedValue(undefined)
mocks.cancelSubscription.mockReset()
mocks.cancelSubscription.mockResolvedValue(undefined)
mocks.trackCancellationFlowOpened.mockReset()
mocks.trackCancellationFlowClosed.mockReset()
mocks.trackMonthlySubscriptionCancelled.mockReset()
mocks.toastAdd.mockReset()
})
it('emits exactly one cancellation_flow_closed when the user cancels', async () => {
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await launch
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
expect(mocks.trackMonthlySubscriptionCancelled).toHaveBeenCalledTimes(1)
})
it('tracks opened once per session, after preparation succeeds', async () => {
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
const prepareOrder = mocks.prepareChurnkey.mock.invocationCallOrder[0]
const openedOrder =
mocks.trackCancellationFlowOpened.mock.invocationCallOrder[0]
const showOrder = mocks.show.mock.invocationCallOrder[0]
expect(prepareOrder).toBeLessThan(openedOrder)
expect(openedOrder).toBeLessThan(showOrder)
})
it('passes handleCancel and calls billing.cancelSubscription for workspace billing', async () => {
mocks.billingType.value = 'workspace'
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
const handleCancel = session.options().handleCancel
expect(handleCancel).toBeTypeOf('function')
await expect(handleCancel?.()).resolves.toEqual({
message: 'subscription.cancelSuccess'
})
expect(mocks.cancelSubscription).toHaveBeenCalledTimes(1)
session.close({ status: 'canceled' })
await launch
})
it('omits handleCancel for legacy billing so Churnkey cancels via Stripe', async () => {
mocks.billingType.value = 'legacy'
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
expect(session.options().handleCancel).toBeUndefined()
expect(mocks.cancelSubscription).not.toHaveBeenCalled()
session.close({ status: 'closed' })
await launch
})
it('rejects handleCancel with the API error message and records cancel_api_failed on close', async () => {
const apiError = new Error('card declined')
mocks.cancelSubscription.mockRejectedValue(apiError)
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
// Churnkey shows this rejection message in its own UI.
await expect(session.options().handleCancel?.()).rejects.toMatchObject({
message: 'card declined',
cause: apiError
})
session.close({ status: 'closed' })
await launch
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'unknown',
failure_reason: 'cancel_api_failed'
})
})
it('clears the cancel_api_failed flag when a retry succeeds', async () => {
mocks.cancelSubscription
.mockRejectedValueOnce(new Error('card declined'))
.mockResolvedValueOnce(undefined)
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
const handleCancel = session.options().handleCancel
await expect(handleCancel?.()).rejects.toThrow('card declined')
await expect(handleCancel?.()).resolves.toEqual({
message: 'subscription.cancelSuccess'
})
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await launch
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
})
it('refreshes local billing state after a cancel', async () => {
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await launch
await vi.waitFor(() => expect(mocks.fetchStatus).toHaveBeenCalledTimes(1))
})
it('does not refresh local state when the user closes without canceling', async () => {
await launchChurnkeyCancellation()
expect(mocks.fetchStatus).not.toHaveBeenCalled()
})
it('records reconsidered when the user closes without canceling', async () => {
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'reconsidered'
})
})
it('maps Churnkey discounted status to discounted outcome', async () => {
mocks.show.mockResolvedValue({ status: 'discounted' })
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'discounted'
})
})
it('maps Churnkey paused status to paused outcome', async () => {
mocks.show.mockResolvedValue({ status: 'paused' })
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'paused'
})
})
it('swallows fetchStatus failures after the cancel', async () => {
mocks.fetchStatus.mockRejectedValue(new Error('network'))
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await expect(launch).resolves.toBeUndefined()
await vi.waitFor(() => expect(mocks.fetchStatus).toHaveBeenCalledTimes(1))
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
})
it('forwards customerAttributes from billing subscription', async () => {
mocks.subscription.value = {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: 'pro-monthly'
}
await launchChurnkeyCancellation()
expect(mocks.show.mock.calls[0][0].customerAttributes).toEqual({
tier: 'PRO',
cycle: 'MONTHLY',
plan_slug: 'pro-monthly'
})
})
it('omits customerAttributes when subscription is null', async () => {
await launchChurnkeyCancellation()
expect(mocks.show.mock.calls[0][0].customerAttributes).toBeUndefined()
})
it('re-throws ChurnkeyAuthUnavailableError without toast or telemetry', async () => {
mocks.prepareChurnkey.mockRejectedValue(new ChurnkeyAuthUnavailableError())
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
ChurnkeyAuthUnavailableError
)
expect(mocks.toastAdd).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('re-throws ChurnkeyEmbedLoadError without toast or telemetry', async () => {
mocks.prepareChurnkey.mockRejectedValue(new ChurnkeyEmbedLoadError())
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
ChurnkeyEmbedLoadError
)
expect(mocks.toastAdd).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('shows a toast without telemetry when preparation fails unexpectedly', async () => {
mocks.prepareChurnkey.mockRejectedValue(new Error('auth endpoint 500'))
await expect(launchChurnkeyCancellation()).resolves.toBeUndefined()
expect(mocks.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'auth endpoint 500'
})
)
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('shows a toast and a balancing closed event when the session fails after opening', async () => {
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.fail(new Error('init exploded'))
await launch
expect(mocks.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'init exploded'
})
)
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'unknown',
failure_reason: 'unexpected'
})
})
it('ignores concurrent calls while the session is open', async () => {
const session = openDeferredSession()
const first = launchChurnkeyCancellation()
await waitForShow()
await launchChurnkeyCancellation()
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
session.close({ status: 'closed' })
await first
// Guard released on close; a fresh launch proceeds.
mocks.show.mockReset()
mocks.show.mockResolvedValue({ status: 'closed' })
await launchChurnkeyCancellation()
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(2)
})
it('releases the in-flight guard when preparation fails', async () => {
mocks.prepareChurnkey.mockRejectedValueOnce(new Error('boom'))
await launchChurnkeyCancellation()
await launchChurnkeyCancellation()
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,143 @@
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import type { CancellationFlowClosedMetadata } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ChurnkeySession } from './churnkeyClient'
import { prepareChurnkey } from './churnkeyClient'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
import type { ChurnkeySessionResults } from './types'
type CancellationOutcome = CancellationFlowClosedMetadata['outcome']
function deriveOutcome(
results: ChurnkeySessionResults,
canceledThisSession: boolean,
cancelApiFailed: boolean
): CancellationOutcome {
if (canceledThisSession) return 'canceled'
if (cancelApiFailed) return 'unknown'
if (results.status === 'closed') return 'reconsidered'
return results.status ?? 'unknown'
}
function buildCustomerAttributes(
billing: ReturnType<typeof useBillingContext>
): Record<string, string> | undefined {
const sub = billing.subscription.value
if (!sub) return undefined
const attrs: Record<string, string> = {}
if (sub.tier) attrs.tier = sub.tier
if (sub.duration) attrs.cycle = sub.duration
if (sub.planSlug) attrs.plan_slug = sub.planSlug
return Object.keys(attrs).length > 0 ? attrs : undefined
}
let inFlight = false
export async function launchChurnkeyCancellation(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await runCancellationFlow()
} finally {
inFlight = false
}
}
async function runCancellationFlow(): Promise<void> {
const billing = useBillingContext()
const telemetry = useTelemetry()
const toast = useToastStore()
function showFailureToast(err: unknown) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
})
}
let session: ChurnkeySession
try {
session = await prepareChurnkey()
} catch (err) {
if (
err instanceof ChurnkeyAuthUnavailableError ||
err instanceof ChurnkeyEmbedLoadError
) {
// Re-throw so the caller can route to the legacy dialog.
throw err
}
showFailureToast(err)
return
}
let canceledThisSession = false
let cancelApiFailed = false
let lastSurveyResponse: string | undefined
telemetry?.trackCancellationFlowOpened()
try {
const results = await session.show({
customerAttributes: buildCustomerAttributes(billing),
// Workspace billing cancels through our API; legacy billing omits
// handleCancel so Churnkey cancels directly via Stripe.
...(billing.type.value === 'workspace' && {
handleCancel: async () => {
try {
await billing.cancelSubscription()
} catch (err) {
cancelApiFailed = true
const message =
err instanceof Error
? err.message
: t('subscription.cancelDialog.failed')
// Churnkey displays the rejection message in its own UI.
throw new Error(message, { cause: err })
}
cancelApiFailed = false
return { message: t('subscription.cancelSuccess') }
}
}),
// Fires after a successful cancel — whether via handleCancel (team)
// or Churnkey's own Stripe cancel (legacy). No double-fire with
// useSubscriptionCancellationWatcher: that watcher only runs after
// opening the Stripe billing portal via manageSubscription.
onCancel: (surveyResponse) => {
canceledThisSession = true
lastSurveyResponse = surveyResponse
telemetry?.trackMonthlySubscriptionCancelled()
}
})
const outcome = deriveOutcome(results, canceledThisSession, cancelApiFailed)
const failureReason = cancelApiFailed
? ('cancel_api_failed' as const)
: undefined
telemetry?.trackCancellationFlowClosed({
outcome,
...(lastSurveyResponse !== undefined && {
survey_response: lastSurveyResponse
}),
...(failureReason !== undefined && { failure_reason: failureReason })
})
if (canceledThisSession) {
// Refresh local state so the UI reflects the cancellation. Failure
// here is non-blocking; the next page load will catch up.
void billing.fetchStatus().catch(() => {})
}
} catch (err) {
// session.show only rejects when churnkey.init itself throws — keep
// the funnel balanced since `opened` has already been tracked.
telemetry?.trackCancellationFlowClosed({
outcome: 'unknown',
failure_reason: 'unexpected'
})
showFailureToast(err)
}
}

View File

@@ -0,0 +1,55 @@
// Subset of the Churnkey embed API. No official @types package exists.
// Docs: https://docs.churnkey.co/cancel-flows/further-configuration/
export type ChurnkeyMode = 'live' | 'test' | 'sandbox'
type ChurnkeyProvider = 'stripe' | 'chargebee' | 'braintree' | 'paddle'
export interface ChurnkeyHandlerResult {
message?: string
}
export interface ChurnkeyInitConfig {
appId: string
authHash: string
customerId: string
subscriptionId?: string
provider: ChurnkeyProvider
mode: ChurnkeyMode
record?: boolean
preview?: boolean
report?: boolean
bypassDiscountAppliedScreen?: boolean
bypassPauseAppliedScreen?: boolean
customerAttributes?: Record<string, string | number>
handleCancel?: (
customer: string,
surveyResponse: string,
freeformFeedback?: string
) => Promise<ChurnkeyHandlerResult>
handleSupportRequest?: (customer: string) => void
onCancel?: (customer: string, surveyResponse: string) => void
onClose?: (sessionResults: ChurnkeySessionResults) => void
onGoToAccount?: (sessionResults: ChurnkeySessionResults) => void
}
export interface ChurnkeySessionResults {
status?: 'canceled' | 'discounted' | 'paused' | 'closed'
[key: string]: unknown
}
export interface ChurnkeyWindow {
created?: boolean
/** Defined once the embed script (loaded on demand) has executed. */
init?: (action: 'show' | 'restart', config: ChurnkeyInitConfig) => void
hide?: () => void
clearState?: () => void
}
declare global {
interface Window {
churnkey?: ChurnkeyWindow
}
}

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
ChurnkeyAuthUnavailableError,
ChurnkeyEmbedLoadError
} from '@/platform/cloud/churnkey/errors'
const showCancelSubscriptionDialog = vi.hoisted(() => vi.fn())
const launchChurnkeyCancellationMock = vi.hoisted(() => vi.fn())
const useFeatureFlagsMock = vi.hoisted(() => vi.fn())
const isChurnkeyConfiguredMock = vi.hoisted(() => vi.fn())
vi.mock('./showCancelSubscriptionDialog', () => ({
showCancelSubscriptionDialog
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: useFeatureFlagsMock
}))
vi.mock('@/platform/cloud/churnkey/churnkeyClient', () => ({
isChurnkeyConfigured: isChurnkeyConfiguredMock
}))
vi.mock('@/platform/cloud/churnkey/launchChurnkeyCancellation', () => ({
launchChurnkeyCancellation: launchChurnkeyCancellationMock
}))
const { launchCancellationFlow } = await import('./launchCancellationFlow')
describe('launchCancellationFlow', () => {
beforeEach(() => {
showCancelSubscriptionDialog.mockReset()
launchChurnkeyCancellationMock.mockReset()
useFeatureFlagsMock.mockReset()
isChurnkeyConfiguredMock.mockReset()
})
it('launches Churnkey when the flag is on and the embed is configured', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockResolvedValue(undefined)
await launchCancellationFlow('2026-12-01')
expect(launchChurnkeyCancellationMock).toHaveBeenCalledTimes(1)
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
})
it('falls back to the legacy dialog when the flag is off', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: false }
})
await launchCancellationFlow('2026-12-01')
expect(launchChurnkeyCancellationMock).not.toHaveBeenCalled()
expect(isChurnkeyConfiguredMock).not.toHaveBeenCalled()
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('falls back to the legacy dialog when CHURNKEY_APP_ID is missing', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
isChurnkeyConfiguredMock.mockReturnValue(false)
await launchCancellationFlow('2026-12-01')
expect(launchChurnkeyCancellationMock).not.toHaveBeenCalled()
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('falls back to the legacy dialog on ChurnkeyAuthUnavailableError', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockRejectedValue(
new ChurnkeyAuthUnavailableError()
)
await launchCancellationFlow('2026-12-01')
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('falls back to the legacy dialog when the embed script fails to load', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockRejectedValue(
new ChurnkeyEmbedLoadError()
)
await launchCancellationFlow('2026-12-01')
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('does not fall back when Churnkey throws other errors', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockRejectedValue(
new Error('something else')
)
await expect(launchCancellationFlow('2026-12-01')).rejects.toThrow(
'something else'
)
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,35 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isChurnkeyConfigured } from '@/platform/cloud/churnkey/churnkeyClient'
import {
ChurnkeyAuthUnavailableError,
ChurnkeyEmbedLoadError
} from '@/platform/cloud/churnkey/errors'
import { launchChurnkeyCancellation } from '@/platform/cloud/churnkey/launchChurnkeyCancellation'
import { showCancelSubscriptionDialog } from './showCancelSubscriptionDialog'
function shouldUseChurnkey(): boolean {
const { flags } = useFeatureFlags()
if (!flags.churnkeyCancellationEnabled) return false
return isChurnkeyConfigured()
}
export async function launchCancellationFlow(cancelAt?: string): Promise<void> {
if (!shouldUseChurnkey()) {
await showCancelSubscriptionDialog(cancelAt)
return
}
try {
await launchChurnkeyCancellation()
} catch (err) {
if (
err instanceof ChurnkeyAuthUnavailableError ||
err instanceof ChurnkeyEmbedLoadError
) {
await showCancelSubscriptionDialog(cancelAt)
return
}
throw err
}
}

View File

@@ -0,0 +1,15 @@
import { workspaceDialogPt } from '@/platform/workspace/components/dialogs/workspaceDialogPt'
import { useDialogStore } from '@/stores/dialogStore'
export async function showCancelSubscriptionDialog(cancelAt?: string) {
const { default: component } =
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
return useDialogStore().showDialog({
key: 'cancel-subscription',
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogPt
}
})
}

View File

@@ -103,6 +103,7 @@ export type RemoteConfig = {
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
churnkey_cancellation_enabled?: boolean
unified_cloud_auth?: boolean
sentry_dsn?: string
}

View File

@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
CancellationFlowClosedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -266,4 +267,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
}
trackCancellationFlowOpened(): void {
this.dispatch((provider) => provider.trackCancellationFlowOpened?.())
}
trackCancellationFlowClosed(metadata: CancellationFlowClosedMetadata): void {
this.dispatch((provider) =>
provider.trackCancellationFlowClosed?.(metadata)
)
}
}

View File

@@ -445,6 +445,36 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('cancellation flow', () => {
it('stamps the reconsidered person property when the flow closes reconsidered', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackCancellationFlowClosed({ outcome: 'reconsidered' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
{ outcome: 'reconsidered' }
)
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
cancellation_reconsidered_at: expect.any(String)
})
})
it('does not stamp the person property for other outcomes', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackCancellationFlowClosed({ outcome: 'canceled' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
{ outcome: 'canceled' }
)
expect(hoisted.mockPeopleSet).not.toHaveBeenCalled()
})
})
describe('disabled events', () => {
it('does not capture default disabled events', async () => {
const provider = createProvider()

View File

@@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
CancellationFlowClosedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -555,4 +556,22 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
...properties
})
}
trackCancellationFlowOpened(): void {
this.trackEvent(TelemetryEvents.CANCELLATION_FLOW_OPENED)
}
trackCancellationFlowClosed(metadata: CancellationFlowClosedMetadata): void {
this.trackEvent(TelemetryEvents.CANCELLATION_FLOW_CLOSED, metadata)
if (metadata.outcome !== 'reconsidered') return
if (!this.posthog || !this.isEnabled) return
try {
this.posthog.people.set({
cancellation_reconsidered_at: new Date().toISOString()
})
} catch (error) {
console.error('Failed to set PostHog user property:', error)
}
}
}

View File

@@ -451,6 +451,18 @@ interface EcommerceMetadata {
items: EcommerceItemMetadata[]
}
export interface CancellationFlowClosedMetadata {
outcome: 'canceled' | 'reconsidered' | 'discounted' | 'paused' | 'unknown'
survey_response?: string
/**
* Categorized reason when `outcome === 'unknown'` so PostHog dashboards
* can separate a failed cancel API call from an embed failure. Fallbacks
* to the legacy dialog (auth endpoint missing, embed script blocked)
* happen before the flow opens and emit no events at all.
*/
failure_reason?: 'cancel_api_failed' | 'unexpected'
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
checkout_attempt_id: string
@@ -560,6 +572,10 @@ export interface TelemetryProvider {
// Page view tracking
trackPageView?(pageName: string, properties?: PageViewMetadata): void
// Cancellation flow events
trackCancellationFlowOpened?(): void
trackCancellationFlowClosed?(metadata: CancellationFlowClosedMetadata): void
}
/**
@@ -654,7 +670,11 @@ export const TelemetryEvents = {
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
// Page View
PAGE_VIEW: 'app:page_view'
PAGE_VIEW: 'app:page_view',
// Cancellation Flow
CANCELLATION_FLOW_OPENED: 'app:cancellation_flow_opened',
CANCELLATION_FLOW_CLOSED: 'app:cancellation_flow_closed'
} as const
export type TelemetryEventName =
@@ -703,3 +723,4 @@ export type TelemetryEventProperties =
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata
| CancellationFlowClosedMetadata

View File

@@ -313,6 +313,46 @@ describe('workspaceApi', () => {
})
expect(result).toEqual(data)
})
it('getChurnkeyAuth() returns the credentials on success', async () => {
const data = {
customer_id: 'cus_123',
auth_hash: 'hash_abc',
mode: 'live'
}
mockAxiosInstance.get.mockResolvedValue({ data })
const result = await workspaceApi.getChurnkeyAuth()
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/api/billing/churnkey/auth',
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)
})
it('getChurnkeyAuth() returns null on 404 so callers fall back', async () => {
mockAxiosInstance.get.mockRejectedValue({
isAxiosError: true,
response: { status: 404, data: { error: { message: 'Not Found' } } },
message: 'Request failed'
})
await expect(workspaceApi.getChurnkeyAuth()).resolves.toBeNull()
})
it('getChurnkeyAuth() rethrows non-404 errors', async () => {
mockAxiosInstance.get.mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { message: 'Server Error' } },
message: 'Request failed'
})
await expect(workspaceApi.getChurnkeyAuth()).rejects.toMatchObject({
name: 'WorkspaceApiError',
status: 500
})
})
})
describe('subscription', () => {

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
import type { ChurnkeyMode } from '@/platform/cloud/churnkey/types'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
WorkspaceId,
@@ -171,6 +172,13 @@ interface PaymentPortalResponse {
url: string
}
export interface ChurnkeyAuthResponse {
customer_id: string
subscription_id?: string
auth_hash: string
mode: ChurnkeyMode
}
interface PreviewPlanInfo {
slug: string
tier: SubscriptionTier
@@ -696,6 +704,37 @@ export const workspaceApi = {
}
},
/**
* Get Churnkey auth credentials (customer ID + HMAC) for the active workspace.
* GET /api/billing/churnkey/auth
* Used by the cancellation flow to launch the Churnkey embedded modal.
*
* Returns `null` on any 404 — callers fall back to the legacy cancel
* dialog. Verified against production (2026-06-12): an undeployed route
* hits the router's catch-all, which returns a JSON 404 body of
* `{"error":{"message":"Not Found","type":"not_found"}}` (application
* errors use a `{"code": ...}` shape instead, e.g. UNAUTHORIZED). A
* future application-level 404 such as "no Churnkey customer" also
* correctly falls back to the legacy dialog.
*
* The HMAC must be signed server-side; never derive it on the client.
*/
async getChurnkeyAuth(): Promise<ChurnkeyAuthResponse | null> {
const headers = await getAuthHeaderOrThrow()
const url = api.apiURL('/billing/churnkey/auth')
try {
const response = await workspaceApiClient.get<ChurnkeyAuthResponse>(url, {
headers
})
return response.data
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
return null
}
handleAxiosError(err)
}
},
/**
* Get billing operation status
* GET /api/billing/ops/:id

View File

@@ -0,0 +1,333 @@
import { createTestingPinia } from '@pinia/testing'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SubscriptionInfo } from '@/composables/billing/types'
import SubscriptionPanelContentWorkspace from './SubscriptionPanelContentWorkspace.vue'
const isInPersonalWorkspaceRef = ref(true)
const isWorkspaceSubscribedRef = ref(true)
const isActiveSubscriptionRef = ref(true)
const subscriptionRef = ref<SubscriptionInfo | null>(null)
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspaceRef,
isWorkspaceSubscribed: isWorkspaceSubscribedRef,
members: ref([])
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: isActiveSubscriptionRef,
isFreeTier: ref(false),
subscription: subscriptionRef,
showSubscriptionDialog: vi.fn(),
manageSubscription: vi.fn(),
fetchStatus: vi.fn(),
fetchBalance: vi.fn(),
getMaxSeats: () => 1,
resubscribe: vi.fn()
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
})
})
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({ isSettingUp: false })
}))
const launchCancellationFlowMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
launchCancellationFlow: launchCancellationFlowMock
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ showPricingTable: vi.fn() })
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => ({
handleAddApiCredits: vi.fn(),
handleRefresh: vi.fn()
})
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
() => ({
useSubscriptionCredits: () => ({
totalCredits: '10',
monthlyBonusCredits: '5',
prepaidCredits: '5',
isLoadingBalance: false
})
})
)
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
escapeParameter: false,
messages: {
en: {
g: { moreOptions: 'More options', error: 'Error' },
billingOperation: { subscriptionProcessing: 'Setting up...' },
subscription: {
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
canceled: 'Canceled',
canceledCard: {
title: 'Subscription canceled',
description: 'Active until {date}'
},
usdPerMonth: '/ month',
usdPerMonthPerMember: '/ member / month',
creditsIncluded: 'Included',
creditsRemainingThisMonth: 'Included (Refills {date})',
creditsRemainingThisYear: 'Included (Refills {date})',
creditsYouveAdded: "Credits you've added",
totalCredits: 'Total Credits',
managePayment: 'Manage payment',
upgradePlan: 'Upgrade plan',
resubscribe: 'Resubscribe',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits',
subscribeNow: 'Subscribe now',
workspaceNotSubscribed: 'Not subscribed',
subscriptionRequiredMessage: 'Subscription required',
contactOwnerToSubscribe: 'Contact owner',
cancelSubscription: 'Cancel subscription',
nextMonthInvoice: 'Next month',
invoiceHistory: 'Invoice history',
memberCount: '{n} member | {n} members',
viewMoreDetailsPlans: 'View pricing',
yourPlanIncludes: 'Your plan includes',
tierNameYearly: '{name} Yearly',
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
pro: { name: 'Pro' },
founder: { name: "Founder's Edition" }
},
membersLabel: '{count} members',
resubscribeSuccess: 'Resubscribed'
}
}
}
})
function activeSubscription(): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jun 19, 2026',
endDate: null,
isCancelled: false,
hasFunds: true
}
}
function canceledSubscription(): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
// Backend clears renewal_date once Stripe schedules a cancel_at; only
// end_date remains populated. Mirror that here.
renewalDate: null,
endDate: 'Jun 19, 2026',
isCancelled: true,
hasFunds: true
}
}
function renderPanel() {
return render(SubscriptionPanelContentWorkspace, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
StatusBadge: true,
Skeleton: true,
Menu: {
props: ['model'],
template: `
<div>
<button
v-for="item in model"
:key="item.label"
@click="item.command"
>
{{ item.label }}
</button>
</div>
`
},
Button: { template: '<button><slot/></button>' }
}
}
})
}
describe('SubscriptionPanelContentWorkspace', () => {
beforeEach(() => {
isInPersonalWorkspaceRef.value = true
isWorkspaceSubscribedRef.value = true
isActiveSubscriptionRef.value = true
subscriptionRef.value = activeSubscription()
launchCancellationFlowMock.mockReset()
})
describe('cancel state', () => {
it('renders "Expires {date}" for a canceled personal subscription', () => {
isInPersonalWorkspaceRef.value = true
subscriptionRef.value = canceledSubscription()
const { container } = renderPanel()
expect(container.textContent).toContain('Expires Jun 19, 2026')
expect(container.textContent).not.toContain('Renews')
})
it('renders "Expires {date}" for a canceled team subscription', () => {
isInPersonalWorkspaceRef.value = false
subscriptionRef.value = canceledSubscription()
const { container } = renderPanel()
expect(container.textContent).toContain('Expires Jun 19, 2026')
expect(container.textContent).not.toContain('Renews')
})
it('renders "Renews {date}" for an active personal subscription', () => {
isInPersonalWorkspaceRef.value = true
subscriptionRef.value = activeSubscription()
const { container } = renderPanel()
expect(container.textContent).toContain('Renews Jun 19, 2026')
expect(container.textContent).not.toContain('Expires')
})
})
describe('action buttons when canceled', () => {
it('shows Resubscribe for canceled team workspaces', () => {
isInPersonalWorkspaceRef.value = false
subscriptionRef.value = canceledSubscription()
const { container } = renderPanel()
expect(container.textContent).toContain('Resubscribe')
expect(container.textContent).not.toContain('Manage payment')
expect(container.textContent).not.toContain('Upgrade plan')
})
it('shows Manage Payment + Upgrade for canceled personal subs (no Resubscribe)', () => {
isInPersonalWorkspaceRef.value = true
subscriptionRef.value = canceledSubscription()
const { container } = renderPanel()
expect(container.textContent).not.toContain('Resubscribe')
expect(container.textContent).toContain('Manage payment')
expect(container.textContent).toContain('Upgrade plan')
})
it('hides the more-options menu trigger when canceled to prevent re-canceling', () => {
isInPersonalWorkspaceRef.value = true
subscriptionRef.value = canceledSubscription()
renderPanel()
expect(screen.queryByLabelText('More options')).toBeNull()
})
it('shows the more-options menu trigger when active', () => {
isInPersonalWorkspaceRef.value = true
subscriptionRef.value = activeSubscription()
renderPanel()
expect(screen.getByLabelText('More options')).toBeInTheDocument()
})
})
describe('refills date', () => {
it('renders refills date from renewalDate when present', () => {
subscriptionRef.value = activeSubscription()
const { container } = renderPanel()
expect(container.textContent).toMatch(/Refills 06\/19\/26/)
})
it('hides the refills date when renewalDate is null (canceled)', () => {
subscriptionRef.value = canceledSubscription()
const { container } = renderPanel()
expect(container.textContent).toContain('Included')
expect(container.textContent).not.toContain('Refills')
})
})
describe('cancel subscription menu item', () => {
it('launches the cancellation flow with the subscription end date', async () => {
subscriptionRef.value = {
...activeSubscription(),
endDate: 'Jun 19, 2026'
}
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByText('Cancel subscription'))
expect(launchCancellationFlowMock).toHaveBeenCalledWith('Jun 19, 2026')
})
it('passes undefined when the subscription has no end date', async () => {
subscriptionRef.value = activeSubscription()
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByText('Cancel subscription'))
expect(launchCancellationFlowMock).toHaveBeenCalledWith(undefined)
})
})
})

View File

@@ -129,7 +129,7 @@
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: show only Resubscribe button -->
<template v-if="isCancelled">
<template v-if="isCancelled && !isInPersonalWorkspace">
<Button
size="lg"
variant="primary"
@@ -161,7 +161,7 @@
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-if="!isFreeTierPlan"
v-if="!isFreeTierPlan && !isCancelled"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
@@ -407,7 +407,7 @@ const {
resubscribe
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const { launchCancellationFlow } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
const isResubscribing = ref(false)
@@ -434,12 +434,7 @@ async function handleResubscribe() {
}
}
// Only show cancelled state for team workspaces (workspace billing)
// Personal workspaces use legacy billing which has different cancellation semantics
const isCancelled = computed(
() =>
!isInPersonalWorkspace.value && (subscription.value?.isCancelled ?? false)
)
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
// Show subscribe prompt to owners without active subscription
// Don't show if subscription is cancelled (still active until end date)
@@ -518,7 +513,7 @@ const planMenuItems = computed(() => [
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
void launchCancellationFlow(subscription.value?.endDate ?? undefined)
}
}
])
@@ -535,36 +530,28 @@ const tierPrice = computed(() =>
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
// Canceled subscriptions have no renewalDate — credits will not refill, so
// the refill date is hidden rather than falling back to endDate.
const refillsDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
const source = subscription.value?.renewalDate
if (!source) return ''
const date = new Date(source)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
)
const creditsRemainingLabel = computed(() => {
if (!refillsDate.value) return t('subscription.creditsIncluded')
return t(
isYearlySubscription.value
? 'subscription.creditsRemainingThisYear'
: 'subscription.creditsRemainingThisMonth',
{ date: refillsDate.value },
{ escapeParameter: false }
)
})
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)

View File

@@ -0,0 +1,9 @@
/** Shared headless dialog passthrough styling for workspace dialogs. */
export const workspaceDialogPt = {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const

View File

@@ -6,6 +6,7 @@ import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.v
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
import { workspaceDialogPt } from '@/platform/workspace/components/dialogs/workspaceDialogPt'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
@@ -446,15 +447,6 @@ export const useDialogService = () => {
}
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
const workspaceDialogPt = {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const
async function showDeleteWorkspaceDialog(options?: {
workspaceId?: string
workspaceName?: string
@@ -590,16 +582,15 @@ export const useDialogService = () => {
}
async function showCancelSubscriptionDialog(cancelAt?: string) {
const { default: component } =
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
return dialogStore.showDialog({
key: 'cancel-subscription',
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogPt
}
})
const { showCancelSubscriptionDialog: show } =
await import('@/platform/cloud/subscription/showCancelSubscriptionDialog')
return show(cancelAt)
}
async function launchCancellationFlow(cancelAt?: string): Promise<void> {
const { launchCancellationFlow: launch } =
await import('@/platform/cloud/subscription/launchCancellationFlow')
return launch(cancelAt)
}
/** Shows one-time cloud notification modal for macOS desktop users. */
@@ -663,6 +654,7 @@ export const useDialogService = () => {
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog
showCancelSubscriptionDialog,
launchCancellationFlow
}
}

View File

@@ -30,6 +30,10 @@ const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
// Churnkey cancellation flow (cloud distribution only). The embed script
// itself is loaded on demand by churnkeyClient.ts when the flow launches.
const CHURNKEY_APP_ID = process.env.CHURNKEY_APP_ID || ''
// Open Graph / Twitter Meta Tags Constants
const VITE_OG_URL = 'https://cloud.comfy.org'
const VITE_OG_TITLE =
@@ -628,6 +632,7 @@ export default defineConfig({
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''),
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__CHURNKEY_APP_ID__: JSON.stringify(CHURNKEY_APP_ID),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)