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)