Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
166 changes: 165 additions & 1 deletion src/platform/cloud/subscription/components/CreditsTile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const state = vi.hoisted(() => ({
showPricingTable: vi.fn(),
showTopUpCreditsDialog: vi.fn(),
trackAddApiCreditButtonClicked: vi.fn(),
checkForCompletedTopup: vi.fn(),
getBillingEvents: vi.fn().mockResolvedValue({ events: [] }),
getMyEvents: vi.fn().mockResolvedValue({ events: [] }),
toastErrorHandler: vi.fn()
}))

Expand Down Expand Up @@ -70,6 +73,12 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
})
}))

vi.mock('@/composables/billing/useBillingRouting', () => ({
useBillingRouting: () => ({
shouldUseWorkspaceBilling: computed(() => state.type === 'workspace')
})
}))

vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({ canTopUp: state.canTopUp }))
Expand All @@ -91,7 +100,20 @@ vi.mock('@/services/dialogService', () => ({

vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked,
checkForCompletedTopup: state.checkForCompletedTopup
})
}))

vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingEvents: state.getBillingEvents
}
}))

vi.mock('@/services/customerEventsService', () => ({
useCustomerEventsService: () => ({
getMyEvents: state.getMyEvents
})
}))

Expand Down Expand Up @@ -173,6 +195,14 @@ function activeProSubscription() {
}
}

function createDeferred() {
let resolve: () => void = () => {}
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}

describe('CreditsTile', () => {
beforeEach(() => {
state.balance = null
Expand All @@ -188,6 +218,7 @@ describe('CreditsTile', () => {
mockIsCloud.value = true
vi.clearAllMocks()
vi.unstubAllEnvs()
localStorage.clear()
})

it('renders the total balance (cents converted to credits) with the remaining suffix', () => {
Expand Down Expand Up @@ -443,6 +474,139 @@ describe('CreditsTile', () => {
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
})

it('keeps refreshing on focus until a pending top-up is confirmed', async () => {
activeProSubscription()
localStorage.setItem('pending_topup_timestamp', Date.now().toString())
renderTile()

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(2))

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(3))
expect(state.fetchStatus).toHaveBeenCalledTimes(3)
expect(localStorage.getItem('pending_topup_timestamp')).not.toBeNull()
})

it('runs a trailing refresh when focus returns during an active refresh', async () => {
activeProSubscription()
renderTile()
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 0))
vi.clearAllMocks()

const balanceRefresh = createDeferred()
const statusRefresh = createDeferred()
state.fetchBalance
.mockImplementationOnce(() => balanceRefresh.promise)
.mockResolvedValue(undefined)
state.fetchStatus
.mockImplementationOnce(() => statusRefresh.promise)
.mockResolvedValue(undefined)
localStorage.setItem('pending_topup_timestamp', Date.now().toString())

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
window.dispatchEvent(new Event('focus'))
expect(state.fetchBalance).toHaveBeenCalledOnce()

balanceRefresh.resolve()
statusRefresh.resolve()

await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(2))
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
expect(state.getBillingEvents).toHaveBeenCalledTimes(2)
})

it('waits for a failed refresh to settle before its trailing refresh', async () => {
activeProSubscription()
renderTile()
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 0))
vi.clearAllMocks()

const statusRefresh = createDeferred()
state.fetchBalance
.mockRejectedValueOnce(new Error('balance unavailable'))
.mockResolvedValue(undefined)
state.fetchStatus
.mockImplementationOnce(() => statusRefresh.promise)
.mockResolvedValue(undefined)
localStorage.setItem('pending_topup_timestamp', Date.now().toString())

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
window.dispatchEvent(new Event('focus'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(state.fetchBalance).toHaveBeenCalledOnce()

statusRefresh.resolve()

await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(2))
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
expect(state.getBillingEvents).toHaveBeenCalledOnce()
})

it('clears a confirmed workspace top-up before the next focus', async () => {
activeProSubscription()
localStorage.setItem('pending_topup_timestamp', Date.now().toString())
const events = [{ event_type: 'topup_completed' }]
state.getBillingEvents.mockResolvedValueOnce({ events })
state.checkForCompletedTopup.mockImplementationOnce(() => {
localStorage.removeItem('pending_topup_timestamp')
return true
})

renderTile()
await waitFor(() =>
expect(localStorage.getItem('pending_topup_timestamp')).toBeNull()
)
expect(state.checkForCompletedTopup).toHaveBeenCalledWith(events)
vi.clearAllMocks()

window.dispatchEvent(new Event('focus'))

await waitFor(() => expect(state.fetchBalance).not.toHaveBeenCalled())
expect(state.getBillingEvents).not.toHaveBeenCalled()
})

it('reconciles pending legacy top-ups against customer events', async () => {
activeProSubscription()
state.type = 'legacy'
localStorage.setItem('pending_topup_timestamp', Date.now().toString())

renderTile()

await waitFor(() => expect(state.getMyEvents).toHaveBeenCalledOnce())
expect(state.getBillingEvents).not.toHaveBeenCalled()
})

