Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/platform/cloud/subscription/components/PricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
benceruleanlu marked this conversation as resolved.
await accessBillingPortal(checkoutTier)
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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)
}
}
Expand All @@ -263,7 +204,6 @@ const handleSubscribed = () => {
}

const handleChooseTeam = () => {
stopPolling()
if (onChooseTeam) {
onChooseTeam()
} else {
Expand All @@ -272,7 +212,6 @@ const handleChooseTeam = () => {
}

const handleClose = () => {
stopPolling()
onClose()
}

Expand All @@ -293,11 +232,6 @@ const handleViewEnterprise = () => {
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}

onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>

<style scoped>
Expand Down
178 changes: 176 additions & 2 deletions src/platform/cloud/subscription/composables/useSubscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,7 +13,8 @@ const {
mockGetCheckoutAttribution,
mockTelemetry,
mockUserId,
mockIsCloud
mockIsCloud,
mockLocalStorage
} = vi.hoisted(() => ({
mockIsLoggedIn: { value: false },
mockIsCloud: { value: true },
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -124,6 +156,7 @@ describe('useSubscription', () => {
scope?.stop()
scope = undefined
setDistribution('localhost')
mockLocalStorage.__reset()
})

beforeEach(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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()
})
Expand All @@ -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({
Expand Down
Loading
Loading