diff --git a/src/components/topbar/CurrentUserPopoverLegacy.vue b/src/components/topbar/CurrentUserPopoverLegacy.vue index 54620e412bc..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') } @@ -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') } @@ -254,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/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..59c37079c95 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -140,7 +140,10 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false + { + openInNewTab: false, + paymentIntentSource: 'deep_link' + } ) // Shows loading affordances @@ -169,7 +172,10 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false + { + openInNewTab: false, + paymentIntentSource: 'deep_link' + } ) }) @@ -180,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 0f359277e25..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,7 +114,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => { if (isActiveSubscription.value) { await accessBillingPortal(undefined, false) } else { - await performSubscriptionCheckout(tierKeyParam, billingCycle, false) + await performSubscriptionCheckout(tierKeyParam, billingCycle, { + openInNewTab: false, + paymentIntentSource: '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/FreeTierDialogContent.test.ts b/src/platform/cloud/subscription/components/FreeTierDialogContent.test.ts index e4e92869970..e8412b46514 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 { PaymentIntentSource } from '@/platform/telemetry/types' + 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?: PaymentIntentSource }) { 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..d697ad21524 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') }} @@ -103,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' -defineProps<{ - reason?: SubscriptionDialogReason +const { reason } = defineProps<{ + reason?: PaymentIntentSource }>() defineEmits<{ @@ -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/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 e5e87423ef2..4d8a10f47b5 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -277,13 +277,19 @@ import type { TierKey, TierPricing } from '@/platform/cloud/subscription/constants/tierPricing' -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 @@ -321,6 +327,10 @@ interface PricingTierConfig { isPopular?: boolean } +const { reason } = defineProps<{ + reason?: PaymentIntentSource +}>() + const emit = defineEmits<{ chooseTeamWorkspace: [] }>() @@ -463,16 +473,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', - ...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, @@ -487,29 +498,39 @@ 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', + payment_intent_source: reason, ...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}), ...(previousPlan ? { previous_cycle: previousPlan.billingCycle } : {}) }) + if (beginCheckoutMetadata) { + telemetry?.trackBeginCheckout( + withPendingCheckoutAttemptId( + beginCheckoutMetadata, + pendingAttempt + ) + ) + } } } else { - await performSubscriptionCheckout( - tierKey, - currentBillingCycle.value, - true - ) + await performSubscriptionCheckout(tierKey, currentBillingCycle.value, { + paymentIntentSource: reason + }) } } finally { isLoading.value = false 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..2c660ae349a 100644 --- a/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue +++ b/src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue @@ -33,7 +33,11 @@ - +

