Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(

try {
if (isActiveSubscription.value) {
const checkoutAttribution = getCheckoutAttribution()
const checkoutAttribution = await getCheckoutAttribution()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same risk: attribution failure surfaces as a user-facing error on subscription change.

wrapWithErrorHandlingAsync will catch and display the error, but users shouldn't see an error toast because a non-essential telemetry call failed. Wrap defensively, consistent with the fix suggested in subscriptionCheckoutUtil.ts.

🛡️ Proposed fix
-      const checkoutAttribution = await getCheckoutAttribution()
+      const checkoutAttribution = await getCheckoutAttribution().catch(
+        () => ({})
+      )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const checkoutAttribution = await getCheckoutAttribution()
const checkoutAttribution = await getCheckoutAttribution().catch(
() => ({})
)
🤖 Prompt for AI Agents
In `@src/platform/cloud/subscription/components/PricingTable.vue` at line 417, The
call to getCheckoutAttribution in PricingTable.vue can throw and currently
bubbles up through wrapWithErrorHandlingAsync causing a user-facing error; wrap
the call in a local try/catch (around the getCheckoutAttribution call that
assigns checkoutAttribution) so any failure is swallowed for UX purposes: on
catch set checkoutAttribution to undefined (or a safe default) and log/debug the
error internally (or send non-blocking telemetry), but do not rethrow or surface
a toast; mirror the defensive pattern used in subscriptionCheckoutUtil.ts to
keep this telemetry call non-fatal.

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 Down Expand Up @@ -84,6 +89,10 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))

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

vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
Expand Down Expand Up @@ -284,6 +293,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
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 { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
Expand Down Expand Up @@ -231,6 +232,7 @@ function useSubscriptionInternal() {
t('toastMessages.userNotAuthenticated')
)
}
const checkoutAttribution = await getCheckoutAttribution()

const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
Expand All @@ -239,7 +241,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 @@ -93,6 +97,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 +115,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 +128,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 @@ -49,7 +49,16 @@ export async function performSubscriptionCheckout(
}

const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
const checkoutAttribution = getCheckoutAttribution()
let checkoutAttribution: Awaited<ReturnType<typeof getCheckoutAttribution>> =
{}
try {
checkoutAttribution = await getCheckoutAttribution()
} 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