mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
fix: tighten subscription recovery triggers
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user