@@ -157,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/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/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..c9eece9d297 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,51 @@ 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 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() + + showPricingTable({ reason: 'subscribe_to_run' }) + + expect(mockTrackSubscription).not.toHaveBeenCalled() + }) }) describe('show', () => { @@ -235,6 +288,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..dddd93102aa 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts @@ -4,6 +4,8 @@ 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 type { PaymentIntentSource } from '@/platform/telemetry/types' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' @@ -11,14 +13,8 @@ const DIALOG_KEY = 'subscription-required' const FREE_TIER_DIALOG_KEY = 'free-tier-info' const RESUME_PRICING_KEY = 'comfy:resume-team-pricing' -export type SubscriptionDialogReason = - | 'subscription_required' - | 'out_of_credits' - | 'top_up_blocked' - | 'deep_link' - -interface SubscriptionDialogOptions { - reason?: SubscriptionDialogReason +export interface SubscriptionDialogOptions { + 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 @@ -38,6 +34,17 @@ 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?: PaymentIntentSource) { + // 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 @@ -71,6 +78,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;', @@ -167,6 +176,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 +247,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.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/cloud/subscription/utils/subscriptionCheckoutTracker.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts index 50b31837fdc..2a9873de5e7 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts @@ -7,7 +7,12 @@ import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' 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([ @@ -23,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 { @@ -32,22 +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?: PaymentIntentSource } -interface RecordPendingSubscriptionCheckoutAttemptInput { +interface PendingSubscriptionCheckoutAttemptInput { tier: TierKey cycle: BillingCycle - checkout_type: CheckoutType + checkout_type: SubscriptionCheckoutType previous_tier?: TierKey previous_cycle?: BillingCycle + payment_intent_source?: PaymentIntentSource } const dispatchPendingCheckoutChangeEvent = () => { @@ -168,6 +174,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 } : {}) } } @@ -224,20 +233,27 @@ 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, 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 } + : {}) } +} +export const persistPendingSubscriptionCheckoutAttempt = ( + attempt: PendingSubscriptionCheckoutAttempt +): PendingSubscriptionCheckoutAttempt => { + const storage = getStorage() if (!storage) { return attempt } @@ -255,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 @@ -287,6 +318,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..53593b0c71a 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -132,13 +132,14 @@ 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', 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' @@ -186,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', @@ -203,11 +210,43 @@ 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') }) + 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', { + paymentIntentSource: 'out_of_credits' + }) + + 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] + 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() + }) + it('uses the latest userId when it changes after checkout starts', async () => { const checkoutUrl = 'https://checkout.stripe.com/test' const openSpy = vi @@ -222,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' }) @@ -235,13 +274,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('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) @@ -250,11 +290,18 @@ describe('performSubscriptionCheckout', () => { json: async () => ({ checkout_url: checkoutUrl }) } as Response) - await performSubscriptionCheckout('pro', 'monthly', true) + await performSubscriptionCheckout('pro', 'monthly') 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).toBeNull() + expect(mockLocalStorage.setItem).not.toHaveBeenCalled() + expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + 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 2f2cde9d8cc..becb8ad0d7f 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -4,12 +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 { 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` @@ -31,6 +38,11 @@ const getCheckoutAttributionForCloud = return getCheckoutAttribution() } +interface PerformSubscriptionCheckoutOptions { + openInNewTab?: boolean + paymentIntentSource?: PaymentIntentSource +} + /** * Core subscription checkout logic shared between PricingTable and * SubscriptionRedirectView. Handles: @@ -47,10 +59,12 @@ const getCheckoutAttributionForCloud = export async function performSubscriptionCheckout( tierKey: TierKey, currentBillingCycle: BillingCycle, - openInNewTab: boolean = true + options: PerformSubscriptionCheckoutOptions = {} ): Promise { if (!isCloud) return + const { openInNewTab = true, paymentIntentSource } = options + const authStore = useAuthStore() const { userId } = storeToRefs(authStore) const telemetry = useTelemetry() @@ -108,14 +122,29 @@ export async function performSubscriptionCheckout( const data = await response.json() if (data.checkout_url) { + const pendingAttempt = createPendingSubscriptionCheckoutAttempt({ + 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', - ...checkoutAttribution - }) + telemetry?.trackBeginCheckout( + withPendingCheckoutAttemptId( + { + user_id: userId.value, + tier: tierKey, + cycle: currentBillingCycle, + checkout_type: 'new', + ...(paymentIntentSource + ? { payment_intent_source: paymentIntentSource } + : {}), + ...checkoutAttribution + }, + pendingAttempt + ) + ) } if (openInNewTab) { @@ -123,18 +152,9 @@ export async function performSubscriptionCheckout( if (!checkoutWindow) { return } - - recordPendingSubscriptionCheckoutAttempt({ - tier: tierKey, - cycle: currentBillingCycle, - checkout_type: 'new' - }) + persistPendingSubscriptionCheckoutAttempt(pendingAttempt) } else { - recordPendingSubscriptionCheckoutAttempt({ - tier: tierKey, - cycle: currentBillingCycle, - checkout_type: 'new' - }) + 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 54bf91ae10d..735cf691930 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' @@ -43,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', @@ -51,6 +63,14 @@ 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', + billing_op_id: 'op_1', + payment_intent_source: 'deep_link' + }) }) it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => { @@ -82,6 +102,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 c608b394ff9..fa558763833 100644 --- a/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil.ts @@ -1,10 +1,16 @@ import { getComfyPlatformBaseUrl } from '@/config/comfyApi' import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops' import { isCloud } from '@/platform/distribution/types' +import type { PaymentIntentSource } from '@/platform/telemetry/types' import { workspaceApi } from '@/platform/workspace/api/workspaceApi' +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 @@ -22,7 +28,8 @@ import type { BillingCycle } from './subscriptionTierRank' */ export async function performTeamSubscriptionCheckout( teamCreditStopId: string, - billingCycle: BillingCycle + billingCycle: BillingCycle, + options: PerformTeamSubscriptionCheckoutOptions = {} ): Promise { if (!isCloud) return @@ -33,6 +40,14 @@ export async function performTeamSubscriptionCheckout( teamCreditStopId }) + 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 // the caller's error handling rather than silently dropping the user home 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/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.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/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..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,16 +443,23 @@ export interface CheckoutAttributionMetadata { export interface SubscriptionMetadata { current_tier?: string - reason?: SubscriptionDialogReason + reason?: PaymentIntentSource +} + +export interface AddCreditsClickMetadata { + source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel' } export interface BeginCheckoutMetadata extends Record, CheckoutAttributionMetadata { user_id: string - tier: TierKey + 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?: PaymentIntentSource } interface EcommerceItemMetadata { @@ -457,8 +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?: PaymentIntentSource value: number currency: string ecommerce: EcommerceMetadata @@ -489,7 +514,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..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' }>() @@ -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/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)