Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
97a7c3d52d feat: tag checkout platform source 2026-07-01 09:49:20 -07:00
9 changed files with 228 additions and 96 deletions

View File

@@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -136,7 +137,8 @@ export const useAuthActions = () => {
const response = await authStore.initiateCreditPurchase({
amount_micros: usdToMicros(amount),
currency: 'usd'
currency: 'usd',
platform_source: getCheckoutPlatformSource()
})
if (!response.checkout_url) {

View File

@@ -409,6 +409,7 @@ export interface PageViewMetadata {
}
export interface CheckoutAttributionMetadata {
platform_source?: PlatformSource
ga_client_id?: string
ga_session_id?: string
ga_session_number?: string
@@ -424,6 +425,8 @@ export interface CheckoutAttributionMetadata {
wbraid?: string
}
export type PlatformSource = 'cloud' | 'desktop_cloud' | 'desktop_local'
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
}
}))
import { getCheckoutPlatformSource } from '../platformSource'
describe('getCheckoutPlatformSource', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
window.localStorage.clear()
window.history.pushState({}, '', '/')
})
it('classifies cloud checkout launched from desktop', () => {
distribution.isCloud = true
window.history.pushState({}, '', '/pricing?utm_source=comfy.desktop')
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
})
it('classifies direct cloud checkout', () => {
distribution.isCloud = true
expect(getCheckoutPlatformSource()).toBe('cloud')
})
it('classifies cloud checkout from persisted desktop attribution', () => {
distribution.isCloud = true
window.localStorage.setItem(
'comfy_checkout_attribution',
JSON.stringify({ utm_source: 'comfy.desktop' })
)
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
})
it('prefers current URL attribution over stored attribution', () => {
distribution.isCloud = true
window.localStorage.setItem(
'comfy_checkout_attribution',
JSON.stringify({ utm_source: 'comfy.desktop' })
)
window.history.pushState({}, '', '/pricing?utm_source=direct')
expect(getCheckoutPlatformSource()).toBe('cloud')
})
it('classifies desktop local checkout', () => {
distribution.isDesktop = true
expect(getCheckoutPlatformSource()).toBe('desktop_local')
})
it('does not classify OSS browser checkout', () => {
expect(getCheckoutPlatformSource()).toBeUndefined()
})
})

View File

@@ -1,7 +1,15 @@
import { isPlainObject } from 'es-toolkit'
import { withTimeout } from 'es-toolkit/promise'
import type { CheckoutAttributionMetadata } from '../types'
import type { AttributionQueryKey } from './checkoutAttributionStorage'
import {
asNonEmptyString,
hasAttributionChanges,
persistAttribution,
readAttributionFromUrl,
readStoredAttribution
} from './checkoutAttributionStorage'
import { getCheckoutPlatformSource } from './platformSource'
type GaIdentity = {
client_id?: string
@@ -16,99 +24,10 @@ const GA_IDENTITY_FIELDS = [
] as const satisfies ReadonlyArray<GtagGetFieldName>
type GaIdentityField = GtagGetFieldName
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'gbraid',
'wbraid'
] as const
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
const GET_GA_IDENTITY_TIMEOUT_MS = 300
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isPlainObject(parsed)) return {}
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = asNonEmptyString(parsed[key])
if (value) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
function persistAttribution(
payload: Partial<Record<AttributionQueryKey, string>>
): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
} catch {
return
}
}
function readAttributionFromUrl(
search: string
): Partial<Record<AttributionQueryKey, string>> {
const params = new URLSearchParams(search)
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = params.get(key)
if (value) {
result[key] = value
}
}
return result
}
function hasAttributionChanges(
existing: Partial<Record<AttributionQueryKey, string>>,
incoming: Partial<Record<AttributionQueryKey, string>>
): boolean {
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = incoming[key]
if (value !== undefined && existing[key] !== value) {
return true
}
}
return false
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
async function getGaIdentityField(
measurementId: string,
fieldName: GaIdentityField
@@ -245,6 +164,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
return {
...attribution,
platform_source: getCheckoutPlatformSource(),
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number,

View File

@@ -0,0 +1,95 @@
import { isPlainObject } from 'es-toolkit'
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'gbraid',
'wbraid'
] as const
export type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
export function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function readStoredAttribution(): Partial<
Record<AttributionQueryKey, string>
> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isPlainObject(parsed)) return {}
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = asNonEmptyString(parsed[key])
if (value) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
export function persistAttribution(
payload: Partial<Record<AttributionQueryKey, string>>
): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
} catch {
return
}
}
export function readAttributionFromUrl(
search: string
): Partial<Record<AttributionQueryKey, string>> {
const params = new URLSearchParams(search)
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = params.get(key)
if (value) {
result[key] = value
}
}
return result
}
export function hasAttributionChanges(
existing: Partial<Record<AttributionQueryKey, string>>,
incoming: Partial<Record<AttributionQueryKey, string>>
): boolean {
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = incoming[key]
if (value !== undefined && existing[key] !== value) {
return true
}
}
return false
}

