mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 01:27:23 +00:00
Compare commits
9 Commits
codex/fix-
...
pysssss/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d16721eb9 | ||
|
|
8957d61a32 | ||
|
|
2f652aab92 | ||
|
|
10b89ee889 | ||
|
|
4f33013411 | ||
|
|
78421d9990 | ||
|
|
b02d2fea85 | ||
|
|
8fb5f49505 | ||
|
|
218b3cb260 |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
144
browser_tests/tests/cloud/cancellationFlow.spec.ts
Normal file
144
browser_tests/tests/cloud/cancellationFlow.spec.ts
Normal 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
1
global.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
249
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal file
249
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
143
src/platform/cloud/churnkey/churnkeyClient.ts
Normal file
143
src/platform/cloud/churnkey/churnkeyClient.ts
Normal 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 }
|
||||
}
|
||||
199
src/platform/cloud/churnkey/embed-theme.css
Normal file
199
src/platform/cloud/churnkey/embed-theme.css
Normal 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;
|
||||
}
|
||||
25
src/platform/cloud/churnkey/errors.ts
Normal file
25
src/platform/cloud/churnkey/errors.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
394
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal file
394
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
143
src/platform/cloud/churnkey/launchChurnkeyCancellation.ts
Normal file
143
src/platform/cloud/churnkey/launchChurnkeyCancellation.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
55
src/platform/cloud/churnkey/types.ts
Normal file
55
src/platform/cloud/churnkey/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
118
src/platform/cloud/subscription/launchCancellationFlow.test.ts
Normal file
118
src/platform/cloud/subscription/launchCancellationFlow.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
35
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal file
35
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user