feat: add frontend subscription success recovery

This commit is contained in:
Benjamin Lu
2026-04-15 15:48:16 -07:00
parent a8e1fa8bef
commit 53763f4d5b
12 changed files with 748 additions and 96 deletions

View File

@@ -274,6 +274,7 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } 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'
@@ -479,6 +480,15 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle.value,
checkout_type: 'change',
...(currentTierKey.value
? { previous_tier: currentTierKey.value }
: {}),
previous_cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
})
await accessBillingPortal(checkoutTier)
}
} else {

View File

@@ -145,7 +145,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue'
import { computed, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -169,7 +169,7 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { fetchStatus, isActiveSubscription } = useBillingContext()
const { isActiveSubscription } = useBillingContext()
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
@@ -190,69 +190,10 @@ const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
}
}
watch(
showCustomPricingTable,
(enabled) => {
if (enabled) {
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
{ immediate: true }
)
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
telemetry?.trackMonthlySubscriptionSucceeded()
emit('close', true)
}
}
@@ -263,7 +204,6 @@ const handleSubscribed = () => {
}
const handleChooseTeam = () => {
stopPolling()
if (onChooseTeam) {
onChooseTeam()
} else {
@@ -272,7 +212,6 @@ const handleChooseTeam = () => {
}
const handleClose = () => {
stopPolling()
onClose()
}
@@ -293,11 +232,6 @@ const handleViewEnterprise = () => {
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
const {
mockIsLoggedIn,
@@ -12,7 +13,8 @@ const {
mockGetCheckoutAttribution,
mockTelemetry,
mockUserId,
mockIsCloud
mockIsCloud,
mockLocalStorage
} = vi.hoisted(() => ({
mockIsLoggedIn: { value: false },
mockIsCloud: { value: true },
@@ -28,9 +30,29 @@ const {
})),
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionSucceeded: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
},
mockUserId: { value: 'user-123' }
mockUserId: { value: 'user-123' },
mockLocalStorage: (() => {
const store = new Map<string, string>()
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
}),
__reset: () => {
store.clear()
}
}
})()
}))
let scope: ReturnType<typeof effectScope> | undefined
@@ -55,6 +77,16 @@ function useSubscriptionWithScope() {
return subscription
}
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
isLoggedIn: mockIsLoggedIn
@@ -124,6 +156,7 @@ describe('useSubscription', () => {
scope?.stop()
scope = undefined
setDistribution('localhost')
mockLocalStorage.__reset()
})
beforeEach(() => {
@@ -132,8 +165,10 @@ describe('useSubscription', () => {
setDistribution('cloud')
vi.clearAllMocks()
mockLocalStorage.__reset()
mockIsLoggedIn.value = false
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
mockUserId.value = 'user-123'
mockIsCloud.value = true
@@ -311,6 +346,16 @@ describe('useSubscription', () => {
)
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ??
'{}'
)
).toMatchObject({
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
windowOpenSpy.mockRestore()
})
@@ -327,6 +372,135 @@ describe('useSubscription', () => {
})
})
describe('pending checkout recovery', () => {
it('emits subscription_success when a pending new subscription becomes active', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-123',
started_at_ms: Date.now(),
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new'
})
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
subscription_duration: 'ANNUAL',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
user_id: 'user-123',
checkout_attempt_id: 'attempt-123',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new',
value: 336,
currency: 'USD'
})
)
})
expect(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
it('emits subscription_success when a pending upgrade reaches the target tier', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-456',
started_at_ms: Date.now(),
tier: 'pro',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'creator',
previous_cycle: 'monthly'
})
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: 'attempt-456',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'creator',
value: 100
})
)
})
})
it('rechecks pending checkout attempts on pageshow', async () => {
mockIsLoggedIn.value = true
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response)
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1)
})
vi.mocked(global.fetch).mockClear()
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-pageshow',
started_at_ms: Date.now(),
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
)
window.dispatchEvent(new Event('pageshow'))
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})
})
describe('requireActiveSubscription', () => {
it('should not show dialog when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({

View File

@@ -1,5 +1,9 @@
import { computed, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import {
createSharedComposable,
defaultWindow,
useEventListener
} from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
@@ -14,6 +18,13 @@ import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
import {
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
consumePendingSubscriptionCheckoutSuccess,
hasPendingSubscriptionCheckoutAttempt,
recordPendingSubscriptionCheckoutAttempt
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = NonNullable<
@@ -24,6 +35,8 @@ export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
const PENDING_SUBSCRIPTION_CHECKOUT_RETRY_DELAYS_MS = [3000, 10000, 30000]
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const telemetry = useTelemetry()
@@ -111,6 +124,78 @@ function useSubscriptionInternal() {
return getCheckoutAttribution()
}
let pendingCheckoutRecoveryTimeout: number | null = null
let pendingCheckoutRecoveryAttempt = 0
let isRecoveringPendingCheckout = false
const stopPendingCheckoutRecovery = () => {
if (pendingCheckoutRecoveryTimeout !== null && defaultWindow) {
defaultWindow.clearTimeout(pendingCheckoutRecoveryTimeout)
}
pendingCheckoutRecoveryTimeout = null
pendingCheckoutRecoveryAttempt = 0
}
const schedulePendingCheckoutRecovery = () => {
if (
!defaultWindow ||
pendingCheckoutRecoveryTimeout !== null ||
!isLoggedIn.value ||
!hasPendingSubscriptionCheckoutAttempt()
) {
return
}
const nextDelay =
PENDING_SUBSCRIPTION_CHECKOUT_RETRY_DELAYS_MS[
pendingCheckoutRecoveryAttempt
]
if (nextDelay === undefined) {
return
}
pendingCheckoutRecoveryTimeout = defaultWindow.setTimeout(() => {
pendingCheckoutRecoveryTimeout = null
pendingCheckoutRecoveryAttempt += 1
void recoverPendingSubscriptionCheckout('retry')
}, nextDelay)
}
const syncPendingSubscriptionSuccess = (
statusData: CloudSubscriptionStatusResponse
) => {
const metadata = consumePendingSubscriptionCheckoutSuccess(statusData)
if (!metadata) {
if (hasPendingSubscriptionCheckoutAttempt()) {
schedulePendingCheckoutRecovery()
} else {
stopPendingCheckoutRecovery()
}
return
}
telemetry?.trackMonthlySubscriptionSucceeded({
...(authStore.userId ? { user_id: authStore.userId } : {}),
...metadata
})
stopPendingCheckoutRecovery()
}
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
return {
...authHeader,
'Content-Type': 'application/json'
}
}
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
@@ -127,6 +212,20 @@ function useSubscriptionInternal() {
)
}
recordPendingSubscriptionCheckoutAttempt({
tier: 'standard',
cycle: 'monthly',
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
...(subscriptionTier.value
? { previous_tier: TIER_TO_KEY[subscriptionTier.value] }
: {}),
...(subscriptionDuration.value === 'ANNUAL'
? { previous_cycle: 'yearly' as const }
: subscriptionDuration.value === 'MONTHLY'
? { previous_cycle: 'monthly' as const }
: {})
})
window.open(response.checkout_url, '_blank')
}, reportError)
@@ -184,23 +283,44 @@ function useSubscriptionInternal() {
await accessBillingPortal()
}
const recoverPendingSubscriptionCheckout = async (
source: 'pageshow' | 'visibilitychange' | 'retry'
) => {
if (
!isCloud ||
!isLoggedIn.value ||
!hasPendingSubscriptionCheckoutAttempt() ||
isRecoveringPendingCheckout
) {
return
}
isRecoveringPendingCheckout = true
try {
await fetchSubscriptionStatus()
} catch (error) {
console.error(
`[Subscription] Failed to recover pending checkout on ${source}:`,
error
)
schedulePendingCheckoutRecovery()
} finally {
isRecoveringPendingCheckout = false
}
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const headers = await buildAuthHeaders()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
headers
}
)
@@ -215,10 +335,41 @@ function useSubscriptionInternal() {
const statusData = await response.json()
subscriptionStatus.value = statusData
syncPendingSubscriptionSuccess(statusData)
return statusData
}
const handlePendingSubscriptionCheckoutChange = () => {
if (!hasPendingSubscriptionCheckoutAttempt()) {
stopPendingCheckoutRecovery()
return
}
pendingCheckoutRecoveryAttempt = 0
void recoverPendingSubscriptionCheckout('retry')
}
useEventListener(defaultWindow, PENDING_SUBSCRIPTION_CHECKOUT_EVENT, () => {
handlePendingSubscriptionCheckoutChange()
})
useEventListener(defaultWindow, 'storage', (event: StorageEvent) => {
if (event.key === PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) {
handlePendingSubscriptionCheckoutChange()
}
})
useEventListener(defaultWindow, 'pageshow', () => {
void recoverPendingSubscriptionCheckout('pageshow')
})
useEventListener(defaultWindow, 'visibilitychange', () => {
if (document.visibilityState === 'visible') {
void recoverPendingSubscriptionCheckout('visibilitychange')
}
})
watch(
() => isLoggedIn.value,
async (loggedIn) => {
@@ -234,6 +385,7 @@ function useSubscriptionInternal() {
}
} else {
subscriptionStatus.value = null
stopPendingCheckoutRecovery()
stopCancellationWatcher()
isInitialized.value = true
}
@@ -243,20 +395,14 @@ function useSubscriptionInternal() {
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify(checkoutAttribution)
}
)