View File

@@ -0,0 +1,20 @@
import { isCloud, isDesktop } from '@/platform/distribution/types'
import type { PlatformSource } from '../types'
import {
readAttributionFromUrl,
readStoredAttribution
} from './checkoutAttributionStorage'
export function getCheckoutPlatformSource(): PlatformSource | undefined {
if (typeof window === 'undefined') return undefined
if (isCloud) {
const fromUrl = readAttributionFromUrl(window.location.search)
const source = fromUrl.utm_source ?? readStoredAttribution().utm_source
return source === 'comfy.desktop' ? 'desktop_cloud' : 'cloud'
}
return isDesktop ? 'desktop_local' : undefined
}

View File

@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAxiosInstance,
mockGetAuthHeaderOrThrow,
mockGetFirebaseAuthHeaderOrThrow
mockGetFirebaseAuthHeaderOrThrow,
mockGetCheckoutPlatformSource
} = vi.hoisted(() => ({
mockAxiosInstance: {
get: vi.fn(),
@@ -13,7 +14,8 @@ const {
interceptors: { response: { use: vi.fn() } }
},
mockGetAuthHeaderOrThrow: vi.fn(),
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
mockGetFirebaseAuthHeaderOrThrow: vi.fn(),
mockGetCheckoutPlatformSource: vi.fn()
}))
vi.mock('axios', () => ({
@@ -47,6 +49,10 @@ vi.mock('@/stores/authStore', () => ({
})
}))
vi.mock('@/platform/telemetry/utils/platformSource', () => ({
getCheckoutPlatformSource: mockGetCheckoutPlatformSource
}))
import { workspaceApi } from './workspaceApi'
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
@@ -56,6 +62,7 @@ describe('workspaceApi', () => {
vi.clearAllMocks()
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
mockGetFirebaseAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
mockGetCheckoutPlatformSource.mockReturnValue('desktop_cloud')
})
describe('authentication', () => {
@@ -367,6 +374,7 @@ describe('workspaceApi', () => {
plan_slug: 'pro-monthly',
return_url: 'https://return.url',
cancel_url: 'https://cancel.url',
platform_source: 'desktop_cloud',
team_credit_stop_id: undefined,
billing_cycle: undefined
},
@@ -390,6 +398,7 @@ describe('workspaceApi', () => {
plan_slug: 'team_per_credit_annual',
return_url: undefined,
cancel_url: undefined,
platform_source: 'desktop_cloud',
team_credit_stop_id: 'team_700',
billing_cycle: 'yearly'
},
@@ -457,7 +466,11 @@ describe('workspaceApi', () => {
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/topup',
{ amount_cents: 1000, idempotency_key: 'key-3' },
{
amount_cents: 1000,
idempotency_key: 'key-3',
platform_source: 'desktop_cloud'
},
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)

View File

@@ -2,6 +2,8 @@ import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type { PlatformSource } from '@/platform/telemetry/types'
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
import type {
WorkspaceId,
WorkspaceInviteId
@@ -161,6 +163,7 @@ interface SubscribeRequest {
idempotency_key?: string
return_url?: string
cancel_url?: string
platform_source?: PlatformSource
/** Required for the per-credit Team plan; selects the slider stop. */
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
@@ -283,6 +286,7 @@ export interface BillingBalanceResponse {
interface CreateTopupRequest {
amount_cents: number
idempotency_key?: string
platform_source?: PlatformSource
}
type TopupStatus = 'pending' | 'completed' | 'failed'
@@ -660,6 +664,7 @@ export const workspaceApi = {
plan_slug: planSlug,
return_url: options.returnUrl,
cancel_url: options.cancelUrl,
platform_source: getCheckoutPlatformSource(),
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
} satisfies SubscribeRequest,
@@ -746,7 +751,8 @@ export const workspaceApi = {
api.apiURL('/billing/topup'),
{
amount_cents: amountCents,
idempotency_key: idempotencyKey
idempotency_key: idempotencyKey,
platform_source: getCheckoutPlatformSource()
} satisfies CreateTopupRequest,
{ headers }
)

View File

@@ -30,6 +30,7 @@ import {
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import type { PlatformSource } from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
@@ -40,7 +41,9 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
type CreditPurchaseResponse =
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
type CreditPurchasePayload =
operations['InitiateCreditPurchase']['requestBody']['content']['application/json']
operations['InitiateCreditPurchase']['requestBody']['content']['application/json'] & {
platform_source?: PlatformSource
}
type CreateCustomerResponse =
operations['createCustomer']['responses']['201']['content']['application/json']