Compare commits

...

5 Commits

Author SHA1 Message Date
Glary-Bot
4f33013411 style(churnkey): scope disclaimer + use focus-visible for inputs
- Scope '.active-discount-disclaimer' to '#ck-app' so it can't leak
  into non-Churnkey UI that happens to use the same class name.
- Switch the embed input/select/textarea focus rules to ':focus-visible'
  with a higher-contrast ring, so keyboard users get a clear focus
  indicator instead of the dropped outline.
2026-05-14 19:22:04 +00:00
pythongosssss
78421d9990 wip: update churnkey cancellation flow
- add styling to churnkey ui elements
- handle missing 404 endpoint
- extract dialog display to platform layer to prevent circular dep
- update env var
2026-05-14 12:11:04 -07:00
Glary-Bot
b02d2fea85 test(billing): tighten Churnkey routing test cleanup
- Move console.warn restore into afterEach so it survives assertion
  failures, and assert the warning carries VITE_CHURNKEY_APP_ID rather
  than just any string.
- Drop the redundant import.meta.env restore in useChurnkey.test.ts;
  vi.unstubAllEnvs() already handles it.
- Flatten launchCancellationFlow with early returns to reduce nesting.
2026-05-09 02:52:46 +00:00
Glary-Bot
8fb5f49505 fix(billing): address Churnkey review feedback
- Fall back to the legacy cancellation dialog when the feature flag is
  enabled but VITE_CHURNKEY_APP_ID is not set, so a partially configured
  rollout never breaks cancellation.
- Emit cancellation_flow_closed exactly once per session (in onClose),
  carrying the survey_response captured in onCancel.
- Move the canceled/survey state into the launcher closure to avoid
  leaking module-level state across concurrent sessions.
- Stop rethrowing after the user-facing toast is shown; the only caller
  uses void launchCancellationFlow(...) and would otherwise produce an
  unhandled rejection on top of the toast.
- Add tests covering: routing fallback when not configured, single
  flow_closed emission on cancel, reconsidered tracking on plain close,
  and toast on embed failure.
2026-05-08 23:31:08 +00:00
Glary-Bot
218b3cb260 feat(billing): add Churnkey cancellation flow integration
Replaces the simple cancel-subscription confirmation dialog with the
Churnkey-hosted cancel flow when the churnkey_cancellation_enabled
feature flag is on, so we can collect survey data and run cancellation
A/B tests through the Churnkey dashboard.

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

View File

@@ -41,6 +41,12 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Churnkey cancellation flow App ID (cloud distribution only).
# Find it in the Churnkey dashboard: Settings > Organization > Cancel Flow API Keys.
# When unset, the Churnkey embed script is not injected and the legacy
# cancellation flow is used regardless of feature flag state.
# CHURNKEY_APP_ID=
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

9
global.d.ts vendored
View File

