Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/composables/auth/useAuthActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 () => {
Expand Down
147 changes: 146 additions & 1 deletion src/platform/cloud/subscription/components/PricingTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@ 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))
}

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
Expand All @@ -25,6 +35,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: () => ({
Expand Down Expand Up @@ -148,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']
},
Expand All @@ -167,7 +219,10 @@ 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({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
Expand Down Expand Up @@ -217,6 +272,96 @@ 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('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'
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'
Expand Down
42 changes: 29 additions & 13 deletions src/platform/cloud/subscription/components/PricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
Expand Down Expand Up @@ -449,37 +450,52 @@ 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
})

if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
await accessBillingPortal(checkoutTier)
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}

recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
benceruleanlu marked this conversation as resolved.
}
} else {
await performSubscriptionCheckout(
Expand Down
Loading
Loading