mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
Compare commits
5 Commits
ext-api/i-
...
glary/chur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f33013411 | ||
|
|
78421d9990 | ||
|
|
b02d2fea85 | ||
|
|
8fb5f49505 | ||
|
|
218b3cb260 |
@@ -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
9
global.d.ts
vendored
@@ -4,6 +4,7 @@ declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __CHURNKEY_APP_ID__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
185
src/platform/cloud/churnkey/embed-theme.css
Normal file
185
src/platform/cloud/churnkey/embed-theme.css
Normal 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;
|
||||
}
|
||||
281
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal file
281
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
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 { 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
|
||||
}
|
||||
}
|
||||
41
src/platform/cloud/churnkey/types.ts
Normal file
41
src/platform/cloud/churnkey/types.ts
Normal 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
|
||||
}
|
||||
96
src/platform/cloud/churnkey/useChurnkey.test.ts
Normal file
96
src/platform/cloud/churnkey/useChurnkey.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
75
src/platform/cloud/churnkey/useChurnkey.ts
Normal file
75
src/platform/cloud/churnkey/useChurnkey.ts
Normal 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
|
||||
}
|
||||
}
|
||||
115
src/platform/cloud/subscription/launchCancellationFlow.test.ts
Normal file
115
src/platform/cloud/subscription/launchCancellationFlow.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
37
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal file
37
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?.())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user