Skip to content

Commit c296842

Browse files
authored
fix(billing): refresh workspace billing status after completed top-up (FE-932) (#12787)
## Summary A completed workspace top-up refreshed only the balance, leaving billing status — and `subscription.hasFunds` (derived from `statusData.has_funds`) — stale until the next status fetch. The completed handler now refreshes both. ## Changes - **What**: `TopUpCreditsDialogContentWorkspace.vue` completed branch — `await fetchBalance()` → `await Promise.all([fetchBalance(), fetchStatus()])` (both already exposed on `useBillingContext()`). - **Breaking**: none. ## Review Focus - Pre-existing bug (predates the B2 facade; `main`'s top-up already called `fetchBalance` only). Test validity proven by reverting to balance-only → the completed case goes red on the `fetchStatus` assertion. - Tests: completed → both refresh; pending / failed → neither (3 cases). typecheck / oxlint / eslint / stylelint / oxfmt / knip clean. Fixes FE-932
1 parent 5acd76c commit c296842

2 files changed

Lines changed: 167 additions & 2 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { render, screen } from '@testing-library/vue'
2+
import userEvent from '@testing-library/user-event'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { createI18n } from 'vue-i18n'
5+
6+
import type { CreateTopupResponse } from '@/platform/workspace/api/workspaceApi'
7+
8+
import TopUpCreditsDialogContentWorkspace from './TopUpCreditsDialogContentWorkspace.vue'
9+
10+
const mockFetchBalance = vi.fn()
11+
const mockFetchStatus = vi.fn()
12+
const mockTopup = vi.fn<(amountCents: number) => Promise<CreateTopupResponse>>()
13+
const mockStartOperation = vi.fn()
14+
const mockShowSettings = vi.fn()
15+
const mockToastAdd = vi.fn()
16+
17+
vi.mock('@/composables/billing/useBillingContext', () => ({
18+
useBillingContext: () => ({
19+
fetchBalance: mockFetchBalance,
20+
fetchStatus: mockFetchStatus,
21+
topup: (amountCents: number) => mockTopup(amountCents)
22+
})
23+
}))
24+
25+
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
26+
useBillingOperationStore: () => ({
27+
hasPendingOperations: false,
28+
startOperation: mockStartOperation
29+
})
30+
}))
31+
32+
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
33+
useSettingsDialog: () => ({ show: mockShowSettings })
34+
}))
35+
36+
vi.mock('@/stores/dialogStore', () => ({
37+
useDialogStore: () => ({ closeDialog: vi.fn() })
38+
}))
39+
40+
vi.mock('@/platform/telemetry', () => ({
41+
useTelemetry: () => ({
42+
trackApiCreditTopupButtonPurchaseClicked: vi.fn()
43+
})
44+
}))
45+
46+
vi.mock('@/platform/telemetry/topupTracker', () => ({
47+
clearTopupTracking: vi.fn()
48+
}))
49+
50+
vi.mock('@/composables/useExternalLink', () => ({
51+
useExternalLink: () => ({
52+
buildDocsUrl: () => 'https://docs.comfy.org',
53+
docsPaths: { partnerNodesPricing: '' }
54+
})
55+
}))
56+
57+
vi.mock('primevue/usetoast', () => ({
58+
useToast: () => ({ add: mockToastAdd })
59+
}))
60+
61+
vi.mock('@/base/credits/comfyCredits', () => ({
62+
creditsToUsd: (credits: number) => credits,
63+
usdToCredits: (usd: number) => usd
64+
}))
65+
66+
const i18n = createI18n({
67+
legacy: false,
68+
locale: 'en',
69+
messages: {
70+
en: {
71+
g: { close: 'Close' },
72+
subscription: { addCredits: 'Add credits' },
73+
credits: {
74+
topUp: {
75+
addMoreCredits: 'Add more credits',
76+
addMoreCreditsToRun: 'Add more credits to run',
77+
selectAmount: 'Select amount',
78+
youPay: 'You pay',
79+
youGet: 'You get',
80+
purchaseSuccess: 'Credits added successfully!',
81+
purchaseError: 'Purchase Failed',
82+
purchaseErrorDetail: 'Failed to purchase credits: {error}',
83+
unknownError: 'An unknown error occurred',
84+
minRequired: 'Minimum required',
85+
maxAllowed: 'Maximum allowed',
86+
needMore: 'Need more?',
87+
contactUs: 'Contact us',
88+
viewPricing: 'View pricing',
89+
insufficientWorkflowMessage: 'Insufficient credits'
90+
}
91+
}
92+
}
93+
}
94+
})
95+
96+
function topupResponse(
97+
status: CreateTopupResponse['status']
98+
): CreateTopupResponse {
99+
return {
100+
billing_op_id: 'op-1',
101+
topup_id: 'topup-1',
102+
status,
103+
amount_cents: 5000
104+
}
105+
}
106+
107+
function renderDialog() {
108+
return render(TopUpCreditsDialogContentWorkspace, {
109+
global: {
110+
plugins: [i18n],
111+
stubs: {
112+
FormattedNumberStepper: {
113+
name: 'FormattedNumberStepper',
114+
props: ['modelValue'],
115+
template: '<div />'
116+
}
117+
}
118+
}
119+
})
120+
}
121+
122+
async function clickAddCredits() {
123+
const user = userEvent.setup()
124+
await user.click(screen.getByRole('button', { name: 'Add credits' }))
125+
}
126+
127+
describe('TopUpCreditsDialogContentWorkspace', () => {
128+
beforeEach(() => {
129+
vi.clearAllMocks()
130+
mockFetchBalance.mockResolvedValue(undefined)
131+
mockFetchStatus.mockResolvedValue(undefined)
132+
})
133+
134+
it('refreshes both balance and status after a completed top-up', async () => {
135+
mockTopup.mockResolvedValue(topupResponse('completed'))
136+
137+
renderDialog()
138+
await clickAddCredits()
139+
140+
expect(mockFetchBalance).toHaveBeenCalledOnce()
141+
expect(mockFetchStatus).toHaveBeenCalledOnce()
142+
expect(mockShowSettings).toHaveBeenCalledWith('workspace')
143+
})
144+
145+
it('does not refresh balance or status for a pending top-up', async () => {
146+
mockTopup.mockResolvedValue(topupResponse('pending'))
147+
148+
renderDialog()
149+
await clickAddCredits()
150+
151+
expect(mockStartOperation).toHaveBeenCalledWith('op-1', 'topup')
152+
expect(mockFetchBalance).not.toHaveBeenCalled()
153+
expect(mockFetchStatus).not.toHaveBeenCalled()
154+
})
155+
156+
it('does not refresh balance or status for a failed top-up', async () => {
157+
mockTopup.mockResolvedValue(topupResponse('failed'))
158+
159+
renderDialog()
160+
await clickAddCredits()
161+
162+
expect(mockFetchBalance).not.toHaveBeenCalled()
163+
expect(mockFetchStatus).not.toHaveBeenCalled()
164+
})
165+
})

src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ const settingsDialog = useSettingsDialog()
176176
const telemetry = useTelemetry()
177177
const toast = useToast()
178178
const { buildDocsUrl, docsPaths } = useExternalLink()
179-
const { fetchBalance, topup } = useBillingContext()
179+
const { fetchBalance, fetchStatus, topup } = useBillingContext()
180180
181181
const billingOperationStore = useBillingOperationStore()
182182
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -265,7 +265,7 @@ async function handleBuy() {
265265
summary: t('credits.topUp.purchaseSuccess'),
266266
life: 5000
267267
})
268-
await fetchBalance()
268+
await Promise.all([fetchBalance(), fetchStatus()])
269269
handleClose(false)
270270
settingsDialog.show('workspace')
271271
} else if (response.status === 'pending') {

0 commit comments

Comments
 (0)