@@ -4,6 +4,7 @@ declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __CHURNKEY_APP_ID__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {
@@ -29,7 +30,15 @@ interface GtagFunction {
(...args: unknown[]): void
}
interface ChurnkeyWindow {
created?: boolean
init: (action: 'show' | 'restart', config: object) => void
hide?: () => void
clearState?: () => void
}
interface Window {
churnkey?: ChurnkeyWindow
__CONFIG__: {
gtm_container_id?: string
ga_measurement_id?: string

View File

@@ -27,6 +27,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'
}
@@ -158,6 +159,14 @@ export function useFeatureFlags() {
false
)
},
get churnkeyCancellationEnabled() {
if (!isCloud) return false
return resolveFlag(
ServerFeatureFlag.CHURNKEY_CANCELLATION_ENABLED,
remoteConfig.value.churnkey_cancellation_enabled,
false
)
},
get showSignInButton(): boolean | undefined {
return api.getServerFeature<boolean | undefined>(
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,

View File

@@ -0,0 +1,185 @@
#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;
}
.ck-style {
--color-brand-black: var(--base-foreground);
}
.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;
}
.ck-style .text-gray-900,
.ck-style .text-gray-800 {
color: var(--base-foreground) !important;
}
.ck-style .text-gray-700,
.ck-style .text-gray-600,
.ck-style .text-gray-500 {
color: var(--muted-foreground) !important;
}
.ck-style .text-brand-black {
color: var(--base-foreground) !important;
}
.ck-style .border-gray-100,
.ck-style .border-gray-200,
.ck-style .border-gray-300 {
border-color: var(--border-default) !important;
}
.ck-style .bg-gray-100,
.ck-style .bg-gray-200,
.ck-style .bg-gray-300 {
background-color: var(--secondary-background) !important;
}
.ck-style .text-opacity-60,
.ck-style .text-opacity-80,
.ck-style .text-opacity-90 {
--tw-text-opacity: 1 !important;
}
.ck-style [class*='bg-client-primary-light'] {
background-color: var(--secondary-background) !important;
}
.ck-style .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-style .h-14.rounded-t-lg {
background-color: var(--secondary-background) !important;
}
.ck-style .bg-client-primary {
background-color: var(--primary-background) !important;
}
.ck-style .text-client-primary,
.ck-style .text-client-primary-light {
color: var(--muted-foreground) !important;
}
.ck-style .text-client-primary-middle {
color: var(--muted-foreground) !important;
opacity: 0.4 !important;
}
.ck-style .border-client-primary {
border-color: var(--primary-background) !important;
}
.ck-style .border-client-primary-light {
border-color: var(--border-default) !important;
}
.ck-style .ck-primary-button {
background: var(--primary-background) !important;
color: var(--base-foreground) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 600 !important;
}
.ck-style .ck-primary-button:hover {
background: var(--primary-background-hover) !important;
}
.ck-style .ck-black-primary-button {
background: var(--primary-background) !important;
color: var(--base-foreground) !important;
border: 1px solid var(--border-default) !important;
border-radius: 8px !important;
}
.ck-style .ck-black-primary-button:hover {
background: var(--primary-background-hover) !important;
}
.ck-style .ck-gray-primary-button {
background: var(--secondary-background) !important;
color: var(--muted-foreground) !important;
border: 1px solid var(--border-default) !important;
border-radius: 8px !important;
}
.ck-style .ck-text-button,
.ck-style .ck-black-text-button {
color: var(--muted-foreground) !important;
}

View File

@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const cancelSubscription = vi.fn()
const fetchStatus = vi.fn()
const trackCancellationFlowOpened = vi.fn()
const trackCancellationFlowClosed = vi.fn()
const trackCancellationReconsidered = vi.fn()
const trackMonthlySubscriptionCancelled = vi.fn()
const toastAdd = vi.fn()
const churnkeyShow = vi.fn()
const isConfiguredRef = { value: true }
const subscriptionRef: {
value: {
tier: string | null
duration: string | null
planSlug: string | null
} | null
} = { value: null }
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
cancelSubscription,
fetchStatus,
subscription: {
get value() {
return subscriptionRef.value
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackCancellationFlowOpened,
trackCancellationFlowClosed,
trackCancellationReconsidered,
trackMonthlySubscriptionCancelled
})
}))
class FakeAuthUnavailableError extends Error {
constructor() {
super('Churnkey auth endpoint not available')
this.name = 'ChurnkeyAuthUnavailableError'
}
}
vi.mock('./useChurnkey', () => ({
useChurnkey: () => ({
get isConfigured() {
return isConfiguredRef.value
},
show: churnkeyShow
}),
ChurnkeyAuthUnavailableError: FakeAuthUnavailableError
}))
const { launchChurnkeyCancellation } =
await import('./launchChurnkeyCancellation')
interface CapturedHandlers {
customerAttributes?: Record<string, string>
handleCancel: () => Promise<{ message?: string }>
onCancel: (surveyResponse: string) => void
onClose: (results: Record<string, unknown>) => void
}
function captureHandlers(): CapturedHandlers {
const opts = churnkeyShow.mock.calls.at(-1)?.[0]
if (!opts) throw new Error('churnkey.show was not called')
return opts as CapturedHandlers
}
describe('launchChurnkeyCancellation', () => {
beforeEach(() => {
isConfiguredRef.value = true
subscriptionRef.value = null
churnkeyShow.mockReset()
churnkeyShow.mockResolvedValue(undefined)
cancelSubscription.mockReset()
cancelSubscription.mockResolvedValue(undefined)
fetchStatus.mockReset()
fetchStatus.mockResolvedValue(undefined)
trackCancellationFlowOpened.mockReset()
trackCancellationFlowClosed.mockReset()
trackCancellationReconsidered.mockReset()
trackMonthlySubscriptionCancelled.mockReset()
toastAdd.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('emits exactly one cancellation_flow_closed when the user cancels', async () => {
await launchChurnkeyCancellation()
const handlers = captureHandlers()
await handlers.handleCancel()
handlers.onCancel('too_expensive')
handlers.onClose({ status: 'canceled' })
expect(trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
expect(trackMonthlySubscriptionCancelled).toHaveBeenCalledTimes(1)
expect(trackCancellationReconsidered).not.toHaveBeenCalled()
})
it('records reconsidered when the user closes without canceling', async () => {
await launchChurnkeyCancellation()
const handlers = captureHandlers()
handlers.onClose({ status: 'closed' })
expect(trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'reconsidered'
})
expect(trackCancellationReconsidered).toHaveBeenCalledTimes(1)
})
it('maps Churnkey discounted status to discounted outcome', async () => {
await launchChurnkeyCancellation()
const handlers = captureHandlers()
handlers.onClose({ status: 'discounted' })
expect(trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'discounted'
})
expect(trackCancellationReconsidered).not.toHaveBeenCalled()
})
it('maps Churnkey paused status to paused outcome', async () => {
await launchChurnkeyCancellation()
const handlers = captureHandlers()
handlers.onClose({ status: 'paused' })
expect(trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'paused'
})
expect(trackCancellationReconsidered).not.toHaveBeenCalled()
})
it('does not abort cancellation success when fetchStatus fails', async () => {
fetchStatus.mockRejectedValue(new Error('network'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await launchChurnkeyCancellation()
const handlers = captureHandlers()
const result = await handlers.handleCancel()
expect(result).toEqual({ message: 'subscription.cancelSuccess' })
expect(trackMonthlySubscriptionCancelled).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalled()
})
it('forwards customerAttributes from billing subscription', async () => {
subscriptionRef.value = {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: 'pro-monthly'
}
await launchChurnkeyCancellation()
const handlers = captureHandlers()
expect(handlers.customerAttributes).toEqual({
tier: 'PRO',
cycle: 'MONTHLY',
plan_slug: 'pro-monthly'
})
})
it('omits customerAttributes when subscription is null', async () => {
await launchChurnkeyCancellation()
const handlers = captureHandlers()
expect(handlers.customerAttributes).toBeUndefined()
})
it('throws when churnkey is not configured', async () => {
isConfiguredRef.value = false
await expect(launchChurnkeyCancellation()).rejects.toThrow(
'Churnkey is not configured'
)
expect(churnkeyShow).not.toHaveBeenCalled()
})
it('re-throws ChurnkeyAuthUnavailableError and skips toast/close', async () => {
churnkeyShow.mockRejectedValue(new FakeAuthUnavailableError())
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
FakeAuthUnavailableError
)
expect(toastAdd).not.toHaveBeenCalled()
expect(trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('shows a toast and emits a balancing closed event when show() rejects', async () => {
churnkeyShow.mockRejectedValue(new Error('embed failed'))
await expect(launchChurnkeyCancellation()).resolves.toBeUndefined()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'embed failed'
})
)
expect(trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
expect(trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'unknown',
failure_reason: 'unexpected'
})
})
it('tags embed-load failures with the embed_not_loaded failure_reason', async () => {
churnkeyShow.mockRejectedValue(
new Error('Churnkey embed script has not loaded')
)
await launchChurnkeyCancellation()
expect(trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'unknown',
failure_reason: 'embed_not_loaded'
})
})
it('releases the in-flight guard via try/finally when show() rejects', async () => {
churnkeyShow.mockRejectedValueOnce(new Error('embed failed'))
await launchChurnkeyCancellation()
// A fresh call after the failure should proceed (guard cleared).
churnkeyShow.mockReset()
churnkeyShow.mockResolvedValue(undefined)
await launchChurnkeyCancellation()
expect(churnkeyShow).toHaveBeenCalledTimes(1)
})
it('ignores concurrent calls while a session is in flight', async () => {
let resolveShow: (() => void) | undefined
churnkeyShow.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveShow = resolve
})
)
const first = launchChurnkeyCancellation()
await Promise.resolve()
const second = launchChurnkeyCancellation()
expect(churnkeyShow).toHaveBeenCalledTimes(1)
expect(trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
resolveShow?.()
await first
await second
// try/finally cleared the guard when show() resolved; a fresh call proceeds.
churnkeyShow.mockReset()
churnkeyShow.mockResolvedValue(undefined)
await launchChurnkeyCancellation()
expect(churnkeyShow).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,143 @@
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import type { CancellationFlowClosedMetadata } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ChurnkeySessionResults } from './types'
import { ChurnkeyAuthUnavailableError, useChurnkey } from './useChurnkey'
type CancellationOutcome = CancellationFlowClosedMetadata['outcome']
type FailureReason = NonNullable<
CancellationFlowClosedMetadata['failure_reason']
>
function deriveOutcome(
results: ChurnkeySessionResults,
canceledThisSession: boolean
): CancellationOutcome {
if (canceledThisSession) return 'canceled'
if (results.status === 'closed') return 'reconsidered'
return results.status ?? 'unknown'
}
function classifyFailure(err: unknown): FailureReason {
if (err instanceof Error) {
if (err.message.includes('embed script has not loaded')) {
return 'embed_not_loaded'
}
if (err.message.includes('Churnkey is not configured')) {
return 'auth_unavailable'
}
}
return 'unexpected'
}
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
const churnkey = useChurnkey()
if (!churnkey.isConfigured) {
throw new Error('Churnkey is not configured')
}
inFlight = true
try {
const billing = useBillingContext()
const telemetry = useTelemetry()
const toast = useToastStore()
let canceledThisSession = false
let lastSurveyResponse: string | undefined
let closedTracked = false
function trackClosed(
outcome: CancellationOutcome,
failureReason?: FailureReason
) {
if (closedTracked) return
closedTracked = true
telemetry?.trackCancellationFlowClosed({
outcome,
...(lastSurveyResponse !== undefined && {
survey_response: lastSurveyResponse
}),
...(failureReason !== undefined && { failure_reason: failureReason })
})
if (outcome === 'reconsidered') {
telemetry?.trackCancellationReconsidered()
}
}
telemetry?.trackCancellationFlowOpened()
try {
await churnkey.show({
customerAttributes: buildCustomerAttributes(billing),
handleCancel: async () => {
try {
await billing.cancelSubscription()
} catch (err) {
const message =
err instanceof Error
? err.message
: t('subscription.cancelDialog.failed')
const wrapped = new Error(message)
if (err instanceof Error) wrapped.cause = err
throw wrapped
}
canceledThisSession = true
telemetry?.trackMonthlySubscriptionCancelled()
// Local state refresh is best-effort; failure here must not
// surface as a cancellation failure in the Churnkey embed.
try {
await billing.fetchStatus()
} catch (err) {
console.warn('[Churnkey] fetchStatus after cancel failed', err)
}
return { message: t('subscription.cancelSuccess') }
},
onCancel: (surveyResponse) => {
canceledThisSession = true
lastSurveyResponse = surveyResponse
},
onClose: (results) => {
const outcome = deriveOutcome(results, canceledThisSession)
trackClosed(outcome)
// 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?.()
}
})
} catch (err) {
if (err instanceof ChurnkeyAuthUnavailableError) {
// Re-throw so the caller can route to the legacy dialog.
throw err
}
trackClosed('unknown', classifyFailure(err))
const detail = err instanceof Error ? err.message : t('g.unknownError')
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail,
life: 5000
})
}
} finally {
inFlight = false
}
}

