mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
15 Commits
codex/cove
...
pysssss/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04624eb8de | ||
|
|
cb3765ba8f | ||
|
|
7546a6e960 | ||
|
|
b40974868a | ||
|
|
7b638a995a | ||
|
|
6dc7e6f4a2 | ||
|
|
0d16721eb9 | ||
|
|
8957d61a32 | ||
|
|
2f652aab92 | ||
|
|
10b89ee889 | ||
|
|
4f33013411 | ||
|
|
78421d9990 | ||
|
|
b02d2fea85 | ||
|
|
8fb5f49505 | ||
|
|
218b3cb260 |
@@ -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[]
|
||||
}
|
||||
|
||||
async function stubChurnkey(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const w = window as ChurnkeyStubWindow
|
||||
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: () => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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.use({ timezoneId: 'UTC' })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new CancelSubscriptionDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test.describe('app id not set', () => {
|
||||
test('routes to the legacy cancel dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.overrideFlags({
|
||||
churnkey_app_id: ''
|
||||
})
|
||||
|
||||
await dialog.open(CANCEL_AT)
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.root).toContainText('December 31, 2026')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('app id set', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.overrideFlags({
|
||||
churnkey_app_id: STUB_APP_ID
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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_APP_ID = 'churnkey_app_id',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
@@ -162,6 +163,14 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
},
|
||||
get churnkeyAppId() {
|
||||
if (!isCloud) return ''
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.CHURNKEY_APP_ID,
|
||||
remoteConfig.value.churnkey_app_id,
|
||||
''
|
||||
)
|
||||
},
|
||||
get showSignInButton(): boolean | undefined {
|
||||
return api.getServerFeature<boolean | undefined>(
|
||||
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,
|
||||
|
||||
@@ -2543,7 +2543,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.",
|
||||
@@ -2575,6 +2575,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",
|
||||
|
||||
238
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal file
238
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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 featureFlags = vi.hoisted(() => ({ churnkeyAppId: '' }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: featureFlags })
|
||||
}))
|
||||
|
||||
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']>
|
||||
|
||||
const AUTH_RESPONSE = {
|
||||
customer_id: 'cus_123',
|
||||
auth_hash: 'hash_abc',
|
||||
mode: 'test'
|
||||
} as const
|
||||
|
||||
describe('churnkeyClient', () => {
|
||||
beforeEach(() => {
|
||||
featureFlags.churnkeyAppId = 'app-test-123'
|
||||
getChurnkeyAuth.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.churnkey
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports isConfigured=false when the churnkey_app_id flag is unset', () => {
|
||||
featureFlags.churnkeyAppId = ''
|
||||
expect(isChurnkeyConfigured()).toBe(false)
|
||||
})
|
||||
|
||||
it('reports isConfigured=true when the churnkey_app_id flag is set', () => {
|
||||
featureFlags.churnkeyAppId = 'app-from-flag'
|
||||
expect(isChurnkeyConfigured()).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when the churnkey_app_id flag is unset', async () => {
|
||||
featureFlags.churnkeyAppId = ''
|
||||
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('passes the churnkey_app_id flag value as the init config appId', async () => {
|
||||
const init = vi.fn<ChurnkeyInit>()
|
||||
window.churnkey = { init }
|
||||
featureFlags.churnkeyAppId = 'app-from-flag'
|
||||
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
|
||||
|
||||
const session = await prepareChurnkey()
|
||||
void session.show({})
|
||||
|
||||
expect(init.mock.calls[0][1]).toMatchObject({ appId: 'app-from-flag' })
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/platform/cloud/churnkey/churnkeyClient.ts
Normal file
140
src/platform/cloud/churnkey/churnkeyClient.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
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 {
|
||||
return useFeatureFlags().flags.churnkeyAppId
|
||||
}
|
||||
|
||||
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. It 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 (churnkey_app_id flag is unset)'
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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 isChurnkeyConfiguredMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./showCancelSubscriptionDialog', () => ({
|
||||
showCancelSubscriptionDialog
|
||||
}))
|
||||
|
||||
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()
|
||||
isChurnkeyConfiguredMock.mockReset()
|
||||
})
|
||||
|
||||
it('launches Churnkey when the churnkey_app_id flag is set', async () => {
|
||||
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 churnkey_app_id flag is not set', async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
isChurnkeyConfiguredMock.mockReturnValue(true)
|
||||
launchChurnkeyCancellationMock.mockRejectedValue(
|
||||
new Error('something else')
|
||||
)
|
||||
|
||||
await expect(launchCancellationFlow('2026-12-01')).rejects.toThrow(
|
||||
'something else'
|
||||
)
|
||||
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
39
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal file
39
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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 {
|
||||
if (isChurnkeyConfigured()) return true
|
||||
console.info(
|
||||
'[Churnkey] Using legacy cancel dialog: churnkey_app_id flag is not set.'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
export async function launchCancellationFlow(cancelAt?: string): Promise<void> {
|
||||
if (!shouldUseChurnkey()) {
|
||||
await showCancelSubscriptionDialog(cancelAt)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await launchChurnkeyCancellation()
|
||||
} catch (err) {
|
||||
const fallbackReason =
|
||||
err instanceof ChurnkeyAuthUnavailableError
|
||||
? 'auth endpoint unavailable'
|
||||
: err instanceof ChurnkeyEmbedLoadError
|
||||
? 'embed script failed to load (often blocked by an ad blocker)'
|
||||
: null
|
||||
if (fallbackReason === null) throw err
|
||||
console.warn(
|
||||
`[Churnkey] Falling back to legacy cancel dialog: ${fallbackReason}.`
|
||||
)
|
||||
await showCancelSubscriptionDialog(cancelAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { workspaceDialogProps } from '@/platform/workspace/components/dialogs/workspaceDialogProps'
|
||||
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: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -109,6 +109,7 @@ export type RemoteConfig = {
|
||||
workflow_sharing_enabled?: boolean
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
churnkey_app_id?: string
|
||||
unified_cloud_auth?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
CancellationFlowClosedMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -268,4 +269,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +488,54 @@ 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()
|
||||
})
|
||||
|
||||
it('does not stamp the person property when the closed event is disabled', async () => {
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
telemetry_disabled_events: [TelemetryEvents.CANCELLATION_FLOW_CLOSED]
|
||||
}
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackCancellationFlowClosed({ outcome: 'reconsidered' })
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
|
||||
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
|
||||
expect.anything()
|
||||
)
|
||||
expect(hoisted.mockPeopleSet).not.toHaveBeenCalledWith({
|
||||
cancellation_reconsidered_at: expect.any(String)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -536,4 +537,24 @@ 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
|
||||
if (this.disabledEvents.has(TelemetryEvents.CANCELLATION_FLOW_CLOSED))
|
||||
return
|
||||
try {
|
||||
this.posthog.people.set({
|
||||
cancellation_reconsidered_at: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set PostHog user property:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +452,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
|
||||
@@ -564,6 +576,10 @@ export interface TelemetryProvider {
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
|
||||
// Cancellation flow events
|
||||
trackCancellationFlowOpened?(): void
|
||||
trackCancellationFlowClosed?(metadata: CancellationFlowClosedMetadata): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -660,7 +676,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 =
|
||||
@@ -709,3 +729,4 @@ export type TelemetryEventProperties =
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
| CancellationFlowClosedMetadata
|
||||
|
||||
@@ -335,6 +335,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,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import type { ChurnkeyMode } from '@/platform/cloud/churnkey/types'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
WorkspaceId,
|
||||
@@ -214,6 +215,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
|
||||
@@ -775,6 +783,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
|
||||
|
||||
@@ -76,7 +76,7 @@ const mockManageSubscription = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockResubscribe = vi.fn()
|
||||
const mockShowLeaveWorkspaceDialog = vi.fn()
|
||||
const mockShowCancelSubscriptionDialog = vi.fn()
|
||||
const mockLaunchCancellationFlow = vi.fn()
|
||||
const mockShowEditWorkspaceDialog = vi.fn()
|
||||
const mockShowDeleteWorkspaceDialog = vi.fn()
|
||||
|
||||
@@ -198,7 +198,7 @@ vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showCancelSubscriptionDialog: mockShowCancelSubscriptionDialog,
|
||||
launchCancellationFlow: mockLaunchCancellationFlow,
|
||||
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
|
||||
showEditWorkspaceDialog: mockShowEditWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog
|
||||
@@ -635,7 +635,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel plan' }))
|
||||
expect(mockShowCancelSubscriptionDialog).toHaveBeenCalledOnce()
|
||||
expect(mockLaunchCancellationFlow).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('enables Delete for the original owner once the plan is cancelled', () => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const SELF_STYLED_PANEL_CONTENT_CLASS =
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
|
||||
|
||||
/**
|
||||
* Reka chrome shared by headless workspace dialogs whose content draws its
|
||||
* own panel — neutralize the DialogContent box and shrink-wrap it around the
|
||||
* content.
|
||||
*/
|
||||
export const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
@@ -26,7 +26,7 @@ export function useWorkspaceMenuItems() {
|
||||
deleteDisabledTooltipKey
|
||||
} = useWorkspaceUI()
|
||||
const {
|
||||
showCancelSubscriptionDialog,
|
||||
launchCancellationFlow,
|
||||
showEditWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showLeaveWorkspaceDialog
|
||||
@@ -37,7 +37,7 @@ export function useWorkspaceMenuItems() {
|
||||
}
|
||||
|
||||
function cancelSubscription() {
|
||||
void showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
void launchCancellationFlow(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
|
||||
function deleteWorkspace() {
|
||||
|
||||
@@ -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 { workspaceDialogProps } from '@/platform/workspace/components/dialogs/workspaceDialogProps'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -456,12 +457,6 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
@@ -612,16 +607,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: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,6 +728,7 @@ export const useDialogService = () => {
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog,
|
||||
launchCancellationFlow,
|
||||
showDowngradeToPersonalDialog
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user