From 9acea20de034fbe6e033376db73aa52901a606fe Mon Sep 17 00:00:00 2001 From: Dante Date: Fri, 19 Jun 2026 15:39:10 +0900 Subject: [PATCH] fix(billing): refresh workspace billing status after completed top-up (FE-932) (#12787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ...TopUpCreditsDialogContentWorkspace.test.ts | 165 ++++++++++++++++++ .../TopUpCreditsDialogContentWorkspace.vue | 4 +- 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.test.ts diff --git a/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.test.ts b/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.test.ts new file mode 100644 index 00000000000..38d422c0ae6 --- /dev/null +++ b/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.test.ts @@ -0,0 +1,165 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { CreateTopupResponse } from '@/platform/workspace/api/workspaceApi' + +import TopUpCreditsDialogContentWorkspace from './TopUpCreditsDialogContentWorkspace.vue' + +const mockFetchBalance = vi.fn() +const mockFetchStatus = vi.fn() +const mockTopup = vi.fn<(amountCents: number) => Promise>() +const mockStartOperation = vi.fn() +const mockShowSettings = vi.fn() +const mockToastAdd = vi.fn() + +vi.mock('@/composables/billing/useBillingContext', () => ({ + useBillingContext: () => ({ + fetchBalance: mockFetchBalance, + fetchStatus: mockFetchStatus, + topup: (amountCents: number) => mockTopup(amountCents) + }) +})) + +vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({ + useBillingOperationStore: () => ({ + hasPendingOperations: false, + startOperation: mockStartOperation + }) +})) + +vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({ + useSettingsDialog: () => ({ show: mockShowSettings }) +})) + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: () => ({ closeDialog: vi.fn() }) +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ + trackApiCreditTopupButtonPurchaseClicked: vi.fn() + }) +})) + +vi.mock('@/platform/telemetry/topupTracker', () => ({ + clearTopupTracking: vi.fn() +})) + +vi.mock('@/composables/useExternalLink', () => ({ + useExternalLink: () => ({ + buildDocsUrl: () => 'https://docs.comfy.org', + docsPaths: { partnerNodesPricing: '' } + }) +})) + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: mockToastAdd }) +})) + +vi.mock('@/base/credits/comfyCredits', () => ({ + creditsToUsd: (credits: number) => credits, + usdToCredits: (usd: number) => usd +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { close: 'Close' }, + subscription: { addCredits: 'Add credits' }, + credits: { + topUp: { + addMoreCredits: 'Add more credits', + addMoreCreditsToRun: 'Add more credits to run', + selectAmount: 'Select amount', + youPay: 'You pay', + youGet: 'You get', + purchaseSuccess: 'Credits added successfully!', + purchaseError: 'Purchase Failed', + purchaseErrorDetail: 'Failed to purchase credits: {error}', + unknownError: 'An unknown error occurred', + minRequired: 'Minimum required', + maxAllowed: 'Maximum allowed', + needMore: 'Need more?', + contactUs: 'Contact us', + viewPricing: 'View pricing', + insufficientWorkflowMessage: 'Insufficient credits' + } + } + } + } +}) + +function topupResponse( + status: CreateTopupResponse['status'] +): CreateTopupResponse { + return { + billing_op_id: 'op-1', + topup_id: 'topup-1', + status, + amount_cents: 5000 + } +} + +function renderDialog() { + return render(TopUpCreditsDialogContentWorkspace, { + global: { + plugins: [i18n], + stubs: { + FormattedNumberStepper: { + name: 'FormattedNumberStepper', + props: ['modelValue'], + template: '
' + } + } + } + }) +} + +async function clickAddCredits() { + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Add credits' })) +} + +describe('TopUpCreditsDialogContentWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchBalance.mockResolvedValue(undefined) + mockFetchStatus.mockResolvedValue(undefined) + }) + + it('refreshes both balance and status after a completed top-up', async () => { + mockTopup.mockResolvedValue(topupResponse('completed')) + + renderDialog() + await clickAddCredits() + + expect(mockFetchBalance).toHaveBeenCalledOnce() + expect(mockFetchStatus).toHaveBeenCalledOnce() + expect(mockShowSettings).toHaveBeenCalledWith('workspace') + }) + + it('does not refresh balance or status for a pending top-up', async () => { + mockTopup.mockResolvedValue(topupResponse('pending')) + + renderDialog() + await clickAddCredits() + + expect(mockStartOperation).toHaveBeenCalledWith('op-1', 'topup') + expect(mockFetchBalance).not.toHaveBeenCalled() + expect(mockFetchStatus).not.toHaveBeenCalled() + }) + + it('does not refresh balance or status for a failed top-up', async () => { + mockTopup.mockResolvedValue(topupResponse('failed')) + + renderDialog() + await clickAddCredits() + + expect(mockFetchBalance).not.toHaveBeenCalled() + expect(mockFetchStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue b/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue index 55f716fa9a2..38ead959026 100644 --- a/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue +++ b/src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue @@ -176,7 +176,7 @@ const settingsDialog = useSettingsDialog() const telemetry = useTelemetry() const toast = useToast() const { buildDocsUrl, docsPaths } = useExternalLink() -const { fetchBalance, topup } = useBillingContext() +const { fetchBalance, fetchStatus, topup } = useBillingContext() const billingOperationStore = useBillingOperationStore() const isPolling = computed(() => billingOperationStore.hasPendingOperations) @@ -265,7 +265,7 @@ async function handleBuy() { summary: t('credits.topUp.purchaseSuccess'), life: 5000 }) - await fetchBalance() + await Promise.all([fetchBalance(), fetchStatus()]) handleClose(false) settingsDialog.show('workspace') } else if (response.status === 'pending') {