View File

@@ -0,0 +1,41 @@
// 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'
export 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
}

View File

@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChurnkeyAuthUnavailableError, useChurnkey } from './useChurnkey'
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getChurnkeyAuth: vi.fn()
}
}))
const { workspaceApi } = await import('@/platform/workspace/api/workspaceApi')
type GlobalWithChurnkey = typeof globalThis & {
__CHURNKEY_APP_ID__: string
}
const globalWithChurnkey = globalThis as GlobalWithChurnkey
describe('useChurnkey', () => {
let originalAppId: string
beforeEach(() => {
originalAppId = globalWithChurnkey.__CHURNKEY_APP_ID__
globalWithChurnkey.__CHURNKEY_APP_ID__ = 'app-test-123'
vi.mocked(workspaceApi.getChurnkeyAuth).mockReset()
})
afterEach(() => {
globalWithChurnkey.__CHURNKEY_APP_ID__ = originalAppId
vi.restoreAllMocks()
delete (window as { churnkey?: unknown }).churnkey
})
it('throws when the embed script has not loaded', async () => {
const { show } = useChurnkey()
await expect(
show({ handleCancel: async () => ({ message: 'ok' }) })
).rejects.toThrow(/embed script has not loaded/)
})
it('forwards customer credentials and provider config to churnkey.init', async () => {
const init = vi.fn()
;(window as { churnkey?: unknown }).churnkey = { init }
vi.mocked(workspaceApi.getChurnkeyAuth).mockResolvedValue({
customer_id: 'cus_123',
subscription_id: 'sub_456',
auth_hash: 'hash_abc',
mode: 'test'
})
const handleCancel = vi.fn().mockResolvedValue({ message: 'done' })
const onClose = vi.fn()
const { show } = useChurnkey()
await show({
handleCancel,
onClose,
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',
subscriptionId: 'sub_456',
provider: 'stripe',
mode: 'test',
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
})
await config.handleCancel('cus_123', 'too_expensive', 'feedback')
expect(handleCancel).toHaveBeenCalledWith('too_expensive', 'feedback')
config.onClose({ status: 'closed' })
expect(onClose).toHaveBeenCalledWith({ status: 'closed' })
})
it('returns isConfigured=false when CHURNKEY_APP_ID is unset', () => {
globalWithChurnkey.__CHURNKEY_APP_ID__ = ''
const churnkey = useChurnkey()
expect(churnkey.isConfigured).toBe(false)
})
it('throws ChurnkeyAuthUnavailableError when getChurnkeyAuth returns null', async () => {
;(window as { churnkey?: unknown }).churnkey = { init: vi.fn() }
vi.mocked(workspaceApi.getChurnkeyAuth).mockResolvedValue(null)
const { show } = useChurnkey()
await expect(
show({ handleCancel: async () => ({ message: 'ok' }) })
).rejects.toBeInstanceOf(ChurnkeyAuthUnavailableError)
})
})