View File

@@ -0,0 +1,296 @@
import {
TIER_TO_KEY,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type {
SubscriptionTier,
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
'free',
'standard',
'creator',
'pro',
'founder'
])
export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
'comfy.subscription.pending_checkout_attempt'
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
is_active?: boolean
subscription_tier?: SubscriptionTier | null
subscription_duration?: SubscriptionDuration | null
}
export interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
}
interface RecordPendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
}
const dispatchPendingCheckoutChangeEvent = () => {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new Event(PENDING_SUBSCRIPTION_CHECKOUT_EVENT))
}
const createAttemptId = (): string => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `attempt-${Date.now()}`
}
const getStorage = (): Storage | null => {
const storage = globalThis.localStorage
if (
!storage ||
typeof storage.getItem !== 'function' ||
typeof storage.setItem !== 'function' ||
typeof storage.removeItem !== 'function'
) {
return null
}
return storage
}
const getAnnualCheckoutValue = (tier: Exclude<TierKey, 'free' | 'founder'>) =>
getTierPrice(tier, true) * 12
const getCheckoutValue = (tier: TierKey, cycle: BillingCycle): number => {
if (tier === 'free' || tier === 'founder') {
return getTierPrice(tier, cycle === 'yearly')
}
return cycle === 'yearly'
? getAnnualCheckoutValue(tier)
: getTierPrice(tier, false)
}
const getTierFromStatus = (
status: SubscriptionStatusSnapshot
): TierKey | null => {
const subscriptionTier = status.subscription_tier
if (!subscriptionTier) {
return null
}
return TIER_TO_KEY[subscriptionTier] ?? null
}
const getCycleFromStatus = (
status: SubscriptionStatusSnapshot
): BillingCycle | null => {
if (status.subscription_duration === 'ANNUAL') {
return 'yearly'
}
if (status.subscription_duration === 'MONTHLY') {
return 'monthly'
}
return null
}
const isExpired = (attempt: PendingSubscriptionCheckoutAttempt): boolean =>
Date.now() - attempt.started_at_ms > PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS
const normalizeAttempt = (
value: unknown
): PendingSubscriptionCheckoutAttempt | null => {
if (!value || typeof value !== 'object') {
return null
}
const candidate = value as Partial<PendingSubscriptionCheckoutAttempt>
if (
typeof candidate.attempt_id !== 'string' ||
typeof candidate.started_at_ms !== 'number' ||
typeof candidate.tier !== 'string' ||
typeof candidate.cycle !== 'string' ||
typeof candidate.checkout_type !== 'string'
) {
return null
}
if (
!VALID_TIER_KEYS.has(candidate.tier as TierKey) ||
(candidate.cycle !== 'monthly' && candidate.cycle !== 'yearly') ||
(candidate.checkout_type !== 'new' && candidate.checkout_type !== 'change')
) {
return null
}
return {
attempt_id: candidate.attempt_id,
started_at_ms: candidate.started_at_ms,
tier: candidate.tier as TierKey,
cycle: candidate.cycle,
checkout_type: candidate.checkout_type,
...(candidate.previous_tier &&
VALID_TIER_KEYS.has(candidate.previous_tier as TierKey)
? { previous_tier: candidate.previous_tier as TierKey }
: {}),
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {})
}
}
export const clearPendingSubscriptionCheckoutAttempt = (): void => {
const storage = getStorage()
if (!storage) {
return
}
storage.removeItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
dispatchPendingCheckoutChangeEvent()
}
export const getPendingSubscriptionCheckoutAttempt =
(): PendingSubscriptionCheckoutAttempt | null => {
const storage = getStorage()
if (!storage) {
return null
}
const rawAttempt = storage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
)
if (!rawAttempt) {
return null
}
try {
const parsed = JSON.parse(rawAttempt)
const attempt = normalizeAttempt(parsed)
if (!attempt || isExpired(attempt)) {
clearPendingSubscriptionCheckoutAttempt()
return null
}
return attempt
} catch {
clearPendingSubscriptionCheckoutAttempt()
return null
}
}
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
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 } : {})
}
}
const attempt: PendingSubscriptionCheckoutAttempt = {
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 } : {})
}
storage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify(attempt)
)
dispatchPendingCheckoutChangeEvent()
return attempt
}
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
): boolean => {
if (!status.is_active) {
return false
}
return (
getTierFromStatus(status) === attempt.tier &&
getCycleFromStatus(status) === attempt.cycle
)
}
export const consumePendingSubscriptionCheckoutSuccess = (
status: SubscriptionStatusSnapshot
): SubscriptionSuccessMetadata | null => {
const attempt = getPendingSubscriptionCheckoutAttempt()
if (!attempt || !didAttemptSucceed(attempt, status)) {
return null
}
clearPendingSubscriptionCheckoutAttempt()
const value = getCheckoutValue(attempt.tier, attempt.cycle)
return {
checkout_attempt_id: attempt.attempt_id,
tier: attempt.tier,
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
value,
currency: 'USD',
ecommerce: {
value,
currency: 'USD',
items: [
{
item_name: attempt.tier,
item_category: 'subscription',
item_variant: attempt.cycle,
price: value,
quantity: 1
}
]
}
}
}

