Skip to content

Commit ca3f895

Browse files
author
Connor Byrne
committed
fix(billing): read the top-up deep link gate from billing policy
The loader computed its own gate from isActiveSubscription and isFreeTier under a comment saying it was the negation of showTopUpCreditsDialog's paywall gate. That stopped being true once the dialog moved to billingPolicyCapabilities: the loader has no distribution term, so off cloud it suppressed add_api_credit_button_clicked for deep links that do open the real top-up dialog. Both now read topUpAccess, so they cannot disagree. Fixes #14862
1 parent b785b33 commit ca3f895

2 files changed

Lines changed: 45 additions & 12 deletions

File tree

src/platform/cloud/subscription/composables/useTopUpUrlLoader.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
4646
useWorkspaceUI: () => ({ permissions: mockPermissions })
4747
}))
4848

49+
const mockIsCloud = vi.hoisted(() => ({ value: true }))
50+
vi.mock('@/platform/distribution/types', () => ({
51+
get isCloud() {
52+
return mockIsCloud.value
53+
}
54+
}))
55+
4956
const mockBilling = vi.hoisted(() => ({
5057
fetchStatus: vi.fn().mockResolvedValue(undefined),
5158
subscription: { value: { isActive: true } as { isActive: boolean } | null },
52-
isActiveSubscription: { value: true },
53-
isFreeTier: { value: false }
59+
canAccessSubscriptionFeatures: { value: true },
60+
isTeamPlan: { value: false },
61+
tier: { value: 'STANDARD' as string | null }
5462
}))
5563

5664
vi.mock('@/composables/billing/useBillingContext', () => ({
@@ -73,8 +81,10 @@ describe('useTopUpUrlLoader', () => {
7381
// clearAllMocks resets calls, not implementations, so restore the defaults.
7482
mockBilling.fetchStatus.mockResolvedValue(undefined)
7583
mockBilling.subscription.value = { isActive: true }
76-
mockBilling.isActiveSubscription.value = true
77-
mockBilling.isFreeTier.value = false
84+
mockIsCloud.value = true
85+
mockBilling.canAccessSubscriptionFeatures.value = true
86+
mockBilling.isTeamPlan.value = false
87+
mockBilling.tier.value = 'STANDARD'
7888
mockShowTopUpCreditsDialog.mockResolvedValue(undefined)
7989
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
8090
})
@@ -154,11 +164,22 @@ describe('useTopUpUrlLoader', () => {
154164
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
155165
})
156166

157-
it('opens without deep_link telemetry for a lapsed or free-tier user', async () => {
167+
it('opens without deep_link telemetry for a lapsed user', async () => {
158168
mockRouteQuery.value = { topup: '1' }
159169
// showTopUpCreditsDialog routes this user to the paywall internally; the
160170
// deep_link source must only count real top-up dialog opens.
161-
mockBilling.isActiveSubscription.value = false
171+
mockBilling.canAccessSubscriptionFeatures.value = false
172+
173+
const { loadTopUpFromUrl } = useTopUpUrlLoader()
174+
await loadTopUpFromUrl()
175+
176+
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
177+
expect(mockTrackAddApiCreditButtonClicked).not.toHaveBeenCalled()
178+
})
179+
180+
it('opens without deep_link telemetry for a free-tier user', async () => {
181+
mockRouteQuery.value = { topup: '1' }
182+
mockBilling.tier.value = 'FREE'
162183

163184
const { loadTopUpFromUrl } = useTopUpUrlLoader()
164185
await loadTopUpFromUrl()
@@ -167,6 +188,19 @@ describe('useTopUpUrlLoader', () => {
167188
expect(mockTrackAddApiCreditButtonClicked).not.toHaveBeenCalled()
168189
})
169190

191+
it('emits deep_link telemetry for a free-tier user off Cloud, where top-up is allowed', async () => {
192+
mockRouteQuery.value = { topup: '1' }
193+
mockIsCloud.value = false
194+
mockBilling.tier.value = 'FREE'
195+
196+
const { loadTopUpFromUrl } = useTopUpUrlLoader()
197+
await loadTopUpFromUrl()
198+
199+
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
200+
source: 'deep_link'
201+
})
202+
})
203+
170204
it('is a silent no-op for a team member', async () => {
171205
mockRouteQuery.value = { topup: '1' }
172206
mockPermissions.value = { canTopUp: false }

src/platform/cloud/subscription/composables/useTopUpUrlLoader.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useRoute, useRouter } from 'vue-router'
22

33
import { useBillingContext } from '@/composables/billing/useBillingContext'
4+
import { useBillingPolicyCapabilities } from '@/platform/cloud/subscription/composables/useBillingPolicyCapabilities'
45
import {
56
clearPreservedQuery,
67
hydratePreservedQuery,
@@ -28,6 +29,7 @@ export function useTopUpUrlLoader() {
2829
const router = useRouter()
2930
const dialogService = useDialogService()
3031
const billingContext = useBillingContext()
32+
const { billingPolicyCapabilities } = useBillingPolicyCapabilities()
3133
const { permissions } = useWorkspaceUI()
3234
const telemetry = useTelemetry()
3335

@@ -73,13 +75,10 @@ export function useTopUpUrlLoader() {
7375
// all overlaps with the swallowed-failure case guarded here.
7476
if (!billingContext.subscription.value) return
7577

76-
// Emit deep_link only for opens of the real top-up dialog, matching the
77-
// other sources whose buttons render only for active paid users. This is
78-
// the negation of the paywall gate in showTopUpCreditsDialog
79-
// (dialogService.ts); keep the two in sync.
78+
// Emit deep_link only for opens of the real top-up dialog, not for opens
79+
// that divert to the paywall.
8080
const willOpenTopUpMode =
81-
billingContext.isActiveSubscription.value &&
82-
!billingContext.isFreeTier.value
81+
billingPolicyCapabilities.value.topUpAccess === 'allowed'
8382
if (willOpenTopUpMode) {
8483
telemetry?.trackAddApiCreditButtonClicked({ source: 'deep_link' })
8584
}

0 commit comments

Comments
 (0)