View File

@@ -0,0 +1,75 @@
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import './embed-theme.css'
import type {
ChurnkeyHandlerResult,
ChurnkeyInitConfig,
ChurnkeySessionResults
} from './types'
function readAppId(): string {
return __CHURNKEY_APP_ID__
}
/**
* Thrown when the backend's `/billing/churnkey/auth` endpoint is missing.
*/
export class ChurnkeyAuthUnavailableError extends Error {
constructor() {
super('Churnkey auth endpoint not available')
this.name = 'ChurnkeyAuthUnavailableError'
}
}
interface ChurnkeyShowOptions {
handleCancel: (
surveyResponse: string,
freeformFeedback?: string
) => Promise<ChurnkeyHandlerResult>
onClose?: (results: ChurnkeySessionResults) => void
onCancel?: (surveyResponse: string) => void
customerAttributes?: Record<string, string | number>
}
export function useChurnkey() {
const appId = readAppId()
const isConfigured = !!appId
async function show(options: ChurnkeyShowOptions): Promise<void> {
if (!appId) {
throw new Error('Churnkey is not configured (missing CHURNKEY_APP_ID)')
}
if (typeof window === 'undefined' || !window.churnkey?.init) {
throw new Error('Churnkey embed script has not loaded')
}
const auth = await workspaceApi.getChurnkeyAuth()
if (auth === null) {
throw new ChurnkeyAuthUnavailableError()
}
const config: ChurnkeyInitConfig = {
appId,
authHash: auth.auth_hash,
customerId: auth.customer_id,
subscriptionId: auth.subscription_id,
provider: 'stripe',
mode: auth.mode,
record: true,
customerAttributes: options.customerAttributes,
handleCancel: (_customer, surveyResponse, freeformFeedback) =>
options.handleCancel(surveyResponse, freeformFeedback),
onCancel: (_customer, surveyResponse) =>
options.onCancel?.(surveyResponse),
onClose: (results) => options.onClose?.(results)
}
window.churnkey.init('show', config)
}
return {
isConfigured,
show
}
}

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const showCancelSubscriptionDialog = vi.hoisted(() => vi.fn())
const launchChurnkeyCancellationMock = vi.hoisted(() => vi.fn())
const useFeatureFlagsMock = vi.hoisted(() => vi.fn())
const useChurnkeyMock = vi.hoisted(() => vi.fn())
class FakeAuthUnavailableError extends Error {
constructor() {
super('Churnkey auth endpoint not available')
this.name = 'ChurnkeyAuthUnavailableError'
}
}
vi.mock('./showCancelSubscriptionDialog', () => ({
showCancelSubscriptionDialog
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: useFeatureFlagsMock
}))
vi.mock('@/platform/cloud/churnkey/useChurnkey', () => ({
useChurnkey: useChurnkeyMock,
ChurnkeyAuthUnavailableError: FakeAuthUnavailableError
}))
vi.mock('@/platform/cloud/churnkey/launchChurnkeyCancellation', () => ({
launchChurnkeyCancellation: launchChurnkeyCancellationMock
}))
const { launchCancellationFlow } = await import('./launchCancellationFlow')
describe('launchCancellationFlow', () => {
beforeEach(() => {
showCancelSubscriptionDialog.mockReset()
launchChurnkeyCancellationMock.mockReset()
useFeatureFlagsMock.mockReset()
useChurnkeyMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('launches Churnkey when the flag is on and the embed is configured', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
useChurnkeyMock.mockReturnValue({ isConfigured: 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(useChurnkeyMock).not.toHaveBeenCalled()
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('falls back to the legacy dialog when CHURNKEY_APP_ID is missing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
useChurnkeyMock.mockReturnValue({ isConfigured: false })
await launchCancellationFlow('2026-12-01')
expect(launchChurnkeyCancellationMock).not.toHaveBeenCalled()
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('CHURNKEY_APP_ID')
)
})
it('falls back to the legacy dialog on ChurnkeyAuthUnavailableError', async () => {
useFeatureFlagsMock.mockReturnValue({
flags: { churnkeyCancellationEnabled: true }
})
useChurnkeyMock.mockReturnValue({ isConfigured: true })
launchChurnkeyCancellationMock.mockRejectedValue(
new FakeAuthUnavailableError()
)
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 }
})
useChurnkeyMock.mockReturnValue({ isConfigured: true })
launchChurnkeyCancellationMock.mockRejectedValue(
new Error('something else')
)
await expect(launchCancellationFlow('2026-12-01')).rejects.toThrow(
'something else'
)
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,37 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { launchChurnkeyCancellation } from '@/platform/cloud/churnkey/launchChurnkeyCancellation'
import {
ChurnkeyAuthUnavailableError,
useChurnkey
} from '@/platform/cloud/churnkey/useChurnkey'
import { showCancelSubscriptionDialog } from './showCancelSubscriptionDialog'
function shouldUseChurnkey(): boolean {
const { flags } = useFeatureFlags()
if (!flags.churnkeyCancellationEnabled) return false
if (!useChurnkey().isConfigured) {
console.warn(
'[Churnkey] Cancellation flag is enabled but CHURNKEY_APP_ID is not set; falling back to legacy dialog.'
)
return false
}
return true
}
export async function launchCancellationFlow(cancelAt?: string): Promise<void> {
if (!shouldUseChurnkey()) {
await showCancelSubscriptionDialog(cancelAt)
return
}
try {
await launchChurnkeyCancellation()
} catch (err) {
if (err instanceof ChurnkeyAuthUnavailableError) {
await showCancelSubscriptionDialog(cancelAt)
return
}
throw err
}
}

View File

@@ -0,0 +1,23 @@
import { useDialogStore } from '@/stores/dialogStore'
const workspaceDialogPt = {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const
export async function showCancelSubscriptionDialog(cancelAt?: string) {
const { default: component } =
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
return useDialogStore().showDialog({
key: 'cancel-subscription',
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogPt
}
})
}

View File

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

View File

@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
CancellationFlowClosedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -241,4 +242,18 @@ 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)
)
}
trackCancellationReconsidered(): void {
this.dispatch((provider) => provider.trackCancellationReconsidered?.())
}
}