it('retries completion reconciliation after a request failure', async () => {
activeProSubscription()
localStorage.setItem('pending_topup_timestamp', Date.now().toString())
const failure = new Error('events unavailable')
state.getBillingEvents
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce({ events: [{ event_type: 'topup_completed' }] })
state.checkForCompletedTopup.mockImplementationOnce(() => {
localStorage.removeItem('pending_topup_timestamp')
return true
})

renderTile()
await waitFor(() =>
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
)
expect(localStorage.getItem('pending_topup_timestamp')).not.toBeNull()

window.dispatchEvent(new Event('focus'))

await waitFor(() =>
expect(localStorage.getItem('pending_topup_timestamp')).toBeNull()
)
expect(state.getBillingEvents).toHaveBeenCalledTimes(2)
})

it('surfaces a failure toast when a refresh rejects', async () => {
activeProSubscription()
const failure = new Error('network down')
Expand Down
55 changes: 50 additions & 5 deletions src/platform/cloud/subscription/components/CreditsTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
Expand All @@ -236,8 +237,10 @@ import {
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { pendingTopupNeedsRefresh } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useCustomerEventsService } from '@/services/customerEventsService'
import { useDialogService } from '@/services/dialogService'

const { zeroState = false, inactivePlan } = defineProps<{
Expand Down Expand Up @@ -267,8 +270,10 @@ const {
isLoadingBalance
} = useSubscriptionCredits()
const { permissions } = useWorkspaceUI()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { showPricingTable } = useSubscriptionDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const customerEventsService = useCustomerEventsService()
const dialogService = useDialogService()
const telemetry = useTelemetry()

Expand Down Expand Up @@ -408,9 +413,49 @@ const emptyStateNotice = computed(() => {
return null
})

const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])
})
async function refreshCredits() {
const results = await Promise.allSettled([fetchBalance(), fetchStatus()])
for (const result of results) {
if (result.status === 'rejected') throw result.reason
}

if (!pendingTopupNeedsRefresh()) return

const response = shouldUseWorkspaceBilling.value
? await workspaceApi.getBillingEvents({ page: 1, limit: 10 })
: await customerEventsService.getMyEvents({ page: 1, limit: 10 })
telemetry?.checkForCompletedTopup(response?.events)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

let refreshRequested = false
let activeRefresh: Promise<void> | null = null

async function refreshLatestCredits() {
refreshRequested = true
if (activeRefresh) return activeRefresh

activeRefresh = (async () => {
let lastError: unknown
while (refreshRequested) {
refreshRequested = false
try {
await refreshCredits()
lastError = undefined
} catch (error) {
lastError = error
}
}
if (lastError) throw lastError
})()

try {
await activeRefresh
} finally {
activeRefresh = null
}
}

const handleRefresh = wrapWithErrorHandlingAsync(refreshLatestCredits)

function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
Expand All @@ -422,7 +467,7 @@ function handleUpgradeToAddCredits() {
}

async function handleWindowFocus() {
if (consumePendingTopup()) {
if (pendingTopupNeedsRefresh()) {
await handleRefresh()
}
}
Expand Down
29 changes: 20 additions & 9 deletions src/platform/telemetry/topupTracker.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import {
consumePendingTopup,
pendingTopupNeedsRefresh,
startTopupTracking,
checkForCompletedTopup,
clearTopupTracking
Expand Down Expand Up @@ -229,34 +229,45 @@ describe('topupTracker', () => {
})
})

describe('consumePendingTopup', () => {
describe('pendingTopupNeedsRefresh', () => {
it('returns false and clears nothing when no marker exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)

expect(consumePendingTopup()).toBe(false)
expect(pendingTopupNeedsRefresh()).toBe(false)
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
})

it('clears and returns true for a fresh marker', () => {
it('keeps a fresh marker available across focus events', () => {
mockLocalStorage.getItem.mockReturnValue(
(Date.now() - 5 * 60 * 1000).toString()
)

expect(consumePendingTopup()).toBe(true)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
expect(pendingTopupNeedsRefresh()).toBe(true)
expect(pendingTopupNeedsRefresh()).toBe(true)
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
})

it('clears and returns false for a marker older than 24 hours', () => {
mockLocalStorage.getItem.mockReturnValue(
(Date.now() - 25 * 60 * 60 * 1000).toString()
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(consumePendingTopup()).toBe(false)
expect(pendingTopupNeedsRefresh()).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})

it.for(['invalid', Number.MAX_SAFE_INTEGER.toString()])(
'clears and returns false for an invalid marker: %s',
(timestamp) => {
mockLocalStorage.getItem.mockReturnValue(timestamp)

expect(pendingTopupNeedsRefresh()).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
}
)
})
})
Loading
Loading