mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
fix: harden subscription success recovery
This commit is contained in:
@@ -117,7 +117,7 @@ export const useAuthActions = () => {
|
||||
|
||||
const accessBillingPortal = wrapWithErrorHandlingAsync<
|
||||
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
|
||||
void
|
||||
boolean
|
||||
>(async (targetTier, openInNewTab = true) => {
|
||||
const response = await authStore.accessBillingPortal(targetTier)
|
||||
if (!response.billing_portal_url) {
|
||||
@@ -128,10 +128,11 @@ export const useAuthActions = () => {
|
||||
)
|
||||
}
|
||||
if (openInNewTab) {
|
||||
window.open(response.billing_portal_url, '_blank')
|
||||
} else {
|
||||
globalThis.location.href = response.billing_portal_url
|
||||
return window.open(response.billing_portal_url, '_blank') !== null
|
||||
}
|
||||
|
||||
globalThis.location.href = response.billing_portal_url
|
||||
return true
|
||||
}, reportError)
|
||||
|
||||
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
@@ -25,6 +26,35 @@ const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
|
||||
const mockLocalStorage = vi.hoisted(() => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
@@ -168,6 +198,7 @@ describe('PricingTable', () => {
|
||||
mockIsYearlySubscription.value = false
|
||||
mockUserId.value = 'user-123'
|
||||
mockTrackBeginCheckout.mockReset()
|
||||
mockLocalStorage.__reset()
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
|
||||
@@ -217,6 +248,56 @@ describe('PricingTable', () => {
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
|
||||
})
|
||||
|
||||
it('records a pending upgrade only after the billing portal opens', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockAccessBillingPortal.mockResolvedValueOnce(true)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const creatorButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Creator'))
|
||||
|
||||
await userEvent.click(creatorButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
) ?? '{}'
|
||||
)
|
||||
).toMatchObject({
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard',
|
||||
previous_cycle: 'monthly'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not record a pending upgrade when the billing portal does not open', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockAccessBillingPortal.mockResolvedValueOnce(false)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const creatorButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Creator'))
|
||||
|
||||
await userEvent.click(creatorButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
@@ -480,6 +480,11 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
|
||||
await accessBillingPortal()
|
||||
} else {
|
||||
const didOpenPortal = await accessBillingPortal(checkoutTier)
|
||||
if (!didOpenPortal) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle.value,
|
||||
@@ -489,7 +494,6 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
: {}),
|
||||
previous_cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
|
||||
})
|
||||
await accessBillingPortal(checkoutTier)
|
||||
}
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
|
||||
@@ -2,7 +2,10 @@ 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'
|
||||
import {
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
|
||||
const {
|
||||
mockIsLoggedIn,
|
||||
@@ -14,10 +17,12 @@ const {
|
||||
mockTelemetry,
|
||||
mockUserId,
|
||||
mockIsCloud,
|
||||
mockAuthStoreInitialized,
|
||||
mockLocalStorage
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsLoggedIn: { value: false },
|
||||
mockIsCloud: { value: true },
|
||||
mockAuthStoreInitialized: { value: true },
|
||||
mockReportError: vi.fn(),
|
||||
mockAccessBillingPortal: vi.fn(),
|
||||
mockShowSubscriptionRequiredDialog: vi.fn(),
|
||||
@@ -141,6 +146,9 @@ vi.mock('@/services/dialogService', () => ({
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: mockGetAuthHeader,
|
||||
get isInitialized() {
|
||||
return mockAuthStoreInitialized.value
|
||||
},
|
||||
get userId() {
|
||||
return mockUserId.value
|
||||
}
|
||||
@@ -153,6 +161,7 @@ global.fetch = vi.fn()
|
||||
|
||||
describe('useSubscription', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
setDistribution('localhost')
|
||||
@@ -172,6 +181,7 @@ describe('useSubscription', () => {
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mockUserId.value = 'user-123'
|
||||
mockIsCloud.value = true
|
||||
mockAuthStoreInitialized.value = true
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -324,7 +334,7 @@ describe('useSubscription', () => {
|
||||
// Mock window.open
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
|
||||
const { subscribe } = useSubscriptionWithScope()
|
||||
|
||||
@@ -360,6 +370,30 @@ describe('useSubscription', () => {
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not persist a pending checkout attempt when the popup is blocked', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { subscribe } = useSubscriptionWithScope()
|
||||
|
||||
await subscribe()
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
expect(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw error when checkout URL is not returned', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -499,6 +533,154 @@ describe('useSubscription', () => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not clear pending attempts before auth initialization resolves', async () => {
|
||||
mockAuthStoreInitialized.value = false
|
||||
mockIsLoggedIn.value = false
|
||||
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-pre-auth',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('restarts the retry backoff when a new pending attempt is recorded', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-initial',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
vi.mocked(global.fetch).mockClear()
|
||||
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-replacement',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
window.dispatchEvent(new Event(PENDING_SUBSCRIPTION_CHECKOUT_EVENT))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
vi.mocked(global.fetch).mockClear()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('schedules retry recovery when bootstrap status fetch fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-bootstrap',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(global.fetch)
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response)
|
||||
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clears pending checkout attempts when initialized while logged out', async () => {
|
||||
localStorage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
attempt_id: 'attempt-logout',
|
||||
started_at_ms: Date.now(),
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
|
||||
mockIsLoggedIn.value = false
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireActiveSubscription', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import {
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
clearPendingSubscriptionCheckoutAttempt,
|
||||
consumePendingSubscriptionCheckoutSuccess,
|
||||
hasPendingSubscriptionCheckoutAttempt,
|
||||
recordPendingSubscriptionCheckoutAttempt
|
||||
@@ -212,6 +213,11 @@ function useSubscriptionInternal() {
|
||||
)
|
||||
}
|
||||
|
||||
const checkoutWindow = window.open(response.checkout_url, '_blank')
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
@@ -225,8 +231,6 @@ function useSubscriptionInternal() {
|
||||
? { previous_cycle: 'monthly' as const }
|
||||
: {})
|
||||
})
|
||||
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = (options?: {
|
||||
@@ -284,7 +288,7 @@ function useSubscriptionInternal() {
|
||||
}
|
||||
|
||||
const recoverPendingSubscriptionCheckout = async (
|
||||
source: 'pageshow' | 'visibilitychange' | 'retry'
|
||||
source: 'bootstrap' | 'pageshow' | 'visibilitychange' | 'retry'
|
||||
) => {
|
||||
if (
|
||||
!isCloud ||
|
||||
@@ -346,7 +350,7 @@ function useSubscriptionInternal() {
|
||||
return
|
||||
}
|
||||
|
||||
pendingCheckoutRecoveryAttempt = 0
|
||||
stopPendingCheckoutRecovery()
|
||||
void recoverPendingSubscriptionCheckout('retry')
|
||||
}
|
||||
|
||||
@@ -371,11 +375,19 @@ function useSubscriptionInternal() {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
() => [authStore.isInitialized, isLoggedIn.value] as const,
|
||||
async ([authInitialized, loggedIn]) => {
|
||||
if (!authInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (loggedIn && isCloud) {
|
||||
try {
|
||||
await fetchSubscriptionStatus()
|
||||
if (hasPendingSubscriptionCheckoutAttempt()) {
|
||||
await recoverPendingSubscriptionCheckout('bootstrap')
|
||||
} else {
|
||||
await fetchSubscriptionStatus()
|
||||
}
|
||||
} catch (error) {
|
||||
// Network errors are expected during navigation/component unmount
|
||||
// and when offline - log for debugging but don't surface to user
|
||||
@@ -385,6 +397,7 @@ function useSubscriptionInternal() {
|
||||
}
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
stopPendingCheckoutRecovery()
|
||||
stopCancellationWatcher()
|
||||
isInitialized.value = true
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
clearPendingSubscriptionCheckoutAttempt,
|
||||
hasPendingSubscriptionCheckoutAttempt,
|
||||
recordPendingSubscriptionCheckoutAttempt
|
||||
} from './subscriptionCheckoutTracker'
|
||||
|
||||
const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'localStorage'
|
||||
)
|
||||
|
||||
function restoreLocalStorage() {
|
||||
if (originalLocalStorageDescriptor) {
|
||||
Object.defineProperty(
|
||||
globalThis,
|
||||
'localStorage',
|
||||
originalLocalStorageDescriptor
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(globalThis, 'localStorage')
|
||||
}
|
||||
|
||||
describe('subscriptionCheckoutTracker', () => {
|
||||
afterEach(() => {
|
||||
restoreLocalStorage()
|
||||
})
|
||||
|
||||
it('fails open when reading localStorage throws', () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error('blocked storage')
|
||||
}
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
).not.toThrow()
|
||||
|
||||
expect(hasPendingSubscriptionCheckoutAttempt()).toBe(false)
|
||||
expect(() => clearPendingSubscriptionCheckoutAttempt()).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails open when storage methods throw', () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: vi.fn(() => {
|
||||
throw new Error('getItem blocked')
|
||||
}),
|
||||
setItem: vi.fn(() => {
|
||||
throw new Error('setItem blocked')
|
||||
}),
|
||||
removeItem: vi.fn(() => {
|
||||
throw new Error('removeItem blocked')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change'
|
||||
})
|
||||
).toMatchObject({
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change'
|
||||
})
|
||||
|
||||
expect(hasPendingSubscriptionCheckoutAttempt()).toBe(false)
|
||||
expect(() => clearPendingSubscriptionCheckoutAttempt()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,13 @@ const createAttemptId = (): string => {
|
||||
}
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
const storage = globalThis.localStorage
|
||||
let storage: Storage | null = null
|
||||
|
||||
try {
|
||||
storage = globalThis.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!storage ||
|
||||
@@ -166,13 +172,17 @@ const normalizeAttempt = (
|
||||
}
|
||||
}
|
||||
|
||||
const clearPendingSubscriptionCheckoutAttempt = (): void => {
|
||||
export const clearPendingSubscriptionCheckoutAttempt = (): void => {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
|
||||
storage.removeItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
try {
|
||||
storage.removeItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
dispatchPendingCheckoutChangeEvent()
|
||||
}
|
||||
|
||||
@@ -183,9 +193,13 @@ const getPendingSubscriptionCheckoutAttempt =
|
||||
return null
|
||||
}
|
||||
|
||||
const rawAttempt = storage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
)
|
||||
let rawAttempt: string | null
|
||||
|
||||
try {
|
||||
rawAttempt = storage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!rawAttempt) {
|
||||
return null
|
||||
@@ -236,10 +250,14 @@ export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
|
||||
}
|
||||
|
||||
storage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify(attempt)
|
||||
)
|
||||
try {
|
||||
storage.setItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
JSON.stringify(attempt)
|
||||
)
|
||||
} catch {
|
||||
return attempt
|
||||
}
|
||||
dispatchPendingCheckoutChangeEvent()
|
||||
|
||||
return attempt
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { performSubscriptionCheckout } from './subscriptionCheckoutUtil'
|
||||
|
||||
const {
|
||||
@@ -8,7 +9,8 @@ const {
|
||||
mockGetAuthHeader,
|
||||
mockUserId,
|
||||
mockIsCloud,
|
||||
mockGetCheckoutAttribution
|
||||
mockGetCheckoutAttribution,
|
||||
mockLocalStorage
|
||||
} = vi.hoisted(() => ({
|
||||
mockTelemetry: {
|
||||
trackBeginCheckout: vi.fn()
|
||||
@@ -29,9 +31,38 @@ const {
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
}))
|
||||
})),
|
||||
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()
|
||||
}
|
||||
}
|
||||
})()
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
@@ -81,16 +112,20 @@ describe('performSubscriptionCheckout', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockUserId.value = 'user-123'
|
||||
mockLocalStorage.__reset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setDistribution('localhost')
|
||||
mockLocalStorage.__reset()
|
||||
})
|
||||
|
||||
it('tracks begin_checkout with user id and tier metadata', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -136,6 +171,17 @@ describe('performSubscriptionCheckout', () => {
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
expect(
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
) ?? '{}'
|
||||
)
|
||||
).toMatchObject({
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
})
|
||||
|
||||
it('continues checkout when attribution collection fails', async () => {
|
||||
@@ -175,7 +221,9 @@ describe('performSubscriptionCheckout', () => {
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
const authHeader = createDeferred<{ Authorization: string }>()
|
||||
|
||||
mockUserId.value = 'user-early'
|
||||
@@ -203,4 +251,21 @@ describe('performSubscriptionCheckout', () => {
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,15 +115,23 @@ export async function performSubscriptionCheckout(
|
||||
})
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(data.checkout_url, '_blank')
|
||||
const checkoutWindow = window.open(data.checkout_url, '_blank')
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
} else {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
globalThis.location.href = data.checkout_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,55 @@ describe('GtmTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reset ecommerce when GTM is not initialized', () => {
|
||||
window.__CONFIG__ = {
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
|
||||
const provider = new GtmTelemetryProvider()
|
||||
|
||||
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 unknown[]
|
||||
|
||||
expect(
|
||||
dataLayer.some(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'ecommerce' in (entry as Record<string, unknown>)
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
dataLayer.some(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).event === 'subscription_success'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('pushes run_workflow with trigger_source', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackRunButton({ trigger_source: 'button' })
|
||||
|
||||
@@ -171,7 +171,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
if (metadata?.ecommerce) {
|
||||
if (this.initialized && metadata?.ecommerce) {
|
||||
window.dataLayer?.push({ ecommerce: null })
|
||||
}
|
||||
|
||||
|
||||
@@ -587,3 +587,4 @@ export type TelemetryEventProperties =
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
|
||||
Reference in New Issue
Block a user