diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index b622cb97a..ccd54fe5e 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -14,12 +14,13 @@ import { FirebaseAuthStoreError, useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher' type CloudSubscriptionCheckoutResponse = { checkout_url: string } -type CloudSubscriptionStatusResponse = { +export type CloudSubscriptionStatusResponse = { is_active: boolean subscription_id: string renewal_date: string | null @@ -28,6 +29,7 @@ type CloudSubscriptionStatusResponse = { function useSubscriptionInternal() { const subscriptionStatus = ref(null) + const telemetry = useTelemetry() const isSubscribedOrIsNotCloud = computed(() => { if (!isCloud || !window.__CONFIG__?.subscription_required) return true @@ -103,8 +105,21 @@ function useSubscriptionInternal() { void dialogService.showSubscriptionRequiredDialog() } + const shouldWatchCancellation = (): boolean => + Boolean(isCloud && window.__CONFIG__?.subscription_required) + + const { startCancellationWatcher, stopCancellationWatcher } = + useSubscriptionCancellationWatcher({ + fetchStatus, + isActiveSubscription: isSubscribedOrIsNotCloud, + subscriptionStatus, + telemetry, + shouldWatchCancellation + }) + const manageSubscription = async () => { await accessBillingPortal() + startCancellationWatcher() } const requireActiveSubscription = async (): Promise => { @@ -168,6 +183,7 @@ function useSubscriptionInternal() { await fetchSubscriptionStatus() } else { subscriptionStatus.value = null + stopCancellationWatcher() } }, { immediate: true } diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts new file mode 100644 index 000000000..d841b02fc --- /dev/null +++ b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts @@ -0,0 +1,129 @@ +import { onScopeDispose, ref } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core' + +import type { TelemetryProvider } from '@/platform/telemetry/types' + +import type { CloudSubscriptionStatusResponse } from './useSubscription' + +const MAX_CANCELLATION_ATTEMPTS = 4 +const CANCELLATION_BASE_DELAY_MS = 5000 +const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals + +type CancellationWatcherOptions = { + fetchStatus: () => Promise + isActiveSubscription: ComputedRef + subscriptionStatus: Ref + telemetry: Pick | null + shouldWatchCancellation: () => boolean +} + +export function useSubscriptionCancellationWatcher({ + fetchStatus, + isActiveSubscription, + subscriptionStatus, + telemetry, + shouldWatchCancellation +}: CancellationWatcherOptions) { + const watcherActive = ref(false) + const cancellationAttempts = ref(0) + const cancellationTracked = ref(false) + const cancellationCheckInFlight = ref(false) + const nextDelay = ref(CANCELLATION_BASE_DELAY_MS) + let detachFocusListener: (() => void) | null = null + + const { start: startTimer, stop: stopTimer } = useTimeoutFn( + () => { + void checkForCancellation() + }, + nextDelay, + { immediate: false } + ) + + const stopCancellationWatcher = () => { + watcherActive.value = false + stopTimer() + cancellationAttempts.value = 0 + cancellationCheckInFlight.value = false + if (detachFocusListener) { + detachFocusListener() + detachFocusListener = null + } + } + + const scheduleNextCancellationCheck = () => { + if (!watcherActive.value) return + + if (cancellationAttempts.value >= MAX_CANCELLATION_ATTEMPTS) { + stopCancellationWatcher() + return + } + + nextDelay.value = + CANCELLATION_BASE_DELAY_MS * + CANCELLATION_BACKOFF_MULTIPLIER ** cancellationAttempts.value + cancellationAttempts.value += 1 + startTimer() + } + + const checkForCancellation = async (triggeredFromFocus = false) => { + if (!watcherActive.value || cancellationCheckInFlight.value) return + + cancellationCheckInFlight.value = true + try { + await fetchStatus() + + if (!isActiveSubscription.value) { + if (!cancellationTracked.value) { + cancellationTracked.value = true + try { + telemetry?.trackMonthlySubscriptionCancelled() + } catch (telemetryError) { + console.error( + '[Subscription] Failed to track cancellation telemetry:', + telemetryError + ) + } + } + stopCancellationWatcher() + return + } + + if (!triggeredFromFocus) { + scheduleNextCancellationCheck() + } + } catch (error) { + console.error('[Subscription] Error checking cancellation status:', error) + scheduleNextCancellationCheck() + } finally { + cancellationCheckInFlight.value = false + } + } + + const startCancellationWatcher = () => { + if (!shouldWatchCancellation() || !subscriptionStatus.value?.is_active) { + return + } + + stopCancellationWatcher() + watcherActive.value = true + cancellationTracked.value = false + cancellationAttempts.value = 0 + if (!detachFocusListener && defaultWindow) { + detachFocusListener = useEventListener(defaultWindow, 'focus', () => { + if (!watcherActive.value) return + void checkForCancellation(true) + }) + } + scheduleNextCancellationCheck() + } + + onScopeDispose(() => { + stopCancellationWatcher() + }) + + return { + startCancellationWatcher, + stopCancellationWatcher + } +} diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index d11c88454..28ef060a6 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -175,6 +175,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED) } + /** + * Track when a user completes a subscription cancellation flow. + * Fired after we detect the backend reports `is_active: false` and the UI stops polling. + */ + trackMonthlySubscriptionCancelled(): void { + this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED) + } + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { const metadata: CreditTopupMetadata = { credit_amount: amount diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index d089c886a..5b5e0f9fc 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -265,6 +265,7 @@ export interface TelemetryProvider { // Subscription flow events trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void trackMonthlySubscriptionSucceeded(): void + trackMonthlySubscriptionCancelled(): void trackAddApiCreditButtonClicked(): void trackApiCreditTopupButtonPurchaseClicked(amount: number): void trackApiCreditTopupSucceeded(): void @@ -344,6 +345,7 @@ export const TelemetryEvents = { SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened', SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked', MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded', + MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled', ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked', API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED: 'app:api_credit_topup_button_purchase_clicked', diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts index e113e626e..1305e9f15 100644 --- a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts @@ -11,6 +11,10 @@ const mockShowSubscriptionRequiredDialog = vi.fn() const mockGetAuthHeader = vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ) +const mockTelemetry = { + trackSubscription: vi.fn(), + trackMonthlySubscriptionCancelled: vi.fn() +} // Mock dependencies vi.mock('@/composables/auth/useCurrentUser', () => ({ @@ -20,7 +24,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ })) vi.mock('@/platform/telemetry', () => ({ - useTelemetry: vi.fn(() => null) + useTelemetry: vi.fn(() => mockTelemetry) })) vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ @@ -72,6 +76,11 @@ describe('useSubscription', () => { beforeEach(() => { vi.clearAllMocks() mockIsLoggedIn.value = false + mockTelemetry.trackSubscription.mockReset() + mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() + window.__CONFIG__ = { + subscription_required: true + } as typeof window.__CONFIG__ vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => ({ @@ -321,5 +330,94 @@ describe('useSubscription', () => { expect(mockAccessBillingPortal).toHaveBeenCalled() }) + + it('tracks cancellation after manage subscription when status flips', async () => { + vi.useFakeTimers() + mockIsLoggedIn.value = true + + const activeResponse = { + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_active', + renewal_date: '2025-11-16' + }) + } + + const cancelledResponse = { + ok: true, + json: async () => ({ + is_active: false, + subscription_id: 'sub_cancelled', + renewal_date: '2025-11-16', + end_date: '2025-12-01' + }) + } + + vi.mocked(global.fetch) + .mockResolvedValueOnce(activeResponse as Response) + .mockResolvedValueOnce(activeResponse as Response) + .mockResolvedValueOnce(cancelledResponse as Response) + + try { + const { fetchStatus, manageSubscription } = useSubscription() + + await fetchStatus() + await manageSubscription() + + await vi.advanceTimersByTimeAsync(5000) + + expect( + mockTelemetry.trackMonthlySubscriptionCancelled + ).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('handles rapid focus events during cancellation polling', async () => { + vi.useFakeTimers() + mockIsLoggedIn.value = true + + const activeResponse = { + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_active', + renewal_date: '2025-11-16' + }) + } + + const cancelledResponse = { + ok: true, + json: async () => ({ + is_active: false, + subscription_id: 'sub_cancelled', + renewal_date: '2025-11-16', + end_date: '2025-12-01' + }) + } + + vi.mocked(global.fetch) + .mockResolvedValueOnce(activeResponse as Response) + .mockResolvedValueOnce(activeResponse as Response) + .mockResolvedValueOnce(cancelledResponse as Response) + + try { + const { fetchStatus, manageSubscription } = useSubscription() + + await fetchStatus() + await manageSubscription() + + window.dispatchEvent(new Event('focus')) + await vi.waitFor(() => { + expect( + mockTelemetry.trackMonthlySubscriptionCancelled + ).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscriptionCancellationWatcher.test.ts b/tests-ui/tests/platform/cloud/subscription/useSubscriptionCancellationWatcher.test.ts new file mode 100644 index 000000000..94db27549 --- /dev/null +++ b/tests-ui/tests/platform/cloud/subscription/useSubscriptionCancellationWatcher.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, effectScope, ref } from 'vue' +import type { EffectScope } from 'vue' + +import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription' +import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher' + +describe('useSubscriptionCancellationWatcher', () => { + const trackMonthlySubscriptionCancelled = vi.fn() + const telemetryMock: Pick< + import('@/platform/telemetry/types').TelemetryProvider, + 'trackMonthlySubscriptionCancelled' + > = { + trackMonthlySubscriptionCancelled + } + + const baseStatus: CloudSubscriptionStatusResponse = { + is_active: true, + subscription_id: 'sub_123', + renewal_date: '2025-11-16' + } + + const subscriptionStatus = ref( + baseStatus + ) + const isActive = ref(true) + const isActiveSubscription = computed(() => isActive.value) + + let shouldWatch = true + const shouldWatchCancellation = () => shouldWatch + + const activeScopes: EffectScope[] = [] + + const initWatcher = ( + options: Parameters[0] + ): ReturnType => { + const scope = effectScope() + let result: ReturnType | null = + null + scope.run(() => { + result = useSubscriptionCancellationWatcher(options) + }) + if (!result) { + throw new Error('Failed to initialize cancellation watcher') + } + activeScopes.push(scope) + return result + } + + beforeEach(() => { + vi.useFakeTimers() + trackMonthlySubscriptionCancelled.mockReset() + subscriptionStatus.value = { ...baseStatus } + isActive.value = true + shouldWatch = true + }) + + afterEach(() => { + activeScopes.forEach((scope) => scope.stop()) + activeScopes.length = 0 + vi.useRealTimers() + }) + + it('polls with exponential backoff and fires telemetry once cancellation detected', async () => { + const fetchStatus = vi.fn(async () => { + if (fetchStatus.mock.calls.length === 2) { + isActive.value = false + subscriptionStatus.value = { + is_active: false, + subscription_id: 'sub_cancelled', + renewal_date: '2025-11-16', + end_date: '2025-12-01' + } + } + }) + + const { startCancellationWatcher } = initWatcher({ + fetchStatus, + isActiveSubscription, + subscriptionStatus, + telemetry: telemetryMock, + shouldWatchCancellation + }) + + startCancellationWatcher() + + await vi.advanceTimersByTimeAsync(5000) + expect(fetchStatus).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(15000) + expect(fetchStatus).toHaveBeenCalledTimes(2) + expect( + telemetryMock.trackMonthlySubscriptionCancelled + ).toHaveBeenCalledTimes(1) + }) + + it('triggers a check immediately when window regains focus', async () => { + const fetchStatus = vi.fn(async () => { + isActive.value = false + subscriptionStatus.value = { + ...baseStatus, + is_active: false, + end_date: '2025-12-01' + } + }) + + const { startCancellationWatcher } = initWatcher({ + fetchStatus, + isActiveSubscription, + subscriptionStatus, + telemetry: telemetryMock, + shouldWatchCancellation + }) + + startCancellationWatcher() + + window.dispatchEvent(new Event('focus')) + await Promise.resolve() + + expect(fetchStatus).toHaveBeenCalledTimes(1) + expect( + telemetryMock.trackMonthlySubscriptionCancelled + ).toHaveBeenCalledTimes(1) + }) + + it('stops after max attempts when subscription stays active', async () => { + const fetchStatus = vi.fn(async () => {}) + + const { startCancellationWatcher } = initWatcher({ + fetchStatus, + isActiveSubscription, + subscriptionStatus, + telemetry: telemetryMock, + shouldWatchCancellation + }) + + startCancellationWatcher() + + const delays = [5000, 15000, 45000, 135000] + for (const delay of delays) { + await vi.advanceTimersByTimeAsync(delay) + } + + expect(fetchStatus).toHaveBeenCalledTimes(4) + expect(trackMonthlySubscriptionCancelled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(200000) + expect(fetchStatus).toHaveBeenCalledTimes(4) + }) + + it('does not start watcher when guard fails or subscription inactive', async () => { + const fetchStatus = vi.fn() + + const { startCancellationWatcher } = initWatcher({ + fetchStatus, + isActiveSubscription, + subscriptionStatus, + telemetry: telemetryMock, + shouldWatchCancellation + }) + + shouldWatch = false + startCancellationWatcher() + await vi.advanceTimersByTimeAsync(60000) + expect(fetchStatus).not.toHaveBeenCalled() + + shouldWatch = true + isActive.value = false + subscriptionStatus.value = { + ...baseStatus, + is_active: false + } + startCancellationWatcher() + await vi.advanceTimersByTimeAsync(60000) + expect(fetchStatus).not.toHaveBeenCalled() + }) +})