Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean

interface ImpactQueueFunction {
(...args: unknown[]): void
a?: unknown[][]
}

interface Window {
__CONFIG__: {
gtm_container_id?: string
Expand Down Expand Up @@ -37,6 +42,8 @@ interface Window {
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}

interface Navigator {
Expand Down
17 changes: 15 additions & 2 deletions src/platform/cloud/subscription/components/PricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptio
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'

Expand All @@ -280,6 +280,19 @@ const getCheckoutTier = (
billingCycle: BillingCycle
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)

const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

const { getCheckoutAttribution } =
await import('@/platform/telemetry/utils/checkoutAttribution')

return getCheckoutAttribution()
}

interface BillingCycleOption {
label: string
value: BillingCycle
Expand Down Expand Up @@ -415,7 +428,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(

try {
if (isActiveSubscription.value) {
const checkoutAttribution = getCheckoutAttribution()
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
mockAccessBillingPortal,
mockShowSubscriptionRequiredDialog,
mockGetAuthHeader,
mockGetCheckoutAttribution,
mockTelemetry,
mockUserId,
mockIsCloud
Expand All @@ -21,6 +22,10 @@ const {
mockGetAuthHeader: vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
),
mockGetCheckoutAttribution: vi.fn(() => ({
im_ref: 'impact-click-001',
utm_source: 'impact'
})),
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
Expand All @@ -29,6 +34,13 @@ const {
}))

let scope: ReturnType<typeof effectScope> | undefined
type Distribution = 'desktop' | 'localhost' | 'cloud'

const setDistribution = (distribution: Distribution) => {
;(
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
).__DISTRIBUTION__ = distribution
}

function useSubscriptionWithScope() {
if (!scope) {
Expand Down Expand Up @@ -84,6 +96,10 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))

vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
getCheckoutAttribution: mockGetCheckoutAttribution
}))

vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
Expand All @@ -107,11 +123,13 @@ describe('useSubscription', () => {
afterEach(() => {
scope?.stop()
scope = undefined
setDistribution('localhost')
})

beforeEach(() => {
scope?.stop()
scope = effectScope()
setDistribution('cloud')

vi.clearAllMocks()
mockIsLoggedIn.value = false
Expand Down Expand Up @@ -284,6 +302,10 @@ describe('useSubscription', () => {
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
}),
body: JSON.stringify({
im_ref: 'impact-click-001',
utm_source: 'impact'
})
})
)
Expand Down
17 changes: 16 additions & 1 deletion src/platform/cloud/subscription/composables/useSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
Expand Down Expand Up @@ -98,6 +99,18 @@ function useSubscriptionInternal() {
return `${getComfyApiBaseUrl()}${path}`
}

const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

const { getCheckoutAttribution } =
await import('@/platform/telemetry/utils/checkoutAttribution')

return getCheckoutAttribution()
}

const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
Expand Down Expand Up @@ -231,6 +244,7 @@ function useSubscriptionInternal() {
t('toastMessages.userNotAuthenticated')
)
}
const checkoutAttribution = await getCheckoutAttributionForCloud()

const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
Expand All @@ -239,7 +253,8 @@ function useSubscriptionInternal() {
headers: {
...authHeader,
'Content-Type': 'application/json'
}
},
body: JSON.stringify(checkoutAttribution)
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const {
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
Expand Down Expand Up @@ -54,6 +58,14 @@ vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({

global.fetch = vi.fn()

type Distribution = 'desktop' | 'localhost' | 'cloud'

const setDistribution = (distribution: Distribution) => {
;(
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
).__DISTRIBUTION__ = distribution
}

function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((res) => {
Expand All @@ -65,13 +77,15 @@ function createDeferred<T>() {

describe('performSubscriptionCheckout', () => {
beforeEach(() => {
setDistribution('cloud')
vi.clearAllMocks()
mockIsCloud.value = true
mockUserId.value = 'user-123'
})

afterEach(() => {
vi.restoreAllMocks()
setDistribution('localhost')
})

it('tracks begin_checkout with user id and tier metadata', async () => {
Expand All @@ -93,6 +107,10 @@ describe('performSubscriptionCheckout', () => {
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
Expand All @@ -107,6 +125,10 @@ describe('performSubscriptionCheckout', () => {
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
Expand All @@ -116,6 +138,41 @@ describe('performSubscriptionCheckout', () => {
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})

it('continues checkout when attribution collection fails', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

mockGetCheckoutAttribution.mockRejectedValueOnce(
new Error('Attribution failed')
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)

await performSubscriptionCheckout('pro', 'monthly', true)

expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
expect.any(Error)
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout/pro'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({})
})
)
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})

it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'

Expand All @@ -19,6 +19,18 @@ const getCheckoutTier = (
billingCycle: BillingCycle
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)

const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

const { getCheckoutAttribution } =
await import('@/platform/telemetry/utils/checkoutAttribution')

return getCheckoutAttribution()
}

/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
Expand Down Expand Up @@ -49,7 +61,15 @@ export async function performSubscriptionCheckout(
}

const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
const checkoutAttribution = getCheckoutAttribution()
let checkoutAttribution: CheckoutAttributionMetadata = {}
try {
checkoutAttribution = await getCheckoutAttributionForCloud()
} catch (error) {
console.warn(
'[SubscriptionCheckout] Failed to collect checkout attribution',
error
)
}
const checkoutPayload = { ...checkoutAttribution }

const response = await fetch(
Expand Down
7 changes: 5 additions & 2 deletions src/platform/telemetry/initTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,19 @@ export async function initTelemetry(): Promise<void> {
const [
{ TelemetryRegistry },
{ MixpanelTelemetryProvider },
{ GtmTelemetryProvider }
{ GtmTelemetryProvider },
{ ImpactTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
import('./providers/cloud/GtmTelemetryProvider')
import('./providers/cloud/GtmTelemetryProvider'),
import('./providers/cloud/ImpactTelemetryProvider')
])

const registry = new TelemetryRegistry()
registry.registerProvider(new MixpanelTelemetryProvider())
registry.registerProvider(new GtmTelemetryProvider())
registry.registerProvider(new ImpactTelemetryProvider())

setTelemetryRegistry(registry)
})()
Expand Down
Loading