fix: tighten subscription recovery triggers

This commit is contained in:
Benjamin Lu
2026-04-15 21:14:53 -07:00
parent 77ccbc18ae
commit 63f56965bb
4 changed files with 166 additions and 22 deletions

View File

@@ -13,6 +13,15 @@ async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
}
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
@@ -178,7 +187,20 @@ function renderComponent() {
},
stubs: {
SelectButton: {
template: '<div><slot /></div>',
template: `
<div>
<button
v-for="option in options"
:key="option.value"
type="button"
@click="$emit('update:modelValue', option.value)"
>
<slot name="option" :option="option">
{{ option.label }}
</slot>
</button>
</div>
`,
props: ['modelValue', 'options'],
emits: ['update:modelValue']
},
@@ -197,6 +219,8 @@ describe('PricingTable', () => {
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockTrackBeginCheckout.mockReset()
mockLocalStorage.__reset()
vi.mocked(global.fetch).mockResolvedValue({
@@ -278,6 +302,46 @@ describe('PricingTable', () => {
})
})
it('records the plan snapshot that was actually opened', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const portalOpen = createDeferredPromise<boolean>()
mockAccessBillingPortal.mockReturnValueOnce(portalOpen.promise)
renderComponent()
await flushPromises()
const creatorButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Creator'))
await userEvent.click(creatorButton!)
await flushPromises()
const monthlyToggle = screen.getByRole('button', { name: 'Monthly' })
await userEvent.click(monthlyToggle)
await flushPromises()
portalOpen.resolve(true)
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
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'

View File

@@ -450,29 +450,31 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
try {
if (hasPaidSubscription.value) {
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(currentTierKey.value
? { previous_tier: currentTierKey.value }
: {})
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
}
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
targetPlan.billingCycle
)
const downgrade =
currentPlanDescriptor.value &&
previousPlan &&
isPlanDowngrade({
current: currentPlanDescriptor.value,
current: previousPlan,
target: targetPlan
})
@@ -486,13 +488,13 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
}
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...(currentTierKey.value
? { previous_tier: currentTierKey.value }
: {}),
previous_cycle: isYearlySubscription.value ? 'yearly' : 'monthly'
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
}
} else {

View File

@@ -179,6 +179,8 @@ describe('useSubscription', () => {
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockUserId.value = 'user-123'
mockIsCloud.value = true
mockAuthStoreInitialized.value = true
@@ -534,6 +536,43 @@ describe('useSubscription', () => {
})
})
it('rechecks pending checkout attempts when the document becomes visible', 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-visible',
started_at_ms: Date.now(),
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
)
document.dispatchEvent(new Event('visibilitychange'))
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})
it('does not clear pending attempts before auth initialization resolves', async () => {
mockAuthStoreInitialized.value = false
mockIsLoggedIn.value = false
@@ -789,6 +828,40 @@ describe('useSubscription', () => {
expect(mockAccessBillingPortal).toHaveBeenCalled()
})
it('does not start cancellation watching when the billing portal does not open', async () => {
vi.useFakeTimers()
mockIsLoggedIn.value = true
mockAccessBillingPortal.mockResolvedValueOnce(false)
const activeResponse = {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_active',
renewal_date: '2025-11-16'
})
}
vi.mocked(global.fetch).mockResolvedValue(activeResponse as Response)
try {
const { fetchStatus, manageSubscription } = useSubscriptionWithScope()
await fetchStatus()
vi.mocked(global.fetch).mockClear()
await manageSubscription()
await vi.advanceTimersByTimeAsync(5000)
expect(global.fetch).not.toHaveBeenCalled()
expect(
mockTelemetry.trackMonthlySubscriptionCancelled
).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('tracks cancellation after manage subscription when status flips', async () => {
vi.useFakeTimers()
mockIsLoggedIn.value = true

View File

@@ -1,6 +1,7 @@
import { computed, ref, watch } from 'vue'
import {
createSharedComposable,
defaultDocument,
defaultWindow,
useEventListener
} from '@vueuse/core'
@@ -263,7 +264,11 @@ function useSubscriptionInternal() {
})
const manageSubscription = async () => {
await accessBillingPortal()
const didOpenPortal = await accessBillingPortal()
if (!didOpenPortal) {
return
}
startCancellationWatcher()
}
@@ -368,8 +373,8 @@ function useSubscriptionInternal() {
void recoverPendingSubscriptionCheckout('pageshow')
})
useEventListener(defaultWindow, 'visibilitychange', () => {
if (document.visibilityState === 'visible') {
useEventListener(defaultDocument, 'visibilitychange', () => {
if (defaultDocument?.visibilityState === 'visible') {
void recoverPendingSubscriptionCheckout('visibilitychange')
}
})