fix: harden subscription success recovery

This commit is contained in:
Benjamin Lu
2026-04-15 20:54:36 -07:00
parent e5c8d26e56
commit 77ccbc18ae
12 changed files with 541 additions and 36 deletions

View File

@@ -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 () => {

View File

@@ -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'

View File

@@ -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(

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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()
})
})

View File

@@ -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
}
}

View File

@@ -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' })

View File

@@ -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 })
}

View File

@@ -587,3 +587,4 @@ export type TelemetryEventProperties =
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata