mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
feat: add frontend subscription success recovery
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user