View File

@@ -9,6 +9,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
CancellationFlowClosedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -455,4 +456,26 @@ 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)
}
trackCancellationReconsidered(): void {
this.trackEvent(TelemetryEvents.CANCELLATION_RECONSIDERED)
if (this.posthog && this.isEnabled) {
try {
this.posthog.people.set({
cancellation_reconsidered_at: new Date().toISOString()
})
} catch (error) {
console.error('Failed to set PostHog user property:', error)
}
}
}
}

View File

@@ -363,6 +363,16 @@ interface EcommerceMetadata {
items: EcommerceItemMetadata[]
}
export interface CancellationFlowClosedMetadata {
outcome: 'canceled' | 'reconsidered' | 'discounted' | 'paused' | 'unknown'
survey_response?: string
failure_reason?:
| 'auth_unavailable'
| 'embed_not_loaded'
| 'cancel_api_failed'
| 'unexpected'
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
checkout_attempt_id: string
@@ -461,6 +471,11 @@ export interface TelemetryProvider {
// Page view tracking
trackPageView?(pageName: string, properties?: PageViewMetadata): void
// Cancellation flow events
trackCancellationFlowOpened?(): void
trackCancellationFlowClosed?(metadata: CancellationFlowClosedMetadata): void
trackCancellationReconsidered?(): void
}
/**
@@ -548,7 +563,12 @@ 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',
CANCELLATION_RECONSIDERED: 'app:cancellation_reconsidered'
} as const
export type TelemetryEventName =
@@ -593,3 +613,4 @@ export type TelemetryEventProperties =
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata
| CancellationFlowClosedMetadata

View File

@@ -171,6 +171,13 @@ interface PaymentPortalResponse {
url: string
}
interface ChurnkeyAuthResponse {
customer_id: string
subscription_id?: string
auth_hash: string
mode: 'live' | 'test' | 'sandbox'
}
interface PreviewPlanInfo {
slug: string
tier: SubscriptionTier
@@ -692,6 +699,28 @@ 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.
* The HMAC must be signed server-side; never derive it on the client.
*/
async getChurnkeyAuth(): Promise<ChurnkeyAuthResponse | null> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ChurnkeyAuthResponse>(
api.apiURL('/billing/churnkey/auth'),
{ headers }
)
return response.data
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
return null
}
handleAxiosError(err)
}
},
/**
* Get billing operation status
* GET /api/billing/ops/:id

View File

@@ -407,7 +407,7 @@ const {
getMaxSeats
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const { launchCancellationFlow } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
const isResubscribing = ref(false)
@@ -519,7 +519,7 @@ const planMenuItems = computed(() => [
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
void launchCancellationFlow(subscription.value?.endDate ?? undefined)
}
}
])

View File

@@ -584,16 +584,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. */
@@ -657,6 +656,7 @@ export const useDialogService = () => {
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog
showCancelSubscriptionDialog,
launchCancellationFlow
}
}

View File

@@ -30,6 +30,9 @@ 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 embed (cloud distribution only).
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 =
@@ -307,6 +310,30 @@ export default defineConfig({
}
},
// Churnkey cancellation flow embed (cloud distribution only)
{
name: 'inject-churnkey',
transformIndexHtml(html) {
if (DISTRIBUTION !== 'cloud') return html
if (!CHURNKEY_APP_ID) return html
const safeAppId = encodeURIComponent(CHURNKEY_APP_ID)
const scriptUrl = `https://assets.churnkey.co/js/app.js?appId=${safeAppId}`
const loaderScript = `!function(){if(!window.churnkey||!window.churnkey.created){window.churnkey={created:!0};var a=document.createElement("script");a.src=${JSON.stringify(scriptUrl)};a.async=!0;var b=document.getElementsByTagName("script")[0];b.parentNode.insertBefore(a,b)}}();`
return {
html,
tags: [
{
tag: 'script',
children: loaderScript,
injectTo: 'head'
}
]
}
}
},
// Twitter/Open Graph meta tags plugin (cloud distribution only)
{
name: 'inject-twitter-meta',
@@ -624,6 +651,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)