From 1ac56c1eb2189d7b0bd35149ef42c2027369719c Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 1 Jul 2026 14:03:08 -0700 Subject: [PATCH 1/5] feat: attribute payment intent through paywall, checkout, and top-up telemetry --- .../topbar/CurrentUserPopoverLegacy.vue | 3 +- src/composables/billing/types.ts | 6 +- src/composables/billing/useBillingContext.ts | 5 +- src/composables/billing/useLegacyBilling.ts | 7 ++- src/composables/useCoreCommands.ts | 6 +- .../components/UploadModelUpgradeModal.vue | 2 +- .../CloudSubscriptionRedirectView.test.ts | 6 +- .../CloudSubscriptionRedirectView.vue | 7 ++- .../subscription/components/CreditsTile.vue | 4 +- .../subscription/components/PricingTable.vue | 10 +++- .../components/SubscribeButton.vue | 2 +- .../components/SubscribeToRun.vue | 2 +- .../SubscriptionPanelContentLegacy.vue | 4 +- .../SubscriptionRequiredDialogContent.vue | 6 +- .../usePricingTableUrlLoader.test.ts | 12 ---- .../composables/usePricingTableUrlLoader.ts | 2 - .../composables/useSubscription.ts | 13 +---- .../useSubscriptionActions.test.ts | 17 +++++- .../composables/useSubscriptionActions.ts | 3 + .../composables/useSubscriptionDialog.test.ts | 57 ++++++++++++++++++- .../composables/useSubscriptionDialog.ts | 34 ++++++++++- .../utils/subscriptionCheckoutTracker.ts | 14 ++++- .../utils/subscriptionCheckoutUtil.test.ts | 23 ++++++++ .../utils/subscriptionCheckoutUtil.ts | 13 ++++- .../teamSubscriptionCheckoutUtil.test.ts | 25 ++++++-- .../utils/teamSubscriptionCheckoutUtil.ts | 15 +++++ src/platform/telemetry/TelemetryRegistry.ts | 7 ++- .../cloud/PostHogTelemetryProvider.test.ts | 36 ++++++++++++ .../cloud/PostHogTelemetryProvider.ts | 10 +++- .../providers/host/HostTelemetrySink.ts | 5 +- src/platform/telemetry/types.ts | 10 +++- .../CurrentUserPopoverWorkspace.vue | 7 +-- .../SubscriptionPanelContentWorkspace.vue | 7 ++- ...bscriptionRequiredDialogContentUnified.vue | 2 +- .../InviteMemberUpsellDialogContent.vue | 5 +- .../workspace/composables/useMembersPanel.ts | 2 +- .../useSubscriptionCheckout.test.ts | 49 ++++++++++++++-- .../composables/useSubscriptionCheckout.ts | 39 ++++++++++++- .../composables/useWorkspaceBilling.ts | 7 ++- src/services/dialogService.ts | 8 +-- 40 files changed, 397 insertions(+), 95 deletions(-) diff --git a/src/components/topbar/CurrentUserPopoverLegacy.vue b/src/components/topbar/CurrentUserPopoverLegacy.vue index 54620e412bc..cf2c9723137 100644 --- a/src/components/topbar/CurrentUserPopoverLegacy.vue +++ b/src/components/topbar/CurrentUserPopoverLegacy.vue @@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => { } const handleTopUp = () => { - // Track purchase credits entry from avatar popover - useTelemetry()?.trackAddApiCreditButtonClicked() + useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' }) dialogService.showTopUpCreditsDialog() emit('close') } diff --git a/src/composables/billing/types.ts b/src/composables/billing/types.ts index fe3ef9407e5..e0be9c59967 100644 --- a/src/composables/billing/types.ts +++ b/src/composables/billing/types.ts @@ -1,5 +1,6 @@ import type { ComputedRef, Ref } from 'vue' +import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' import type { BillingStatus, @@ -75,9 +76,10 @@ export interface BillingActions { */ requireActiveSubscription: () => Promise /** - * Shows the subscription dialog. + * Shows the subscription dialog. Pass a reason so the paywall open and any + * downstream checkout stay attributed to the triggering product moment. */ - showSubscriptionDialog: () => void + showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void } export interface BillingState { diff --git a/src/composables/billing/useBillingContext.ts b/src/composables/billing/useBillingContext.ts index 209616902a5..ed7cbadec67 100644 --- a/src/composables/billing/useBillingContext.ts +++ b/src/composables/billing/useBillingContext.ts @@ -7,6 +7,7 @@ import { getTierFeatures } from '@/platform/cloud/subscription/constants/tierPricing' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { PreviewSubscribeOptions, SubscribeOptions @@ -281,8 +282,8 @@ function useBillingContextInternal(): BillingContext { return activeContext.value.requireActiveSubscription() } - function showSubscriptionDialog() { - return activeContext.value.showSubscriptionDialog() + function showSubscriptionDialog(options?: SubscriptionDialogOptions) { + return activeContext.value.showSubscriptionDialog(options) } return { diff --git a/src/composables/billing/useLegacyBilling.ts b/src/composables/billing/useLegacyBilling.ts index f8610dfe7be..d238d25b227 100644 --- a/src/composables/billing/useLegacyBilling.ts +++ b/src/composables/billing/useLegacyBilling.ts @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' import { useAuthActions } from '@/composables/auth/useAuthActions' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' +import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { BillingStatus, BillingSubscriptionStatus, @@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions { async function requireActiveSubscription(): Promise { await fetchStatus() if (!isActiveSubscription.value) { - legacyShowSubscriptionDialog() + legacyShowSubscriptionDialog({ reason: 'subscription_required' }) } } - function showSubscriptionDialog(): void { - legacyShowSubscriptionDialog() + function showSubscriptionDialog(options?: SubscriptionDialogOptions): void { + legacyShowSubscriptionDialog(options) } return { diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index c31327a6a9f..8069507bf3a 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] { }) => { trackRunButton(metadata) if (!isActiveSubscription.value) { - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'subscribe_to_run' }) return } @@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] { }) => { trackRunButton(metadata) if (!isActiveSubscription.value) { - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'subscribe_to_run' }) return } @@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] { }) => { trackRunButton(metadata) if (!isActiveSubscription.value) { - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'subscribe_to_run' }) return } diff --git a/src/platform/assets/components/UploadModelUpgradeModal.vue b/src/platform/assets/components/UploadModelUpgradeModal.vue index 2ba3e6d19b0..3526817a3d0 100644 --- a/src/platform/assets/components/UploadModelUpgradeModal.vue +++ b/src/platform/assets/components/UploadModelUpgradeModal.vue @@ -25,6 +25,6 @@ function handleClose() { } function handleSubscribe() { - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'upload_model_upgrade' }) } diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index 611213277ed..d7691886e8b 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -140,7 +140,8 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false + false, + 'deep_link' ) // Shows loading affordances @@ -169,7 +170,8 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false + false, + 'deep_link' ) }) diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue index 0f359277e25..3eb59bb3150 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -112,7 +112,12 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => { if (isActiveSubscription.value) { await accessBillingPortal(undefined, false) } else { - await performSubscriptionCheckout(tierKeyParam, billingCycle, false) + await performSubscriptionCheckout( + tierKeyParam, + billingCycle, + false, + 'deep_link' + ) } }, reportError) diff --git a/src/platform/cloud/subscription/components/CreditsTile.vue b/src/platform/cloud/subscription/components/CreditsTile.vue index d45609aed87..4c60462455b 100644 --- a/src/platform/cloud/subscription/components/CreditsTile.vue +++ b/src/platform/cloud/subscription/components/CreditsTile.vue @@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => { }) function handleAddCredits() { - telemetry?.trackAddApiCreditButtonClicked() + telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' }) void dialogService.showTopUpCreditsDialog() } function handleUpgradeToAddCredits() { - showPricingTable() + showPricingTable({ reason: 'upgrade_to_add_credits' }) } async function handleWindowFocus() { diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index e5e87423ef2..1a63f44bf0b 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -277,6 +277,7 @@ import type { TierKey, TierPricing } from '@/platform/cloud/subscription/constants/tierPricing' +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker' import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil' import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank' @@ -321,6 +322,10 @@ interface PricingTierConfig { isPopular?: boolean } +const { reason } = defineProps<{ + reason?: SubscriptionDialogReason +}>() + const emit = defineEmits<{ chooseTeamWorkspace: [] }>() @@ -469,6 +474,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync( tier: targetPlan.tierKey, cycle: targetPlan.billingCycle, checkout_type: 'change', + ...(reason ? { payment_intent_source: reason } : {}), ...checkoutAttribution, ...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}) }) @@ -498,6 +504,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync( tier: targetPlan.tierKey, cycle: targetPlan.billingCycle, checkout_type: 'change', + payment_intent_source: reason, ...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}), ...(previousPlan ? { previous_cycle: previousPlan.billingCycle } @@ -508,7 +515,8 @@ const handleSubscribe = wrapWithErrorHandlingAsync( await performSubscriptionCheckout( tierKey, currentBillingCycle.value, - true + true, + reason ) } } finally { diff --git a/src/platform/cloud/subscription/components/SubscribeButton.vue b/src/platform/cloud/subscription/components/SubscribeButton.vue index 5e7f68016aa..97955f15a37 100644 --- a/src/platform/cloud/subscription/components/SubscribeButton.vue +++ b/src/platform/cloud/subscription/components/SubscribeButton.vue @@ -56,7 +56,7 @@ const handleSubscribe = () => { current_tier: tier.value?.toLowerCase() }) isAwaitingStripeSubscription.value = true - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'subscribe_now_button' }) } onBeforeUnmount(() => { diff --git a/src/platform/cloud/subscription/components/SubscribeToRun.vue b/src/platform/cloud/subscription/components/SubscribeToRun.vue index dc8d3efb608..368601f6b27 100644 --- a/src/platform/cloud/subscription/components/SubscribeToRun.vue +++ b/src/platform/cloud/subscription/components/SubscribeToRun.vue @@ -54,6 +54,6 @@ function handleSubscribeToRun() { trackRunButton({ subscribe_to_run: true }) } - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'subscribe_to_run' }) } diff --git a/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue b/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue index 58c2cc6116b..365459fd57c 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue @@ -48,7 +48,9 @@ v-if="isActiveSubscription" variant="primary" class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary" - @click="showSubscriptionDialog" + @click=" + showSubscriptionDialog({ reason: 'settings_billing_panel' }) + " > {{ $t('subscription.upgradePlan') }} diff --git a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue index 49801371361..786ea98c8a7 100644 --- a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue +++ b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue @@ -33,7 +33,11 @@ - +
diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts index b271744f371..c91e2f8efcb 100644 --- a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts @@ -55,12 +55,6 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({ }) })) -const mockTrackSubscription = vi.hoisted(() => vi.fn()) - -vi.mock('@/platform/telemetry', () => ({ - useTelemetry: () => ({ trackSubscription: mockTrackSubscription }) -})) - describe('usePricingTableUrlLoader', () => { beforeEach(() => { vi.clearAllMocks() @@ -96,9 +90,6 @@ describe('usePricingTableUrlLoader', () => { reason: 'deep_link', planMode: undefined }) - expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', { - reason: 'deep_link' - }) expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} }) }) @@ -150,7 +141,6 @@ describe('usePricingTableUrlLoader', () => { await loadPricingTableFromUrl() expect(mockShowPricingTable).not.toHaveBeenCalled() - expect(mockTrackSubscription).not.toHaveBeenCalled() }) it('denies, strips, and clears together when the user is not eligible', async () => { @@ -161,7 +151,6 @@ describe('usePricingTableUrlLoader', () => { await loadPricingTableFromUrl() expect(mockShowPricingTable).not.toHaveBeenCalled() - expect(mockTrackSubscription).not.toHaveBeenCalled() expect(mockRouterReplace).toHaveBeenCalledWith({ query: { other: 'param' } }) @@ -230,7 +219,6 @@ describe('usePricingTableUrlLoader', () => { ) expect(mockShowPricingTable).not.toHaveBeenCalled() - expect(mockTrackSubscription).not.toHaveBeenCalled() expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} }) expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith( 'pricing' diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts index b62830edba1..a94fca4b498 100644 --- a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts @@ -7,7 +7,6 @@ import { mergePreservedQueryIntoQuery } from '@/platform/navigation/preservedQueryManager' import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces' -import { useTelemetry } from '@/platform/telemetry' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' @@ -62,7 +61,6 @@ export function usePricingTableUrlLoader() { const planMode = param === 'team' || param === 'personal' ? param : undefined - useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' }) subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode }) } diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 9f9f6923480..a9a912d1341 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -15,7 +15,7 @@ import { t } from '@/i18n' import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' import { AuthStoreError, useAuthStore } from '@/stores/authStore' import { useDialogService } from '@/services/dialogService' @@ -237,14 +237,7 @@ function useSubscriptionInternal() { }) }, reportError) - const showSubscriptionDialog = (options?: { - reason?: SubscriptionDialogReason - }) => { - useTelemetry()?.trackSubscription('modal_opened', { - current_tier: subscriptionTier.value?.toLowerCase(), - reason: options?.reason - }) - + const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => { void showSubscriptionRequiredDialog(options) } @@ -277,7 +270,7 @@ function useSubscriptionInternal() { await fetchSubscriptionStatus() if (!isSubscribedOrIsNotCloud.value) { - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'subscription_required' }) } } diff --git a/src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts index 0a5649ad4d7..ceac95e4d5e 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts @@ -39,15 +39,23 @@ vi.mock('@/stores/commandStore', () => ({ })) // useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud. -const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({ +const { + mockIsCloud, + mockTrackHelpResourceClicked, + mockTrackAddApiCreditButtonClicked +} = vi.hoisted(() => ({ mockIsCloud: { value: true }, - mockTrackHelpResourceClicked: vi.fn() + mockTrackHelpResourceClicked: vi.fn(), + mockTrackAddApiCreditButtonClicked: vi.fn() })) vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => mockIsCloud.value - ? { trackHelpResourceClicked: mockTrackHelpResourceClicked } + ? { + trackHelpResourceClicked: mockTrackHelpResourceClicked, + trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked + } : null })) @@ -69,6 +77,9 @@ describe('useSubscriptionActions', () => { const { handleAddApiCredits } = useSubscriptionActions() handleAddApiCredits() expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce() + expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({ + source: 'settings_billing_panel' + }) }) }) diff --git a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts index 3927424484a..50b15c4d84a 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts @@ -21,6 +21,9 @@ export function useSubscriptionActions() { }) const handleAddApiCredits = () => { + telemetry?.trackAddApiCreditButtonClicked({ + source: 'settings_billing_panel' + }) void dialogService.showTopUpCreditsDialog() } diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts index 0c31e4964e8..228d627c419 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts @@ -5,8 +5,10 @@ import { useSubscriptionDialog } from './useSubscriptionDialog' const mockCloseDialog = vi.fn() const mockShowLayoutDialog = vi.fn() const mockShowTeamWorkspacesDialog = vi.fn() +const mockTrackSubscription = vi.hoisted(() => vi.fn()) const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true })) const mockIsFreeTier = vi.hoisted(() => ({ value: false })) +const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null })) const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false })) const mockIsCloud = vi.hoisted(() => ({ value: true })) const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false })) @@ -60,10 +62,15 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({ vi.mock('@/composables/billing/useBillingContext', () => ({ useBillingContext: () => ({ isFreeTier: mockIsFreeTier, - isLegacyTeamPlan: mockIsLegacyTeamPlan + isLegacyTeamPlan: mockIsLegacyTeamPlan, + tier: mockTier }) })) +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ trackSubscription: mockTrackSubscription }) +})) + vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({ useWorkspaceUI: () => ({ permissions: { @@ -80,6 +87,7 @@ describe('useSubscriptionDialog', () => { mockIsCloud.value = true mockIsInPersonalWorkspace.value = true mockIsFreeTier.value = false + mockTier.value = 'FREE' mockTeamWorkspacesEnabled.value = false mockIsLegacyTeamPlan.value = false mockCanManageSubscription.value = true @@ -198,6 +206,39 @@ describe('useSubscriptionDialog', () => { const props = mockShowLayoutDialog.mock.calls[0][0].props expect(props.initialPlanMode).toBe('team') }) + + it('tracks modal_opened with the caller reason and current tier', () => { + mockTier.value = 'STANDARD' + const { showPricingTable } = useSubscriptionDialog() + + showPricingTable({ reason: 'upgrade_to_add_credits' }) + + expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', { + current_tier: 'standard', + reason: 'upgrade_to_add_credits' + }) + }) + + it('tracks modal_opened on the workspace (unified) path too', () => { + mockTeamWorkspacesEnabled.value = true + const { showPricingTable } = useSubscriptionDialog() + + showPricingTable({ reason: 'subscribe_to_run' }) + + expect(mockTrackSubscription).toHaveBeenCalledWith( + 'modal_opened', + expect.objectContaining({ reason: 'subscribe_to_run' }) + ) + }) + + it('does not track on non-cloud', () => { + mockIsCloud.value = false + const { showPricingTable } = useSubscriptionDialog() + + showPricingTable({ reason: 'subscribe_to_run' }) + + expect(mockTrackSubscription).not.toHaveBeenCalled() + }) }) describe('show', () => { @@ -235,6 +276,20 @@ describe('useSubscriptionDialog', () => { expect.objectContaining({ key: 'subscription-required' }) ) }) + + it('tracks modal_opened with the reason for the free-tier dialog', () => { + mockIsFreeTier.value = true + mockIsInPersonalWorkspace.value = true + const { show } = useSubscriptionDialog() + + show({ reason: 'out_of_credits' }) + + expect(mockTrackSubscription).toHaveBeenCalledTimes(1) + expect(mockTrackSubscription).toHaveBeenCalledWith( + 'modal_opened', + expect.objectContaining({ reason: 'out_of_credits' }) + ) + }) }) describe('startTeamWorkspaceUpgradeFlow', () => { diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts index e72c8d1f7eb..cf143e4ec05 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts @@ -4,6 +4,7 @@ import { useDialogStore } from '@/stores/dialogStore' import { useBillingContext } from '@/composables/billing/useBillingContext' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' @@ -11,13 +12,27 @@ const DIALOG_KEY = 'subscription-required' const FREE_TIER_DIALOG_KEY = 'free-tier-info' const RESUME_PRICING_KEY = 'comfy:resume-team-pricing' +/** + * The product moment that sent the user into the paywall/pricing flow. + * Attached to `app:subscription_required_modal_opened` and carried through + * `begin_checkout` so payment attempts stay attributable to their trigger. + */ export type SubscriptionDialogReason = | 'subscription_required' | 'out_of_credits' | 'top_up_blocked' | 'deep_link' + | 'subscribe_to_run' + | 'subscribe_now_button' + | 'upgrade_to_add_credits' + | 'settings_billing_panel' + | 'avatar_menu_plans' + | 'team_members_panel' + | 'invite_member_upsell' + | 'upload_model_upgrade' + | 'team_upgrade_resume' -interface SubscriptionDialogOptions { +export interface SubscriptionDialogOptions { reason?: SubscriptionDialogReason /** * Forces the unified pricing dialog to open on a specific plan tab, @@ -38,9 +53,22 @@ export const useSubscriptionDialog = () => { dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY }) } + // Fired here — the choke point every paywall/pricing dialog variant passes + // through — so both the legacy and workspace billing paths emit it. + function trackModalOpened(reason?: SubscriptionDialogReason) { + // Resolved lazily to avoid the useBillingContext import cycle (see below). + const { tier } = useBillingContext() + useTelemetry()?.trackSubscription('modal_opened', { + current_tier: tier.value?.toLowerCase(), + reason + }) + } + function showPricingTable(options?: SubscriptionDialogOptions) { if (!isCloud) return + trackModalOpened(options?.reason) + // Resolved lazily (not at setup): useWorkspaceUI reads useBillingContext, so // a setup-time read re-enters the half-built context during the // useBillingContext -> useWorkspaceBilling -> useSubscriptionDialog cycle. @@ -167,6 +195,8 @@ export const useSubscriptionDialog = () => { // (not at composable setup) to avoid the useBillingContext import cycle. const { isFreeTier } = useBillingContext() if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) { + trackModalOpened(options?.reason) + const component = defineAsyncComponent( () => import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue') @@ -236,7 +266,7 @@ export const useSubscriptionDialog = () => { sessionStorage.removeItem(RESUME_PRICING_KEY) if (!workspaceStore.isInPersonalWorkspace) { - showPricingTable() + showPricingTable({ reason: 'team_upgrade_resume' }) } } catch { // sessionStorage may be unavailable diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts index 50b31837fdc..d108732773b 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts @@ -6,6 +6,7 @@ import type { SubscriptionTier, TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types' @@ -40,6 +41,7 @@ interface PendingSubscriptionCheckoutAttempt { checkout_type: CheckoutType previous_tier?: TierKey previous_cycle?: BillingCycle + payment_intent_source?: SubscriptionDialogReason } interface RecordPendingSubscriptionCheckoutAttemptInput { @@ -48,6 +50,7 @@ interface RecordPendingSubscriptionCheckoutAttemptInput { checkout_type: CheckoutType previous_tier?: TierKey previous_cycle?: BillingCycle + payment_intent_source?: SubscriptionDialogReason } const dispatchPendingCheckoutChangeEvent = () => { @@ -168,6 +171,9 @@ const normalizeAttempt = ( ...(candidate.previous_cycle === 'monthly' || candidate.previous_cycle === 'yearly' ? { previous_cycle: candidate.previous_cycle } + : {}), + ...(typeof candidate.payment_intent_source === 'string' + ? { payment_intent_source: candidate.payment_intent_source } : {}) } } @@ -235,7 +241,10 @@ export const recordPendingSubscriptionCheckoutAttempt = ( cycle: input.cycle, checkout_type: input.checkout_type, ...(input.previous_tier ? { previous_tier: input.previous_tier } : {}), - ...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}) + ...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}), + ...(input.payment_intent_source + ? { payment_intent_source: input.payment_intent_source } + : {}) } if (!storage) { @@ -287,6 +296,9 @@ export const consumePendingSubscriptionCheckoutSuccess = ( cycle: attempt.cycle, checkout_type: attempt.checkout_type, ...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}), + ...(attempt.payment_intent_source + ? { payment_intent_source: attempt.payment_intent_source } + : {}), value, currency: 'USD', ecommerce: { diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 226d8e25c04..096aa752696 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -208,6 +208,29 @@ describe('performSubscriptionCheckout', () => { expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') }) + it('carries the payment intent source into begin_checkout and the pending attempt', async () => { + const checkoutUrl = 'https://checkout.stripe.com/test' + const openSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => window as unknown as Window) + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ checkout_url: checkoutUrl }) + } as Response) + + await performSubscriptionCheckout('pro', 'monthly', true, 'out_of_credits') + + expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ payment_intent_source: 'out_of_credits' }) + ) + const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0] + expect(JSON.parse(storedAttempt)).toMatchObject({ + payment_intent_source: 'out_of_credits' + }) + openSpy.mockRestore() + }) + it('uses the latest userId when it changes after checkout starts', async () => { const checkoutUrl = 'https://checkout.stripe.com/test' const openSpy = vi diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 2f2cde9d8cc..34edc891057 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -8,6 +8,7 @@ import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' import { AuthStoreError, useAuthStore } from '@/stores/authStore' import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker' import type { BillingCycle } from './subscriptionTierRank' @@ -47,7 +48,8 @@ const getCheckoutAttributionForCloud = export async function performSubscriptionCheckout( tierKey: TierKey, currentBillingCycle: BillingCycle, - openInNewTab: boolean = true + openInNewTab: boolean = true, + paymentIntentSource?: SubscriptionDialogReason ): Promise { if (!isCloud) return @@ -114,6 +116,9 @@ export async function performSubscriptionCheckout( tier: tierKey, cycle: currentBillingCycle, checkout_type: 'new', + ...(paymentIntentSource + ? { payment_intent_source: paymentIntentSource } + : {}), ...checkoutAttribution }) } @@ -127,13 +132,15 @@ export async function performSubscriptionCheckout( recordPendingSubscriptionCheckoutAttempt({ tier: tierKey, cycle: currentBillingCycle, - checkout_type: 'new' + checkout_type: 'new', + payment_intent_source: paymentIntentSource }) } else { recordPendingSubscriptionCheckoutAttempt({ tier: tierKey, cycle: currentBillingCycle, - checkout_type: 'new' + checkout_type: 'new', + payment_intent_source: paymentIntentSource }) globalThis.location.href = data.checkout_url } diff --git a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts index 54bf91ae10d..5a2326be362 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, reactive } from 'vue' -const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({ - mockIsCloud: { value: true }, - mockSubscribe: vi.fn() -})) +const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } = + vi.hoisted(() => ({ + mockIsCloud: { value: true }, + mockSubscribe: vi.fn(), + mockTrackBeginCheckout: vi.fn(), + mockUserId: { value: 'user-1' as string | null } + })) vi.mock('@/platform/distribution/types', () => ({ get isCloud() { @@ -16,6 +20,12 @@ vi.mock('@/config/comfyApi', () => ({ vi.mock('@/platform/workspace/api/workspaceApi', () => ({ workspaceApi: { subscribe: mockSubscribe } })) +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout }) +})) +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) }) +})) import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil' @@ -51,6 +61,13 @@ describe('performTeamSubscriptionCheckout', () => { teamCreditStopId: 'team_700' }) expect(assignedHref).toBe('https://stripe.test/pay') + expect(mockTrackBeginCheckout).toHaveBeenCalledWith({ + user_id: 'user-1', + tier: 'team', + cycle: 'yearly', + checkout_type: 'new', + payment_intent_source: 'deep_link' + }) }) it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => { diff --git a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts index c608b394ff9..6e8fa7573db 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts @@ -1,7 +1,11 @@ +import { storeToRefs } from 'pinia' + import { getComfyPlatformBaseUrl } from '@/config/comfyApi' import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops' import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { workspaceApi } from '@/platform/workspace/api/workspaceApi' +import { useAuthStore } from '@/stores/authStore' import type { BillingCycle } from './subscriptionTierRank' @@ -26,6 +30,17 @@ export async function performTeamSubscriptionCheckout( ): Promise { if (!isCloud) return + const { userId } = storeToRefs(useAuthStore()) + if (userId.value) { + useTelemetry()?.trackBeginCheckout({ + user_id: userId.value, + tier: 'team', + cycle: billingCycle, + checkout_type: 'new', + payment_intent_source: 'deep_link' + }) + } + const planSlug = getTeamPlanSlug(billingCycle) const response = await workspaceApi.subscribe(planSlug, { returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`, diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts index f54694004e6..34987122220 100644 --- a/src/platform/telemetry/TelemetryRegistry.ts +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -1,6 +1,7 @@ import type { AuditLog } from '@/services/customerEventsService' import type { + AddCreditsClickMetadata, AuthMetadata, BeginCheckoutMetadata, DefaultViewSetMetadata, @@ -99,8 +100,10 @@ export class TelemetryRegistry implements TelemetryDispatcher { this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.()) } - trackAddApiCreditButtonClicked(): void { - this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.()) + trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void { + this.dispatch((provider) => + provider.trackAddApiCreditButtonClicked?.(metadata) + ) } trackApiCreditTopupButtonPurchaseClicked(amount: number): void { diff --git a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts index a9846ddc27d..7a398bc97a9 100644 --- a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts @@ -313,6 +313,42 @@ describe('PostHogTelemetryProvider', () => { ) }) + it('captures begin_checkout with intent metadata', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackBeginCheckout({ + user_id: 'user-1', + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new', + payment_intent_source: 'subscribe_to_run' + }) + + expect(hoisted.mockCapture).toHaveBeenCalledWith( + TelemetryEvents.BEGIN_CHECKOUT, + { + user_id: 'user-1', + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new', + payment_intent_source: 'subscribe_to_run' + } + ) + }) + + it('captures add-credit clicks with their source', async () => { + const provider = createProvider() + await vi.dynamicImportSettled() + + provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' }) + + expect(hoisted.mockCapture).toHaveBeenCalledWith( + TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, + { source: 'credits_panel' } + ) + }) + it('captures share attribution events', async () => { const provider = createProvider() await vi.dynamicImportSettled() diff --git a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts index 1454e7b6d0e..8f3e4c1e866 100644 --- a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts @@ -10,7 +10,9 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' import type { RemoteConfig } from '@/platform/remoteConfig/types' import type { + AddCreditsClickMetadata, AuthMetadata, + BeginCheckoutMetadata, DefaultViewSetMetadata, EnterLinearMetadata, ShareFlowMetadata, @@ -350,8 +352,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider { this.trackEvent(eventName, metadata) } - trackAddApiCreditButtonClicked(): void { - this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED) + trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void { + this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata) + } + + trackBeginCheckout(metadata: BeginCheckoutMetadata): void { + this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata) } trackMonthlySubscriptionSucceeded( diff --git a/src/platform/telemetry/providers/host/HostTelemetrySink.ts b/src/platform/telemetry/providers/host/HostTelemetrySink.ts index 7a4ac01bae9..b95c194dd68 100644 --- a/src/platform/telemetry/providers/host/HostTelemetrySink.ts +++ b/src/platform/telemetry/providers/host/HostTelemetrySink.ts @@ -10,6 +10,7 @@ import { import type { AuditLog } from '@/services/customerEventsService' import type { + AddCreditsClickMetadata, AuthMetadata, BeginCheckoutMetadata, DefaultViewSetMetadata, @@ -126,8 +127,8 @@ export class HostTelemetrySink implements TelemetryProvider { this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED) } - trackAddApiCreditButtonClicked(): void { - this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED) + trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void { + this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata) } trackApiCreditTopupButtonPurchaseClicked(amount: number): void { diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index fc5942cabbf..f2e7cc1a4ae 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -429,13 +429,18 @@ export interface SubscriptionMetadata { reason?: SubscriptionDialogReason } +export interface AddCreditsClickMetadata { + source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel' +} + export interface BeginCheckoutMetadata extends Record, CheckoutAttributionMetadata { user_id: string - tier: TierKey + tier: TierKey | 'team' cycle: BillingCycle checkout_type: 'new' | 'change' previous_tier?: TierKey + payment_intent_source?: SubscriptionDialogReason } interface EcommerceItemMetadata { @@ -459,6 +464,7 @@ export interface SubscriptionSuccessMetadata extends Record { cycle: BillingCycle checkout_type: 'new' | 'change' previous_tier?: TierKey + payment_intent_source?: SubscriptionDialogReason value: number currency: string ecommerce: EcommerceMetadata @@ -489,7 +495,7 @@ export interface TelemetryProvider { metadata?: SubscriptionSuccessMetadata ): void trackMonthlySubscriptionCancelled?(): void - trackAddApiCreditButtonClicked?(): void + trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void trackApiCreditTopupButtonPurchaseClicked?(amount: number): void trackApiCreditTopupSucceeded?(): void trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void diff --git a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue index bf40d077e63..90250bc33d2 100644 --- a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue +++ b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue @@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => { } const handleOpenPlansAndPricing = () => { - subscriptionDialog.showPricingTable() + subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' }) emit('close') } @@ -336,13 +336,12 @@ const handleOpenPlanAndCreditsSettings = () => { } const handleUpgradeToAddCredits = () => { - subscriptionDialog.showPricingTable() + subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' }) emit('close') } const handleTopUp = () => { - // Track purchase credits entry from avatar popover - useTelemetry()?.trackAddApiCreditButtonClicked() + useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' }) dialogService.showTopUpCreditsDialog() emit('close') } diff --git a/src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue b/src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue index 3a8627a14f4..9ee3da31e3c 100644 --- a/src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue @@ -391,12 +391,13 @@ const showZeroState = computed( ) function handleSubscribeWorkspace() { - showSubscriptionDialog() + showSubscriptionDialog({ reason: 'settings_billing_panel' }) } function handleUpgrade() { - if (isFreeTierPlan.value) showPricingTable() - else showSubscriptionDialog() + if (isFreeTierPlan.value) + showPricingTable({ reason: 'settings_billing_panel' }) + else showSubscriptionDialog({ reason: 'settings_billing_panel' }) } function handleViewMoreDetails() { diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue index 1acfa146da6..9a1da4dc3c8 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue @@ -152,7 +152,7 @@ const { handleConfirmTransition, handleTeamSubscribe, handleResubscribe -} = useSubscriptionCheckout(emit) +} = useSubscriptionCheckout(emit, reason) // Backspace mirrors the back arrow on the confirm step, but never while an // editable element is focused (let it delete text there). diff --git a/src/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue b/src/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue index fa0da72251f..cda6a05b06d 100644 --- a/src/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue +++ b/src/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue @@ -61,6 +61,9 @@ function onDismiss() { function onUpgrade() { dialogStore.closeDialog({ key: 'invite-member-upsell' }) - subscriptionDialog.show({ planMode: 'team' }) + subscriptionDialog.show({ + planMode: 'team', + reason: 'invite_member_upsell' + }) } diff --git a/src/platform/workspace/composables/useMembersPanel.ts b/src/platform/workspace/composables/useMembersPanel.ts index 72d824115e1..9a47785c112 100644 --- a/src/platform/workspace/composables/useMembersPanel.ts +++ b/src/platform/workspace/composables/useMembersPanel.ts @@ -277,7 +277,7 @@ export function useMembersPanel() { } function showTeamPlans() { - subscriptionDialog.show({ planMode: 'team' }) + subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' }) } return { diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts index 7b2969d2ed8..eb8098d1de9 100644 --- a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts +++ b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts @@ -1,8 +1,9 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { computed } from 'vue' +import { computed, reactive } from 'vue' +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { Plan } from '@/platform/workspace/api/workspaceApi' import { findPlanSlug } from './useSubscriptionCheckout' @@ -75,7 +76,9 @@ const { mockPlans, mockResubscribe, mockToastAdd, - mockStartOperation + mockStartOperation, + mockTrackBeginCheckout, + mockUserId } = vi.hoisted(() => ({ mockSubscribe: vi.fn(), mockPreviewSubscribe: vi.fn(), @@ -84,7 +87,9 @@ const { mockPlans: { value: [] as Plan[] }, mockResubscribe: vi.fn(), mockToastAdd: vi.fn(), - mockStartOperation: vi.fn() + mockStartOperation: vi.fn(), + mockTrackBeginCheckout: vi.fn(), + mockUserId: { value: 'user-1' as string | null } })) vi.mock('@/composables/billing/useBillingContext', () => ({ @@ -119,7 +124,14 @@ vi.mock('primevue/usetoast', () => ({ })) vi.mock('@/platform/telemetry', () => ({ - useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() }) + useTelemetry: () => ({ + trackMonthlySubscriptionSucceeded: vi.fn(), + trackBeginCheckout: mockTrackBeginCheckout + }) +})) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) }) })) vi.mock('vue-i18n', async (importOriginal) => { @@ -135,10 +147,10 @@ vi.mock('vue-i18n', async (importOriginal) => { describe('useSubscriptionCheckout', () => { let emit: ReturnType - async function setup() { + async function setup(paymentIntentSource?: SubscriptionDialogReason) { const { useSubscriptionCheckout } = await import('./useSubscriptionCheckout') - return useSubscriptionCheckout(emit as never) + return useSubscriptionCheckout(emit as never, paymentIntentSource) } beforeEach(() => { @@ -459,6 +471,9 @@ describe('useSubscriptionCheckout', () => { cancelUrl: 'https://platform.comfy.org/payment/failed' }) expect(checkout.checkoutStep.value).toBe('success') + expect(mockTrackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ tier: 'team', checkout_type: 'new' }) + ) }) it('uses the annual plan slug for the yearly cycle', async () => { @@ -603,6 +618,28 @@ describe('useSubscriptionCheckout', () => { expect(checkout.checkoutStep.value).toBe('success') }) + it('fires begin_checkout carrying the payment intent source', async () => { + const checkout = await setup('subscribe_to_run') + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockResolvedValueOnce({ + status: 'subscribed', + billing_op_id: 'op-1' + }) + mockFetchStatus.mockResolvedValueOnce(undefined) + mockFetchBalance.mockResolvedValueOnce(undefined) + + await checkout.handleAddCreditCard() + + expect(mockTrackBeginCheckout).toHaveBeenCalledWith({ + user_id: 'user-1', + tier: 'standard', + cycle: 'yearly', + checkout_type: 'new', + payment_intent_source: 'subscribe_to_run' + }) + }) + it('opens payment URL when needs_payment_method', async () => { const checkout = await setup() checkout.selectedTierKey.value = 'standard' diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.ts b/src/platform/workspace/composables/useSubscriptionCheckout.ts index 71873b6e3ca..f17c633e5f0 100644 --- a/src/platform/workspace/composables/useSubscriptionCheckout.ts +++ b/src/platform/workspace/composables/useSubscriptionCheckout.ts @@ -1,9 +1,11 @@ +import { storeToRefs } from 'pinia' import { useToast } from 'primevue/usetoast' import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useBillingContext } from '@/composables/billing/useBillingContext' import { getComfyPlatformBaseUrl } from '@/config/comfyApi' +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops' import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' @@ -15,6 +17,7 @@ import type { SubscribeResponse } from '@/platform/workspace/api/workspaceApi' import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore' +import { useAuthStore } from '@/stores/authStore' type CheckoutStep = 'pricing' | 'preview' | 'success' type CheckoutTierKey = Exclude @@ -45,9 +48,12 @@ export function findPlanSlug( return plan?.slug ?? null } -export function useSubscriptionCheckout(emit: { - (e: 'close', subscribed: boolean): void -}) { +export function useSubscriptionCheckout( + emit: { + (e: 'close', subscribed: boolean): void + }, + paymentIntentSource?: SubscriptionDialogReason +) { const { t } = useI18n() const toast = useToast() const { @@ -92,6 +98,23 @@ export function useSubscriptionCheckout(emit: { return findPlanSlug(plans.value, tierKey, billingCycle) } + function trackCheckoutStarted( + tier: TierKey | 'team', + checkoutType: 'new' | 'change' + ) { + const { userId } = storeToRefs(useAuthStore()) + if (!userId.value) return + telemetry?.trackBeginCheckout({ + user_id: userId.value, + tier, + cycle: selectedBillingCycle.value, + checkout_type: checkoutType, + ...(paymentIntentSource + ? { payment_intent_source: paymentIntentSource } + : {}) + }) + } + async function handleSubscribeClick(payload: { tierKey: CheckoutTierKey billingCycle: BillingCycle @@ -192,6 +215,14 @@ export function useSubscriptionCheckout(emit: { async function handleSubscription() { if (!selectedTierKey.value) return + trackCheckoutStarted( + selectedTierKey.value, + previewData.value && + previewData.value.transition_type !== 'new_subscription' + ? 'change' + : 'new' + ) + isSubscribing.value = true try { const planSlug = getApiPlanSlug( @@ -279,6 +310,8 @@ export function useSubscriptionCheckout(emit: { return } + trackCheckoutStarted('team', previewData.value ? 'change' : 'new') + isSubscribing.value = true try { const planSlug = getTeamPlanSlug(selectedBillingCycle.value) diff --git a/src/platform/workspace/composables/useWorkspaceBilling.ts b/src/platform/workspace/composables/useWorkspaceBilling.ts index 6977592beb4..6871ea38ecd 100644 --- a/src/platform/workspace/composables/useWorkspaceBilling.ts +++ b/src/platform/workspace/composables/useWorkspaceBilling.ts @@ -2,6 +2,7 @@ import { computed, ref, shallowRef } from 'vue' import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans' import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { BillingBalanceResponse, BillingStatusResponse, @@ -275,12 +276,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions { async function requireActiveSubscription(): Promise { await fetchStatus() if (!isActiveSubscription.value) { - subscriptionDialog.show() + subscriptionDialog.show({ reason: 'subscription_required' }) } } - function showSubscriptionDialog(): void { - subscriptionDialog.show() + function showSubscriptionDialog(options?: SubscriptionDialogOptions): void { + subscriptionDialog.show(options) } return { diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 217abb65c2b..8d93aee7e83 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -18,7 +18,7 @@ import type { } from '@/stores/dialogStore' import type { ComponentAttrs } from 'vue-component-type-helpers' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi' // Lazy loaders for dialogs - components are loaded on first use @@ -442,9 +442,9 @@ export const useDialogService = () => { }) } - async function showSubscriptionRequiredDialog(options?: { - reason?: SubscriptionDialogReason - }) { + async function showSubscriptionRequiredDialog( + options?: SubscriptionDialogOptions + ) { if (!isCloud || !window.__CONFIG__?.subscription_required) { return } From aba1263e7e0aaedd0b0f441f82a0f215c6a7e327 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 1 Jul 2026 14:24:07 -0700 Subject: [PATCH 2/5] fix: keep generic free-tier copy for new paywall intent reasons The free-tier dialog only rendered its description and credits-refresh line for the pre-existing reasons, so the widened intent taxonomy made them vanish (caught by the cloud e2e suite). Gate the special copy on the two credit-blocked variants instead, and add coverage for the new telemetry dispatch paths. --- .../components/FreeTierDialogContent.test.ts | 19 ++++++- .../components/FreeTierDialogContent.vue | 17 ++++--- .../utils/subscriptionCheckoutTracker.test.ts | 49 +++++++++++++++++++ .../telemetry/TelemetryRegistry.test.ts | 33 +++++++++++++ .../providers/host/HostTelemetrySink.test.ts | 11 +++++ .../useSubscriptionCheckout.test.ts | 18 +++++++ 6 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.test.ts diff --git a/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts b/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts index e4e92869970..0231beef045 100644 --- a/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts +++ b/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts @@ -5,6 +5,8 @@ import { render, screen } from '@testing-library/vue' import enMessages from '@/locales/en/main.json' with { type: 'json' } +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' + import FreeTierDialogContent from './FreeTierDialogContent.vue' const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null })) @@ -15,7 +17,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({ })) })) -function renderComponent() { +function renderComponent(props?: { reason?: SubscriptionDialogReason }) { const i18n = createI18n({ legacy: false, locale: 'en', @@ -23,6 +25,7 @@ function renderComponent() { }) return render(FreeTierDialogContent, { + props, global: { plugins: [i18n] } @@ -43,4 +46,18 @@ describe('FreeTierDialogContent', () => { renderComponent() expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument() }) + + it('keeps the generic copy for intent reasons outside the credits variants', () => { + mockRenewalDate.value = '2026-07-15T10:00:00Z' + renderComponent({ reason: 'subscribe_to_run' }) + expect( + screen.getByText('Your credits refresh on Jul 15, 2026.') + ).toBeInTheDocument() + }) + + it('swaps to the out-of-credits copy without the refresh line', () => { + mockRenewalDate.value = '2026-07-15T10:00:00Z' + renderComponent({ reason: 'out_of_credits' }) + expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument() + }) }) diff --git a/src/platform/cloud/subscription/components/FreeTierDialogContent.vue b/src/platform/cloud/subscription/components/FreeTierDialogContent.vue index e77559bc511..a577d3145ed 100644 --- a/src/platform/cloud/subscription/components/FreeTierDialogContent.vue +++ b/src/platform/cloud/subscription/components/FreeTierDialogContent.vue @@ -52,7 +52,7 @@

{{ @@ -65,10 +65,7 @@

{{ @@ -88,7 +85,7 @@ @click="$emit('upgrade')" > {{ - reason === 'out_of_credits' || reason === 'top_up_blocked' + isCreditsBlockedVariant ? $t('subscription.freeTier.upgradeCta') : $t('subscription.freeTier.subscribeCta') }} @@ -107,7 +104,7 @@ import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/com import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue' import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing' -defineProps<{ +const { reason } = defineProps<{ reason?: SubscriptionDialogReason }>() @@ -129,4 +126,10 @@ const formattedRenewalDate = computed(() => { }) const freeTierCredits = computed(() => getTierCredits('free')) + +// Only these two variants replace the generic free-tier copy; any other +// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch. +const isCreditsBlockedVariant = computed( + () => reason === 'out_of_credits' || reason === 'top_up_blocked' +) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.test.ts new file mode 100644 index 00000000000..ac3b82454ae --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + clearPendingSubscriptionCheckoutAttempt, + consumePendingSubscriptionCheckoutSuccess, + recordPendingSubscriptionCheckoutAttempt +} from './subscriptionCheckoutTracker' + +const activeProStatus = { + is_active: true, + subscription_tier: 'PRO', + subscription_duration: 'MONTHLY' +} as const + +describe('subscriptionCheckoutTracker', () => { + beforeEach(() => { + clearPendingSubscriptionCheckoutAttempt() + }) + + it('round-trips payment_intent_source from attempt to success metadata', () => { + recordPendingSubscriptionCheckoutAttempt({ + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new', + payment_intent_source: 'subscribe_to_run' + }) + + const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus) + + expect(metadata).toMatchObject({ + tier: 'pro', + checkout_type: 'new', + payment_intent_source: 'subscribe_to_run' + }) + }) + + it('omits payment_intent_source when the attempt had none', () => { + recordPendingSubscriptionCheckoutAttempt({ + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new' + }) + + const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus) + + expect(metadata).not.toBeNull() + expect(metadata).not.toHaveProperty('payment_intent_source') + }) +}) diff --git a/src/platform/telemetry/TelemetryRegistry.test.ts b/src/platform/telemetry/TelemetryRegistry.test.ts index 611ac184f0e..7be4002f0c5 100644 --- a/src/platform/telemetry/TelemetryRegistry.test.ts +++ b/src/platform/telemetry/TelemetryRegistry.test.ts @@ -30,6 +30,39 @@ describe('TelemetryRegistry', () => { expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload) }) + it('dispatches trackBeginCheckout with intent metadata to every provider', () => { + const a: TelemetryProvider = { trackBeginCheckout: vi.fn() } + const b: TelemetryProvider = {} + const registry = new TelemetryRegistry() + registry.registerProvider(a) + registry.registerProvider(b) + + const metadata = { + user_id: 'user-1', + tier: 'pro' as const, + cycle: 'monthly' as const, + checkout_type: 'new' as const, + payment_intent_source: 'subscribe_to_run' as const + } + registry.trackBeginCheckout(metadata) + + expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata) + }) + + it('dispatches trackAddApiCreditButtonClicked with its source', () => { + const provider: TelemetryProvider = { + trackAddApiCreditButtonClicked: vi.fn() + } + const registry = new TelemetryRegistry() + registry.registerProvider(provider) + + registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' }) + + expect( + provider.trackAddApiCreditButtonClicked + ).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' }) + }) + it('skips providers that do not implement trackSearchQuery', () => { const empty: TelemetryProvider = {} const registry = new TelemetryRegistry() diff --git a/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts b/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts index 70cdbd8ca76..0967cc556f4 100644 --- a/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts +++ b/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts @@ -115,6 +115,17 @@ describe('HostTelemetrySink', () => { ) }) + it('forwards add-credit clicks with their source', () => { + new HostTelemetrySink().trackAddApiCreditButtonClicked({ + source: 'avatar_menu' + }) + + expect(state.capture).toHaveBeenCalledExactlyOnceWith( + TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, + { source: 'avatar_menu' } + ) + }) + it('does nothing when the host bridge is absent', () => { delete window.__comfyDesktop2 diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts index eb8098d1de9..6e918b7f062 100644 --- a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts +++ b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts @@ -618,6 +618,24 @@ describe('useSubscriptionCheckout', () => { expect(checkout.checkoutStep.value).toBe('success') }) + it('skips begin_checkout when no user id is available', async () => { + mockUserId.value = null + const checkout = await setup('subscribe_to_run') + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockResolvedValueOnce({ + status: 'subscribed', + billing_op_id: 'op-1' + }) + mockFetchStatus.mockResolvedValueOnce(undefined) + mockFetchBalance.mockResolvedValueOnce(undefined) + + await checkout.handleAddCreditCard() + + expect(mockTrackBeginCheckout).not.toHaveBeenCalled() + mockUserId.value = 'user-1' + }) + it('fires begin_checkout carrying the payment intent source', async () => { const checkout = await setup('subscribe_to_run') checkout.selectedTierKey.value = 'standard' From 5e8595e8d96f6b476a78fea84a1718f74877ad3c Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 1 Jul 2026 16:12:20 -0700 Subject: [PATCH 3/5] fix: tighten payment intent telemetry attribution --- .../topbar/CurrentUserPopoverLegacy.vue | 4 +- .../topbar/TopbarSubscribeButton.vue | 2 +- .../components/PricingTable.test.ts | 3 ++ .../subscription/components/PricingTable.vue | 35 ++++++++----- .../useAccountPreconditionDialog.ts | 4 +- .../composables/useSubscriptionDialog.test.ts | 12 +++++ .../composables/useSubscriptionDialog.ts | 4 +- .../utils/subscriptionCheckoutUtil.test.ts | 35 ++++++++++--- .../utils/subscriptionCheckoutUtil.ts | 21 +++----- .../teamSubscriptionCheckoutUtil.test.ts | 11 +++++ .../utils/teamSubscriptionCheckoutUtil.ts | 15 +++--- src/platform/telemetry/types.ts | 2 + .../useSubscriptionCheckout.test.ts | 42 +++++++++++++++- .../composables/useSubscriptionCheckout.ts | 49 +++++++++++++------ 14 files changed, 178 insertions(+), 61 deletions(-) diff --git a/src/components/topbar/CurrentUserPopoverLegacy.vue b/src/components/topbar/CurrentUserPopoverLegacy.vue index cf2c9723137..74c1f1953a1 100644 --- a/src/components/topbar/CurrentUserPopoverLegacy.vue +++ b/src/components/topbar/CurrentUserPopoverLegacy.vue @@ -224,7 +224,7 @@ const handleOpenUserSettings = () => { } const handleOpenPlansAndPricing = () => { - subscriptionDialog.showPricingTable() + subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' }) emit('close') } @@ -253,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => { } const handleUpgradeToAddCredits = () => { - subscriptionDialog.showPricingTable() + subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' }) emit('close') } diff --git a/src/components/topbar/TopbarSubscribeButton.vue b/src/components/topbar/TopbarSubscribeButton.vue index dd51c433308..aebf49d7049 100644 --- a/src/components/topbar/TopbarSubscribeButton.vue +++ b/src/components/topbar/TopbarSubscribeButton.vue @@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext() const subscriptionDialog = useSubscriptionDialog() function handleClick() { - subscriptionDialog.showPricingTable() + subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' }) } diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index 67739201c1e..39286090eae 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -261,6 +261,7 @@ describe('PricingTable', () => { tier: 'creator', cycle: 'yearly', checkout_type: 'change', + checkout_attempt_id: expect.any(String), previous_tier: 'standard' }) expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly') @@ -341,6 +342,7 @@ describe('PricingTable', () => { expect( window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ).toBeNull() + expect(mockTrackBeginCheckout).not.toHaveBeenCalled() }) it('should use the latest userId value when it changes after mount', async () => { @@ -366,6 +368,7 @@ describe('PricingTable', () => { tier: 'creator', cycle: 'yearly', checkout_type: 'change', + checkout_attempt_id: expect.any(String), previous_tier: 'standard' }) }) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 1a63f44bf0b..408c155a9e5 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -468,17 +468,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync( } as const const previousPlan = currentPlanDescriptor.value const checkoutAttribution = await getCheckoutAttributionForCloud() - if (userId.value) { - telemetry?.trackBeginCheckout({ - user_id: userId.value, - tier: targetPlan.tierKey, - cycle: targetPlan.billingCycle, - checkout_type: 'change', - ...(reason ? { payment_intent_source: reason } : {}), - ...checkoutAttribution, - ...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}) - }) - } + const beginCheckoutMetadata = userId.value + ? { + user_id: userId.value, + tier: targetPlan.tierKey, + cycle: targetPlan.billingCycle, + checkout_type: 'change' as const, + ...(reason ? { payment_intent_source: reason } : {}), + ...checkoutAttribution, + ...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}) + } + : null // Pass the target tier to create a deep link to subscription update confirmation const checkoutTier = getCheckoutTier( targetPlan.tierKey, @@ -493,14 +493,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync( if (downgrade) { // TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end") - await accessBillingPortal() + const didOpenPortal = await accessBillingPortal() + if (didOpenPortal && beginCheckoutMetadata) { + telemetry?.trackBeginCheckout(beginCheckoutMetadata) + } } else { const didOpenPortal = await accessBillingPortal(checkoutTier) if (!didOpenPortal) { return } - recordPendingSubscriptionCheckoutAttempt({ + const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({ tier: targetPlan.tierKey, cycle: targetPlan.billingCycle, checkout_type: 'change', @@ -510,6 +513,12 @@ const handleSubscribe = wrapWithErrorHandlingAsync( ? { previous_cycle: previousPlan.billingCycle } : {}) }) + if (beginCheckoutMetadata) { + telemetry?.trackBeginCheckout({ + ...beginCheckoutMetadata, + checkout_attempt_id: pendingAttempt.attempt_id + }) + } } } else { await performSubscriptionCheckout( diff --git a/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts b/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts index 8cd5884600d..4c8ddfc50f5 100644 --- a/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts +++ b/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts @@ -24,7 +24,9 @@ export function useAccountPreconditionDialog() { ) return case 'subscription': - void dialogService.showSubscriptionRequiredDialog() + void dialogService.showSubscriptionRequiredDialog({ + reason: 'subscription_required' + }) return case 'credits': void dialogService.showTopUpCreditsDialog({ diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts index 228d627c419..c9eece9d297 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts @@ -231,6 +231,18 @@ describe('useSubscriptionDialog', () => { ) }) + it('does not track modal_opened for the inactive member dialog', () => { + mockTeamWorkspacesEnabled.value = true + mockIsInPersonalWorkspace.value = false + mockCanManageSubscription.value = false + const { showPricingTable } = useSubscriptionDialog() + + showPricingTable({ reason: 'subscribe_to_run' }) + + expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1) + expect(mockTrackSubscription).not.toHaveBeenCalled() + }) + it('does not track on non-cloud', () => { mockIsCloud.value = false const { showPricingTable } = useSubscriptionDialog() diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts index cf143e4ec05..b1bbdeeb04f 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts @@ -67,8 +67,6 @@ export const useSubscriptionDialog = () => { function showPricingTable(options?: SubscriptionDialogOptions) { if (!isCloud) return - trackModalOpened(options?.reason) - // Resolved lazily (not at setup): useWorkspaceUI reads useBillingContext, so // a setup-time read re-enters the half-built context during the // useBillingContext -> useWorkspaceBilling -> useSubscriptionDialog cycle. @@ -99,6 +97,8 @@ export const useSubscriptionDialog = () => { return } + trackModalOpened(options?.reason) + // Shared dialog shell styling for both variants. const dialogComponentProps = { style: 'width: min(1328px, 95vw); max-height: 958px;', diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 096aa752696..3c8b4e80d1c 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -139,6 +139,7 @@ describe('performSubscriptionCheckout', () => { tier: 'pro', cycle: 'yearly', checkout_type: 'new', + checkout_attempt_id: expect.any(String), ga_client_id: 'ga-client-id', ga_session_id: 'ga-session-id', ga_session_number: 'ga-session-number', @@ -150,6 +151,12 @@ describe('performSubscriptionCheckout', () => { gbraid: 'gbraid-456', wbraid: 'wbraid-789' }) + const beginCheckoutMetadata = + mockTelemetry.trackBeginCheckout.mock.calls[0][0] + const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0] + expect(beginCheckoutMetadata.checkout_attempt_id).toBe( + JSON.parse(storedAttempt).attempt_id + ) expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining( '/customers/cloud-subscription-checkout/pro-yearly' @@ -203,7 +210,8 @@ describe('performSubscriptionCheckout', () => { user_id: 'user-123', tier: 'pro', cycle: 'monthly', - checkout_type: 'new' + checkout_type: 'new', + checkout_attempt_id: expect.any(String) }) expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') }) @@ -224,10 +232,16 @@ describe('performSubscriptionCheckout', () => { expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith( expect.objectContaining({ payment_intent_source: 'out_of_credits' }) ) + const beginCheckoutMetadata = + mockTelemetry.trackBeginCheckout.mock.calls[0][0] const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0] - expect(JSON.parse(storedAttempt)).toMatchObject({ + const pendingAttempt = JSON.parse(storedAttempt) + expect(pendingAttempt).toMatchObject({ payment_intent_source: 'out_of_credits' }) + expect(beginCheckoutMetadata.checkout_attempt_id).toBe( + pendingAttempt.attempt_id + ) openSpy.mockRestore() }) @@ -258,13 +272,14 @@ describe('performSubscriptionCheckout', () => { user_id: 'user-late', tier: 'pro', cycle: 'yearly', - checkout_type: 'new' + checkout_type: 'new', + checkout_attempt_id: expect.any(String) }) ) expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') }) - it('does not persist a pending attempt when the checkout popup is blocked', async () => { + it('persists the pending attempt when the checkout popup is blocked', async () => { const checkoutUrl = 'https://checkout.stripe.com/test' const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) @@ -276,8 +291,14 @@ describe('performSubscriptionCheckout', () => { await performSubscriptionCheckout('pro', 'monthly', true) expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') - expect( - window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) - ).toBeNull() + const storedAttempt = window.localStorage.getItem( + PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY + ) + expect(storedAttempt).not.toBeNull() + expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + checkout_attempt_id: JSON.parse(storedAttempt ?? '{}').attempt_id + }) + ) }) }) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 34edc891057..dd68d3500ab 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -110,12 +110,20 @@ export async function performSubscriptionCheckout( const data = await response.json() if (data.checkout_url) { + const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({ + tier: tierKey, + cycle: currentBillingCycle, + checkout_type: 'new', + payment_intent_source: paymentIntentSource + }) + if (userId.value) { telemetry?.trackBeginCheckout({ user_id: userId.value, tier: tierKey, cycle: currentBillingCycle, checkout_type: 'new', + checkout_attempt_id: pendingAttempt.attempt_id, ...(paymentIntentSource ? { payment_intent_source: paymentIntentSource } : {}), @@ -128,20 +136,7 @@ export async function performSubscriptionCheckout( if (!checkoutWindow) { return } - - recordPendingSubscriptionCheckoutAttempt({ - tier: tierKey, - cycle: currentBillingCycle, - checkout_type: 'new', - payment_intent_source: paymentIntentSource - }) } else { - recordPendingSubscriptionCheckoutAttempt({ - tier: tierKey, - cycle: currentBillingCycle, - checkout_type: 'new', - payment_intent_source: paymentIntentSource - }) globalThis.location.href = data.checkout_url } } diff --git a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts index 5a2326be362..ceb1d5d9c24 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts @@ -66,6 +66,7 @@ describe('performTeamSubscriptionCheckout', () => { tier: 'team', cycle: 'yearly', checkout_type: 'new', + billing_op_id: 'op_1', payment_intent_source: 'deep_link' }) }) @@ -99,6 +100,16 @@ describe('performTeamSubscriptionCheckout', () => { expect(assignedHref).toBeUndefined() }) + it('does not track begin_checkout when subscribe fails', async () => { + mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed')) + + await expect( + performTeamSubscriptionCheckout('team_700', 'yearly') + ).rejects.toThrow('subscribe failed') + + expect(mockTrackBeginCheckout).not.toHaveBeenCalled() + }) + it('does nothing off cloud', async () => { mockIsCloud.value = false diff --git a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts index 6e8fa7573db..9dcd2b08d8b 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts @@ -31,23 +31,24 @@ export async function performTeamSubscriptionCheckout( if (!isCloud) return const { userId } = storeToRefs(useAuthStore()) + const planSlug = getTeamPlanSlug(billingCycle) + const response = await workspaceApi.subscribe(planSlug, { + returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`, + cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`, + teamCreditStopId + }) + if (userId.value) { useTelemetry()?.trackBeginCheckout({ user_id: userId.value, tier: 'team', cycle: billingCycle, checkout_type: 'new', + billing_op_id: response.billing_op_id, payment_intent_source: 'deep_link' }) } - const planSlug = getTeamPlanSlug(billingCycle) - const response = await workspaceApi.subscribe(planSlug, { - returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`, - cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`, - teamCreditStopId - }) - if (response.status === 'needs_payment_method') { // A needs_payment_method response without a URL is unusable: surface it to // the caller's error handling rather than silently dropping the user home diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index f2e7cc1a4ae..ade0fe3fc5e 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -439,6 +439,8 @@ export interface BeginCheckoutMetadata tier: TierKey | 'team' cycle: BillingCycle checkout_type: 'new' | 'change' + checkout_attempt_id?: string + billing_op_id?: string previous_tier?: TierKey payment_intent_source?: SubscriptionDialogReason } diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts index 6e918b7f062..d10d291d39c 100644 --- a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts +++ b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts @@ -158,6 +158,7 @@ describe('useSubscriptionCheckout', () => { vi.clearAllMocks() mockPlans.value = allPlans() mockStartOperation.mockResolvedValue({ status: 'succeeded' }) + mockUserId.value = 'user-1' emit = vi.fn() }) @@ -472,7 +473,11 @@ describe('useSubscriptionCheckout', () => { }) expect(checkout.checkoutStep.value).toBe('success') expect(mockTrackBeginCheckout).toHaveBeenCalledWith( - expect.objectContaining({ tier: 'team', checkout_type: 'new' }) + expect.objectContaining({ + tier: 'team', + checkout_type: 'new', + billing_op_id: 'op-team-1' + }) ) }) @@ -568,6 +573,39 @@ describe('useSubscriptionCheckout', () => { detail: 'Team payment failed' }) ) + expect(mockTrackBeginCheckout).not.toHaveBeenCalled() + }) + + it('keeps team checkout_type as change when the preview request fails', async () => { + const checkout = await setup() + mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported')) + await checkout.handleSubscribeTeamClick({ + stop: { + id: 'team_1400', + usd: 1400, + credits: 295_400, + discountedUsd: 1295 + }, + billingCycle: 'monthly', + isChange: true + }) + mockSubscribe.mockResolvedValueOnce({ + status: 'subscribed', + billing_op_id: 'op-team-change' + }) + mockFetchStatus.mockResolvedValueOnce(undefined) + mockFetchBalance.mockResolvedValueOnce(undefined) + + await checkout.handleTeamSubscribe() + + expect(mockTrackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + tier: 'team', + cycle: 'monthly', + checkout_type: 'change', + billing_op_id: 'op-team-change' + }) + ) }) }) @@ -654,6 +692,7 @@ describe('useSubscriptionCheckout', () => { tier: 'standard', cycle: 'yearly', checkout_type: 'new', + billing_op_id: 'op-1', payment_intent_source: 'subscribe_to_run' }) }) @@ -775,6 +814,7 @@ describe('useSubscriptionCheckout', () => { detail: 'Payment failed' }) ) + expect(mockTrackBeginCheckout).not.toHaveBeenCalled() }) }) diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.ts b/src/platform/workspace/composables/useSubscriptionCheckout.ts index f17c633e5f0..ccfd87a15de 100644 --- a/src/platform/workspace/composables/useSubscriptionCheckout.ts +++ b/src/platform/workspace/composables/useSubscriptionCheckout.ts @@ -21,6 +21,7 @@ import { useAuthStore } from '@/stores/authStore' type CheckoutStep = 'pricing' | 'preview' | 'success' type CheckoutTierKey = Exclude +type CheckoutType = 'new' | 'change' /** * Which screen the `preview` step shows. Only a change prorates: a team change @@ -75,6 +76,7 @@ export function useSubscriptionCheckout( const previewData = ref(null) const selectedTierKey = ref(null) const selectedTeamStop = ref(null) + const selectedTeamCheckoutType = ref('new') const selectedBillingCycle = ref('yearly') const isPolling = computed(() => billingOperationStore.hasPendingOperations) const isTeamCheckout = computed(() => selectedTeamStop.value !== null) @@ -100,15 +102,18 @@ export function useSubscriptionCheckout( function trackCheckoutStarted( tier: TierKey | 'team', - checkoutType: 'new' | 'change' + cycle: BillingCycle, + checkoutType: CheckoutType, + billingOpId: string ) { const { userId } = storeToRefs(useAuthStore()) if (!userId.value) return telemetry?.trackBeginCheckout({ user_id: userId.value, tier, - cycle: selectedBillingCycle.value, + cycle, checkout_type: checkoutType, + billing_op_id: billingOpId, ...(paymentIntentSource ? { payment_intent_source: paymentIntentSource } : {}) @@ -178,6 +183,7 @@ export function useSubscriptionCheckout( isChange?: boolean }) { selectedTeamStop.value = payload.stop + selectedTeamCheckoutType.value = payload.isChange ? 'change' : 'new' selectedBillingCycle.value = payload.billingCycle selectedTierKey.value = null previewData.value = null @@ -206,6 +212,7 @@ export function useSubscriptionCheckout( checkoutStep.value = 'pricing' previewData.value = null selectedTeamStop.value = null + selectedTeamCheckoutType.value = 'new' } function handleSuccessClose() { @@ -213,28 +220,33 @@ export function useSubscriptionCheckout( } async function handleSubscription() { - if (!selectedTierKey.value) return + const tierKey = selectedTierKey.value + if (!tierKey) return - trackCheckoutStarted( - selectedTierKey.value, + const billingCycle = selectedBillingCycle.value + const checkoutType = previewData.value && - previewData.value.transition_type !== 'new_subscription' + previewData.value.transition_type !== 'new_subscription' ? 'change' : 'new' - ) isSubscribing.value = true try { - const planSlug = getApiPlanSlug( - selectedTierKey.value, - selectedBillingCycle.value - ) + const planSlug = getApiPlanSlug(tierKey, billingCycle) if (!planSlug) return const response = await subscribe(planSlug, { returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`, cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed` }) + if (response) { + trackCheckoutStarted( + tierKey, + billingCycle, + checkoutType, + response.billing_op_id + ) + } await handleSubscribeResponse(response) } catch (error) { showSubscribeError(error) @@ -310,18 +322,27 @@ export function useSubscriptionCheckout( return } - trackCheckoutStarted('team', previewData.value ? 'change' : 'new') + const billingCycle = selectedBillingCycle.value + const checkoutType = selectedTeamCheckoutType.value isSubscribing.value = true try { - const planSlug = getTeamPlanSlug(selectedBillingCycle.value) + const planSlug = getTeamPlanSlug(billingCycle) const response = await subscribe(planSlug, { teamCreditStopId: stop.id, - billingCycle: selectedBillingCycle.value, + billingCycle, returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`, cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed` }) + if (response) { + trackCheckoutStarted( + 'team', + billingCycle, + checkoutType, + response.billing_op_id + ) + } await handleSubscribeResponse(response) } catch (error) { showSubscribeError(error) From edc8d95faf5bed0797dc2db9cdeb11e57ef3066e Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 1 Jul 2026 16:57:53 -0700 Subject: [PATCH 4/5] fix: repair checkout attribution gaps --- .../CloudSubscriptionRedirectView.test.ts | 15 ++-- .../CloudSubscriptionRedirectView.vue | 14 ++-- .../components/FreeTierDialogContent.test.ts | 4 +- .../components/FreeTierDialogContent.vue | 4 +- .../subscription/components/PricingTable.vue | 32 +++---- .../SubscriptionRequiredDialogContent.vue | 4 +- .../composables/useSubscriptionDialog.ts | 25 +----- .../utils/subscriptionCheckoutTracker.ts | 48 ++++++++--- .../utils/subscriptionCheckoutUtil.test.ts | 19 +++-- .../utils/subscriptionCheckoutUtil.ts | 54 ++++++++---- .../teamSubscriptionCheckoutUtil.test.ts | 4 +- .../utils/teamSubscriptionCheckoutUtil.ts | 31 ++++--- src/platform/telemetry/types.ts | 31 +++++-- ...bscriptionRequiredDialogContentUnified.vue | 4 +- ...tionRequiredDialogContentWorkspace.test.ts | 49 ++++++----- ...criptionRequiredDialogContentWorkspace.vue | 6 +- .../useSubscriptionCheckout.test.ts | 4 +- .../composables/useSubscriptionCheckout.ts | 83 +++++++++---------- .../utils/workspaceCheckoutTelemetry.ts | 40 +++++++++ 19 files changed, 282 insertions(+), 189 deletions(-) create mode 100644 src/platform/workspace/utils/workspaceCheckoutTelemetry.ts diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index d7691886e8b..59c37079c95 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -140,8 +140,10 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false, - 'deep_link' + { + openInNewTab: false, + paymentIntentSource: 'deep_link' + } ) // Shows loading affordances @@ -170,8 +172,10 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false, - 'deep_link' + { + openInNewTab: false, + paymentIntentSource: 'deep_link' + } ) }) @@ -182,7 +186,8 @@ describe('CloudSubscriptionRedirectView', () => { expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument() expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith( 'team_700', - 'yearly' + 'yearly', + { paymentIntentSource: 'deep_link' } ) // Team never goes through the personal checkout path expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled() diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue index 3eb59bb3150..7f44f488089 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -94,7 +94,9 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => { return } isTeamCheckout.value = true - await performTeamSubscriptionCheckout(stopId, billingCycle) + await performTeamSubscriptionCheckout(stopId, billingCycle, { + paymentIntentSource: 'deep_link' + }) return } @@ -112,12 +114,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => { if (isActiveSubscription.value) { await accessBillingPortal(undefined, false) } else { - await performSubscriptionCheckout( - tierKeyParam, - billingCycle, - false, - 'deep_link' - ) + await performSubscriptionCheckout(tierKeyParam, billingCycle, { + openInNewTab: false, + paymentIntentSource: 'deep_link' + }) } }, reportError) diff --git a/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts b/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts index 0231beef045..e8412b46514 100644 --- a/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts +++ b/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts @@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/vue' import enMessages from '@/locales/en/main.json' with { type: 'json' } -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import FreeTierDialogContent from './FreeTierDialogContent.vue' @@ -17,7 +17,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({ })) })) -function renderComponent(props?: { reason?: SubscriptionDialogReason }) { +function renderComponent(props?: { reason?: PaymentIntentSource }) { const i18n = createI18n({ legacy: false, locale: 'en', diff --git a/src/platform/cloud/subscription/components/FreeTierDialogContent.vue b/src/platform/cloud/subscription/components/FreeTierDialogContent.vue index a577d3145ed..d697ad21524 100644 --- a/src/platform/cloud/subscription/components/FreeTierDialogContent.vue +++ b/src/platform/cloud/subscription/components/FreeTierDialogContent.vue @@ -100,12 +100,12 @@ import { computed } from 'vue' import Button from '@/components/ui/button/Button.vue' import { useBillingContext } from '@/composables/billing/useBillingContext' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue' import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing' const { reason } = defineProps<{ - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource }>() defineEmits<{ diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 408c155a9e5..4d8a10f47b5 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -277,14 +277,19 @@ import type { TierKey, TierPricing } from '@/platform/cloud/subscription/constants/tierPricing' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' -import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker' +import { + recordPendingSubscriptionCheckoutAttempt, + withPendingCheckoutAttemptId +} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker' import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil' import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' -import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' +import type { + CheckoutAttributionMetadata, + PaymentIntentSource +} from '@/platform/telemetry/types' import { useAuthStore } from '@/stores/authStore' type CheckoutTierKey = Exclude @@ -323,7 +328,7 @@ interface PricingTierConfig { } const { reason } = defineProps<{ - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource }>() const emit = defineEmits<{ @@ -514,19 +519,18 @@ const handleSubscribe = wrapWithErrorHandlingAsync( : {}) }) if (beginCheckoutMetadata) { - telemetry?.trackBeginCheckout({ - ...beginCheckoutMetadata, - checkout_attempt_id: pendingAttempt.attempt_id - }) + telemetry?.trackBeginCheckout( + withPendingCheckoutAttemptId( + beginCheckoutMetadata, + pendingAttempt + ) + ) } } } else { - await performSubscriptionCheckout( - tierKey, - currentBillingCycle.value, - true, - reason - ) + await performSubscriptionCheckout(tierKey, currentBillingCycle.value, { + paymentIntentSource: reason + }) } } finally { isLoading.value = false diff --git a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue index 786ea98c8a7..2c660ae349a 100644 --- a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue +++ b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue @@ -161,11 +161,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' import { useCommandStore } from '@/stores/commandStore' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { PaymentIntentSource } from '@/platform/telemetry/types' const { onClose, reason, onChooseTeam } = defineProps<{ onClose: () => void - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource onChooseTeam?: () => void }>() diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts index b1bbdeeb04f..dddd93102aa 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts @@ -5,6 +5,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' @@ -12,28 +13,8 @@ const DIALOG_KEY = 'subscription-required' const FREE_TIER_DIALOG_KEY = 'free-tier-info' const RESUME_PRICING_KEY = 'comfy:resume-team-pricing' -/** - * The product moment that sent the user into the paywall/pricing flow. - * Attached to `app:subscription_required_modal_opened` and carried through - * `begin_checkout` so payment attempts stay attributable to their trigger. - */ -export type SubscriptionDialogReason = - | 'subscription_required' - | 'out_of_credits' - | 'top_up_blocked' - | 'deep_link' - | 'subscribe_to_run' - | 'subscribe_now_button' - | 'upgrade_to_add_credits' - | 'settings_billing_panel' - | 'avatar_menu_plans' - | 'team_members_panel' - | 'invite_member_upsell' - | 'upload_model_upgrade' - | 'team_upgrade_resume' - export interface SubscriptionDialogOptions { - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource /** * Forces the unified pricing dialog to open on a specific plan tab, * overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA @@ -55,7 +36,7 @@ export const useSubscriptionDialog = () => { // Fired here — the choke point every paywall/pricing dialog variant passes // through — so both the legacy and workspace billing paths emit it. - function trackModalOpened(reason?: SubscriptionDialogReason) { + function trackModalOpened(reason?: PaymentIntentSource) { // Resolved lazily to avoid the useBillingContext import cycle (see below). const { tier } = useBillingContext() useTelemetry()?.trackSubscription('modal_opened', { diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts index d108732773b..51e54ae30b9 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts @@ -6,9 +6,13 @@ import type { SubscriptionTier, TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' -import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types' +import type { + BeginCheckoutMetadata, + PaymentIntentSource, + SubscriptionCheckoutType, + SubscriptionSuccessMetadata +} from '@/platform/telemetry/types' const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000 const VALID_TIER_KEYS = new Set([ @@ -24,7 +28,6 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY = export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT = 'comfy:subscription-checkout-attempt-changed' -type CheckoutType = 'new' | 'change' type SubscriptionDuration = 'MONTHLY' | 'ANNUAL' interface SubscriptionStatusSnapshot { @@ -33,24 +36,24 @@ interface SubscriptionStatusSnapshot { subscription_duration?: SubscriptionDuration | null } -interface PendingSubscriptionCheckoutAttempt { +export interface PendingSubscriptionCheckoutAttempt { attempt_id: string started_at_ms: number tier: TierKey cycle: BillingCycle - checkout_type: CheckoutType + checkout_type: SubscriptionCheckoutType previous_tier?: TierKey previous_cycle?: BillingCycle - payment_intent_source?: SubscriptionDialogReason + payment_intent_source?: PaymentIntentSource } -interface RecordPendingSubscriptionCheckoutAttemptInput { +export interface PendingSubscriptionCheckoutAttemptInput { tier: TierKey cycle: BillingCycle - checkout_type: CheckoutType + checkout_type: SubscriptionCheckoutType previous_tier?: TierKey previous_cycle?: BillingCycle - payment_intent_source?: SubscriptionDialogReason + payment_intent_source?: PaymentIntentSource } const dispatchPendingCheckoutChangeEvent = () => { @@ -230,11 +233,10 @@ const getPendingSubscriptionCheckoutAttempt = export const hasPendingSubscriptionCheckoutAttempt = (): boolean => getPendingSubscriptionCheckoutAttempt() !== null -export const recordPendingSubscriptionCheckoutAttempt = ( - input: RecordPendingSubscriptionCheckoutAttemptInput +export const createPendingSubscriptionCheckoutAttempt = ( + input: PendingSubscriptionCheckoutAttemptInput ): PendingSubscriptionCheckoutAttempt => { - const storage = getStorage() - const attempt: PendingSubscriptionCheckoutAttempt = { + return { attempt_id: createAttemptId(), started_at_ms: Date.now(), tier: input.tier, @@ -246,7 +248,12 @@ export const recordPendingSubscriptionCheckoutAttempt = ( ? { payment_intent_source: input.payment_intent_source } : {}) } +} +export const persistPendingSubscriptionCheckoutAttempt = ( + attempt: PendingSubscriptionCheckoutAttempt +): PendingSubscriptionCheckoutAttempt => { + const storage = getStorage() if (!storage) { return attempt } @@ -264,6 +271,21 @@ export const recordPendingSubscriptionCheckoutAttempt = ( return attempt } +export const recordPendingSubscriptionCheckoutAttempt = ( + input: PendingSubscriptionCheckoutAttemptInput +): PendingSubscriptionCheckoutAttempt => + persistPendingSubscriptionCheckoutAttempt( + createPendingSubscriptionCheckoutAttempt(input) + ) + +export const withPendingCheckoutAttemptId = ( + metadata: BeginCheckoutMetadata, + attempt: PendingSubscriptionCheckoutAttempt +): BeginCheckoutMetadata => ({ + ...metadata, + checkout_attempt_id: attempt.attempt_id +}) + const didAttemptSucceed = ( attempt: PendingSubscriptionCheckoutAttempt, status: SubscriptionStatusSnapshot diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 3c8b4e80d1c..53593b0c71a 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -132,7 +132,7 @@ describe('performSubscriptionCheckout', () => { json: async () => ({ checkout_url: checkoutUrl }) } as Response) - await performSubscriptionCheckout('pro', 'yearly', true) + await performSubscriptionCheckout('pro', 'yearly') expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({ user_id: 'user-123', @@ -193,7 +193,7 @@ describe('performSubscriptionCheckout', () => { json: async () => ({ checkout_url: checkoutUrl }) } as Response) - await performSubscriptionCheckout('pro', 'monthly', true) + await performSubscriptionCheckout('pro', 'monthly') expect(warnSpy).toHaveBeenCalledWith( '[SubscriptionCheckout] Failed to collect checkout attribution', @@ -227,7 +227,9 @@ describe('performSubscriptionCheckout', () => { json: async () => ({ checkout_url: checkoutUrl }) } as Response) - await performSubscriptionCheckout('pro', 'monthly', true, 'out_of_credits') + await performSubscriptionCheckout('pro', 'monthly', { + paymentIntentSource: 'out_of_credits' + }) expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith( expect.objectContaining({ payment_intent_source: 'out_of_credits' }) @@ -259,7 +261,7 @@ describe('performSubscriptionCheckout', () => { json: async () => ({ checkout_url: checkoutUrl }) } as Response) - const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true) + const checkoutPromise = performSubscriptionCheckout('pro', 'yearly') mockUserId.value = 'user-late' authHeader.resolve({ Authorization: 'Bearer test-token' }) @@ -279,7 +281,7 @@ describe('performSubscriptionCheckout', () => { expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') }) - it('persists the pending attempt when the checkout popup is blocked', async () => { + it('does not persist the pending attempt when the checkout popup is blocked', async () => { const checkoutUrl = 'https://checkout.stripe.com/test' const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) @@ -288,16 +290,17 @@ describe('performSubscriptionCheckout', () => { json: async () => ({ checkout_url: checkoutUrl }) } as Response) - await performSubscriptionCheckout('pro', 'monthly', true) + await performSubscriptionCheckout('pro', 'monthly') expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') const storedAttempt = window.localStorage.getItem( PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY ) - expect(storedAttempt).not.toBeNull() + expect(storedAttempt).toBeNull() + expect(mockLocalStorage.setItem).not.toHaveBeenCalled() expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith( expect.objectContaining({ - checkout_attempt_id: JSON.parse(storedAttempt ?? '{}').attempt_id + checkout_attempt_id: expect.any(String) }) ) }) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index dd68d3500ab..becb8ad0d7f 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -4,13 +4,19 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags' import { getComfyApiBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry' +import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import { + createPendingSubscriptionCheckoutAttempt, + persistPendingSubscriptionCheckoutAttempt, + withPendingCheckoutAttemptId +} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' +import type { + CheckoutAttributionMetadata, + PaymentIntentSource +} from '@/platform/telemetry/types' import { AuthStoreError, useAuthStore } from '@/stores/authStore' -import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' -import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker' import type { BillingCycle } from './subscriptionTierRank' type CheckoutTier = TierKey | `${TierKey}-yearly` @@ -32,6 +38,11 @@ const getCheckoutAttributionForCloud = return getCheckoutAttribution() } +interface PerformSubscriptionCheckoutOptions { + openInNewTab?: boolean + paymentIntentSource?: PaymentIntentSource +} + /** * Core subscription checkout logic shared between PricingTable and * SubscriptionRedirectView. Handles: @@ -48,11 +59,12 @@ const getCheckoutAttributionForCloud = export async function performSubscriptionCheckout( tierKey: TierKey, currentBillingCycle: BillingCycle, - openInNewTab: boolean = true, - paymentIntentSource?: SubscriptionDialogReason + options: PerformSubscriptionCheckoutOptions = {} ): Promise { if (!isCloud) return + const { openInNewTab = true, paymentIntentSource } = options + const authStore = useAuthStore() const { userId } = storeToRefs(authStore) const telemetry = useTelemetry() @@ -110,7 +122,7 @@ export async function performSubscriptionCheckout( const data = await response.json() if (data.checkout_url) { - const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({ + const pendingAttempt = createPendingSubscriptionCheckoutAttempt({ tier: tierKey, cycle: currentBillingCycle, checkout_type: 'new', @@ -118,17 +130,21 @@ export async function performSubscriptionCheckout( }) if (userId.value) { - telemetry?.trackBeginCheckout({ - user_id: userId.value, - tier: tierKey, - cycle: currentBillingCycle, - checkout_type: 'new', - checkout_attempt_id: pendingAttempt.attempt_id, - ...(paymentIntentSource - ? { payment_intent_source: paymentIntentSource } - : {}), - ...checkoutAttribution - }) + telemetry?.trackBeginCheckout( + withPendingCheckoutAttemptId( + { + user_id: userId.value, + tier: tierKey, + cycle: currentBillingCycle, + checkout_type: 'new', + ...(paymentIntentSource + ? { payment_intent_source: paymentIntentSource } + : {}), + ...checkoutAttribution + }, + pendingAttempt + ) + ) } if (openInNewTab) { @@ -136,7 +152,9 @@ export async function performSubscriptionCheckout( if (!checkoutWindow) { return } + persistPendingSubscriptionCheckoutAttempt(pendingAttempt) } else { + persistPendingSubscriptionCheckoutAttempt(pendingAttempt) globalThis.location.href = data.checkout_url } } diff --git a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts index ceb1d5d9c24..735cf691930 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.test.ts @@ -53,7 +53,9 @@ describe('performTeamSubscriptionCheckout', () => { billing_op_id: 'op_1' }) - await performTeamSubscriptionCheckout('team_700', 'yearly') + await performTeamSubscriptionCheckout('team_700', 'yearly', { + paymentIntentSource: 'deep_link' + }) expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', { returnUrl: 'https://app.test/payment/success', diff --git a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts index 9dcd2b08d8b..fa558763833 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts @@ -1,14 +1,16 @@ -import { storeToRefs } from 'pinia' - import { getComfyPlatformBaseUrl } from '@/config/comfyApi' import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops' import { isCloud } from '@/platform/distribution/types' -import { useTelemetry } from '@/platform/telemetry' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import { workspaceApi } from '@/platform/workspace/api/workspaceApi' -import { useAuthStore } from '@/stores/authStore' +import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry' import type { BillingCycle } from './subscriptionTierRank' +interface PerformTeamSubscriptionCheckoutOptions { + paymentIntentSource?: PaymentIntentSource +} + /** * Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep * link: subscribes to the per-credit Team plan at the chosen slider stop and @@ -26,11 +28,11 @@ import type { BillingCycle } from './subscriptionTierRank' */ export async function performTeamSubscriptionCheckout( teamCreditStopId: string, - billingCycle: BillingCycle + billingCycle: BillingCycle, + options: PerformTeamSubscriptionCheckoutOptions = {} ): Promise { if (!isCloud) return - const { userId } = storeToRefs(useAuthStore()) const planSlug = getTeamPlanSlug(billingCycle) const response = await workspaceApi.subscribe(planSlug, { returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`, @@ -38,16 +40,13 @@ export async function performTeamSubscriptionCheckout( teamCreditStopId }) - if (userId.value) { - useTelemetry()?.trackBeginCheckout({ - user_id: userId.value, - tier: 'team', - cycle: billingCycle, - checkout_type: 'new', - billing_op_id: response.billing_op_id, - payment_intent_source: 'deep_link' - }) - } + trackWorkspaceCheckoutStarted({ + tier: 'team', + cycle: billingCycle, + checkoutType: 'new', + billingOpId: response.billing_op_id, + paymentIntentSource: options.paymentIntentSource + }) if (response.status === 'needs_payment_method') { // A needs_payment_method response without a URL is unusable: surface it to diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index ade0fe3fc5e..d9a714cd217 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -12,12 +12,29 @@ * 3. Check dist/assets/*.js files contain no tracking code */ -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { AuditLog } from '@/services/customerEventsService' import type { AppMode } from '@/utils/appMode' +export type PaymentIntentSource = + | 'subscription_required' + | 'out_of_credits' + | 'top_up_blocked' + | 'deep_link' + | 'subscribe_to_run' + | 'subscribe_now_button' + | 'upgrade_to_add_credits' + | 'settings_billing_panel' + | 'avatar_menu_plans' + | 'team_members_panel' + | 'invite_member_upsell' + | 'upload_model_upgrade' + | 'team_upgrade_resume' + +export type SubscriptionCheckoutType = 'new' | 'change' +export type SubscriptionCheckoutTier = TierKey | 'team' + /** * Authentication metadata for sign-up tracking */ @@ -426,7 +443,7 @@ export interface CheckoutAttributionMetadata { export interface SubscriptionMetadata { current_tier?: string - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource } export interface AddCreditsClickMetadata { @@ -436,13 +453,13 @@ export interface AddCreditsClickMetadata { export interface BeginCheckoutMetadata extends Record, CheckoutAttributionMetadata { user_id: string - tier: TierKey | 'team' + tier: SubscriptionCheckoutTier cycle: BillingCycle - checkout_type: 'new' | 'change' + checkout_type: SubscriptionCheckoutType checkout_attempt_id?: string billing_op_id?: string previous_tier?: TierKey - payment_intent_source?: SubscriptionDialogReason + payment_intent_source?: PaymentIntentSource } interface EcommerceItemMetadata { @@ -464,9 +481,9 @@ export interface SubscriptionSuccessMetadata extends Record { checkout_attempt_id: string tier: TierKey cycle: BillingCycle - checkout_type: 'new' | 'change' + checkout_type: SubscriptionCheckoutType previous_tier?: TierKey - payment_intent_source?: SubscriptionDialogReason + payment_intent_source?: PaymentIntentSource value: number currency: string ecommerce: EcommerceMetadata diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue index 9a1da4dc3c8..e7ba5e02ef5 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue @@ -113,7 +113,7 @@ import { cn } from '@comfyorg/tailwind-utils' import { useEventListener } from '@vueuse/core' import Button from '@/components/ui/button/Button.vue' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout' import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue' @@ -123,7 +123,7 @@ import UnifiedPricingTable from './UnifiedPricingTable.vue' const { onClose, reason, initialPlanMode } = defineProps<{ onClose: () => void - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource initialPlanMode?: 'personal' | 'team' }>() diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts index d648d5876e0..ff5d4b9271e 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { createI18n } from 'vue-i18n' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue' @@ -17,25 +17,10 @@ const mockHandleResubscribe = vi.fn() const mockHandleSuccessClose = vi.fn() const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing') const mockPreviewData = ref<{ transition_type: string } | null>(null) +const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn()) vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({ - useSubscriptionCheckout: () => ({ - checkoutStep: mockCheckoutStep, - isLoadingPreview: ref(false), - loadingTier: ref(null), - isSubscribing: ref(false), - isResubscribing: ref(false), - previewData: mockPreviewData, - selectedTierKey: ref('standard'), - selectedBillingCycle: ref('yearly'), - isPolling: ref(false), - handleSubscribeClick: mockHandleSubscribeClick, - handleBackToPricing: mockHandleBackToPricing, - handleAddCreditCard: mockHandleAddCreditCard, - handleConfirmTransition: mockHandleConfirmTransition, - handleResubscribe: mockHandleResubscribe, - handleSuccessClose: mockHandleSuccessClose - }) + useSubscriptionCheckout: mockUseSubscriptionCheckout })) const i18n = createI18n({ @@ -91,7 +76,7 @@ const SuccessStub = { function renderComponent( props: { onClose?: () => void - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource isPersonal?: boolean } = {} ) { @@ -121,6 +106,23 @@ function renderComponent( describe('SubscriptionRequiredDialogContentWorkspace', () => { beforeEach(() => { vi.clearAllMocks() + mockUseSubscriptionCheckout.mockReturnValue({ + checkoutStep: mockCheckoutStep, + isLoadingPreview: ref(false), + loadingTier: ref(null), + isSubscribing: ref(false), + isResubscribing: ref(false), + previewData: mockPreviewData, + selectedTierKey: ref('standard'), + selectedBillingCycle: ref('yearly'), + isPolling: ref(false), + handleSubscribeClick: mockHandleSubscribeClick, + handleBackToPricing: mockHandleBackToPricing, + handleAddCreditCard: mockHandleAddCreditCard, + handleConfirmTransition: mockHandleConfirmTransition, + handleResubscribe: mockHandleResubscribe, + handleSuccessClose: mockHandleSuccessClose + }) mockCheckoutStep.value = 'pricing' mockPreviewData.value = null }) @@ -132,6 +134,15 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => { expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument() }) + it('passes the reason into subscription checkout', () => { + renderComponent({ reason: 'out_of_credits' }) + + expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith( + expect.any(Function), + 'out_of_credits' + ) + }) + it('shows the team workspace header by default', () => { renderComponent() expect(screen.getByText('Team Workspace')).toBeInTheDocument() diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue index e2dbba81e56..58f8e0f9e61 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue @@ -116,7 +116,7 @@ import { cn } from '@comfyorg/tailwind-utils' import Button from '@/components/ui/button/Button.vue' -import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout' import PricingTableWorkspace from './PricingTableWorkspace.vue' @@ -130,7 +130,7 @@ const { isPersonal = false } = defineProps<{ onClose: () => void - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource isPersonal?: boolean }>() @@ -154,7 +154,7 @@ const { handleConfirmTransition, handleResubscribe, handleSuccessClose -} = useSubscriptionCheckout(emit) +} = useSubscriptionCheckout(emit, reason)