View File

@@ -7,6 +7,7 @@ import { useTelemetry } from '@/platform/telemetry'
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`
@@ -113,6 +114,13 @@ export async function performSubscriptionCheckout(
...checkoutAttribution
})
}
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
if (openInNewTab) {
window.open(data.checkout_url, '_blank')
} else {

View File

@@ -18,6 +18,7 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryDispatcher,
@@ -80,8 +81,12 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
}
trackMonthlySubscriptionSucceeded(): void {
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.dispatch((provider) =>
provider.trackMonthlySubscriptionSucceeded?.(metadata)
)
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -98,6 +98,41 @@ describe('GtmTelemetryProvider', () => {
})
})
it('pushes subscription_success metadata with ecommerce reset', () => {
const provider = createInitializedProvider()
provider.trackMonthlySubscriptionSucceeded({
checkout_attempt_id: 'attempt-123',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new',
value: 336,
currency: 'USD',
ecommerce: {
currency: 'USD',
value: 336,
items: [
{
item_name: 'creator',
item_category: 'subscription',
item_variant: 'yearly',
price: 336,
quantity: 1
}
]
}
})
const dataLayer = window.dataLayer as Record<string, unknown>[]
expect(dataLayer[dataLayer.length - 2]).toMatchObject({ ecommerce: null })
expect(lastDataLayerEntry()).toMatchObject({
event: 'subscription_success',
checkout_attempt_id: 'attempt-123',
value: 336
})
})
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({ trigger_source: 'button' })

View File

@@ -16,6 +16,7 @@ import type {
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
@@ -167,8 +168,17 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.pushEvent('signup_opened')
}
trackMonthlySubscriptionSucceeded(): void {
this.pushEvent('subscription_success')
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
if (metadata?.ecommerce) {
window.dataLayer?.push({ ecommerce: null })
}
this.pushEvent(
'subscription_success',
metadata ? { ...metadata } : undefined
)
}
trackRunButton(options?: {

View File

@@ -31,6 +31,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -235,8 +236,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
}
/**

View File

@@ -26,6 +26,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -255,8 +256,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -344,6 +344,32 @@ export interface BeginCheckoutMetadata
previous_tier?: TierKey
}
interface EcommerceItemMetadata {
item_name: string
item_category: string
item_variant?: string
price: number
quantity: number
}
interface EcommerceMetadata {
currency: string
value: number
items: EcommerceItemMetadata[]
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: 'new' | 'change'
previous_tier?: TierKey
value: number
currency: string
ecommerce: EcommerceMetadata
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -360,7 +386,9 @@ export interface TelemetryProvider {
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackMonthlySubscriptionSucceeded?(): void
trackMonthlySubscriptionSucceeded?(
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void