Skip to content

Commit 7494f71

Browse files
authored
fix(billing): make the Credits panel follow the active billing rail (#15151)
## Summary Settings → Credits still assumed the legacy `/customers` rail, so its activity table never loaded when the balance came from anywhere else, and Invoice History opened the wrong Stripe customer. ## Changes - **What**: - `UsageLogsTable` owns its first load (`immediate` watch on the billing route). It previously started at `loading = true` and waited for a parent `refresh()`, which `CreditsPanel` only fired from `authStore.lastBalanceUpdateTime` — written solely by the legacy `/customers/balance` success path (`authStore.ts:374`). Any path that does not write it leaves the spinner up forever. - `CreditsPanel` refreshes activity off the balance it renders instead of that legacy-only timestamp, so a top-up refreshes the table on both rails. First hydration is skipped, since the table now loads itself. - Invoice History routes through `useBillingContext().manageSubscription()`, matching `SubscriptionFooterLinks`. The legacy `/customers` portal bills a different Stripe customer than the workspace on screen. ## Review Focus Two independent ways to reach the stuck spinner on `main`: 1. **Local/Desktop, new account** — `authStore.fetchBalance()` returns early on a `404` from `/customers/balance` (`authStore.ts:355-358`), so `lastBalanceUpdateTime` is never written. A user with no customer record yet gets a Credits tab that spins forever. 2. **Cloud, workspace rail** — with `billing_control_enabled` off the standalone Credits panel stays in the sidebar (`useSettingUI.ts:325`) while `useWorkspaceBilling.fetchBalance()` supplies the balance from `/billing/balance` and never touches the timestamp. This also becomes the default failure once the workspace switcher is exposed on Local/Desktop (#15051). Other notes: - `AutoRefreshWrapper` is gone from the `UsageLogsTable` tests; the component no longer needs a parent to drive its first fetch. Reverting the `immediate` flag alone fails 19 of the 21 tests. - Legacy `manageSubscription()` also starts the cancellation watcher, which the raw `accessBillingPortal()` call did not. `SubscriptionFooterLinks` already accepts that for the same action, and `browser_tests/tests/localCreditsNoSubscribeUi.spec.ts` (which asserts Invoice History issues `POST /customers/billing`) still passes. ## Screenshots Captured from the same local dev-server setup and viewport with a fully mocked Personal workspace on the workspace billing rail. `billing_control_enabled` is off so the standalone Settings → Credits panel is visible. AS-IS uses base `085c6ec51`; TO-BE uses this PR's head `e9fcd6adc`. | AS-IS | TO-BE | | --- | --- | | Activity stays on the loading indicator because no initial request is issued. | Activity loads from `/api/billing/events` on mount. | | ![AS-IS — Credits activity stuck loading](https://ampcode.com/user-content/artifacts/50a4a56fc1f0a33cf6c00e5fb3bfd64b4365b2261217eace1e01ec243a61aa7d-file.png) | ![TO-BE — Credits activity loaded](https://ampcode.com/user-content/artifacts/cb6f3f0d3dda7ca1f329ce34517f851dc451afccd8748bd7ccdc0e0e1479a762-file.png) | Linear: [FE-1584](https://linear.app/comfyorg/issue/FE-1584/add-workspace-switcher-to-localdesktop-credits-only-no-settings) Prototype this supports: #15051
1 parent 32d0b6e commit 7494f71

4 files changed

Lines changed: 160 additions & 47 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 { defineComponent, h, nextTick } from 'vue'
5+
import { createI18n } from 'vue-i18n'
6+
7+
import type { BalanceInfo } from '@/composables/billing/types'
8+
9+
import CreditsPanel from './CreditsPanel.vue'
10+
11+
const billingMocks = vi.hoisted(() => ({
12+
balance: { value: null as BalanceInfo | null },
13+
manageSubscription: vi.fn()
14+
}))
15+
vi.mock('@/composables/billing/useBillingContext', async () => {
16+
const { ref } = await import('vue')
17+
const balance = ref<BalanceInfo | null>(null)
18+
Object.defineProperty(billingMocks, 'balance', { get: () => balance })
19+
return {
20+
useBillingContext: () => ({
21+
balance,
22+
manageSubscription: billingMocks.manageSubscription
23+
})
24+
}
25+
})
26+
27+
const refreshActivity = vi.hoisted(() => vi.fn())
28+
vi.mock('./UsageLogsTable.vue', async () => {
29+
const { defineComponent, h } = await import('vue')
30+
return {
31+
default: defineComponent({
32+
setup(_props, { expose }) {
33+
expose({ refresh: refreshActivity })
34+
return () => h('div', { 'data-testid': 'usage-logs-table' })
35+
}
36+
})
37+
}
38+
})
39+
40+
vi.mock('@/platform/cloud/subscription/components/CreditsTile.vue', () => ({
41+
default: defineComponent({ setup: () => () => h('div') })
42+
}))
43+
44+
vi.mock('@/platform/telemetry', () => ({
45+
useTelemetry: () => ({ trackHelpResourceClicked: vi.fn() })
46+
}))
47+
48+
vi.mock('@/stores/commandStore', () => ({
49+
useCommandStore: () => ({ execute: vi.fn() })
50+
}))
51+
52+
vi.mock('@/composables/useExternalLink', () => ({
53+
useExternalLink: () => ({
54+
buildDocsUrl: () => 'https://docs.comfy.org',
55+
docsPaths: { partnerNodesPricing: '/partner-nodes' }
56+
})
57+
}))
58+
59+
const i18n = createI18n({
60+
legacy: false,
61+
locale: 'en',
62+
messages: {
63+
en: {
64+
credits: {
65+
credits: 'Credits',
66+
activity: 'Activity',
67+
invoiceHistory: 'Invoice History',
68+
faqs: 'FAQs',
69+
messageSupport: 'Message Support'
70+
},
71+
subscription: { partnerNodesCredits: 'Partner Nodes Credits' }
72+
}
73+
}
74+
})
75+
76+
function makeBalance(amountMicros: number): BalanceInfo {
77+
return {
78+
amountMicros,
79+
currency: 'usd',
80+
effectiveBalanceMicros: amountMicros,
81+
prepaidBalanceMicros: 0,
82+
cloudCreditBalanceMicros: 0
83+
}
84+
}
85+
86+
describe('CreditsPanel', () => {
87+
beforeEach(() => {
88+
billingMocks.balance.value = null
89+
})
90+
91+
function renderComponent() {
92+
return render(CreditsPanel, {
93+
global: { plugins: [i18n], stubs: { Divider: true } }
94+
})
95+
}
96+
97+
it('opens the billing portal for the active billing rail', async () => {
98+
const user = userEvent.setup()
99+
renderComponent()
100+
101+
await user.click(screen.getByRole('button', { name: /Invoice History/ }))
102+
103+
expect(billingMocks.manageSubscription).toHaveBeenCalledOnce()
104+
})
105+
106+
it('refreshes activity on a balance change but not on first hydration', async () => {
107+
renderComponent()
108+
109+
billingMocks.balance.value = makeBalance(5000)
110+
await nextTick()
111+
expect(refreshActivity).not.toHaveBeenCalled()
112+
113+
billingMocks.balance.value = makeBalance(9000)
114+
await nextTick()
115+
expect(refreshActivity).toHaveBeenCalledOnce()
116+
})
117+
})

src/components/dialog/content/setting/CreditsPanel.vue

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,36 +65,30 @@ import { ref, watch } from 'vue'
6565
6666
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
6767
import Button from '@/components/ui/button/Button.vue'
68-
import { useAuthActions } from '@/composables/auth/useAuthActions'
68+
import { useBillingContext } from '@/composables/billing/useBillingContext'
6969
import { useExternalLink } from '@/composables/useExternalLink'
7070
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
7171
import { useTelemetry } from '@/platform/telemetry'
72-
import { useAuthStore } from '@/stores/authStore'
7372
import { useCommandStore } from '@/stores/commandStore'
7473
7574
const { embedded = false } = defineProps<{
7675
embedded?: boolean
7776
}>()
7877
7978
const { buildDocsUrl, docsPaths } = useExternalLink()
80-
const authStore = useAuthStore()
81-
const authActions = useAuthActions()
79+
const { balance, manageSubscription } = useBillingContext()
8280
const commandStore = useCommandStore()
8381
const telemetry = useTelemetry()
8482
8583
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
8684
87-
watch(
88-
() => authStore.lastBalanceUpdateTime,
89-
(newTime, oldTime) => {
90-
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
91-
void usageLogsTableRef.value.refresh()
92-
}
93-
}
94-
)
85+
watch(balance, (next, previous) => {
86+
if (!next || !previous) return
87+
void usageLogsTableRef.value?.refresh()
88+
})
9589
9690
const handleCreditsHistoryClick = async () => {
97-
await authActions.accessBillingPortal()
91+
await manageSubscription()
9892
}
9993
10094
const handleMessageSupport = async () => {

src/components/dialog/content/setting/UsageLogsTable.test.ts

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
22
import PrimeVue from 'primevue/config'
33
import Tooltip from 'primevue/tooltip'
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { defineComponent, nextTick, onMounted, ref } from 'vue'
5+
import { nextTick } from 'vue'
66
import { createI18n } from 'vue-i18n'
77

88
import { render, screen, waitFor } from '@testing-library/vue'
@@ -91,22 +91,6 @@ const globalConfig = {
9191
directives: { tooltip: Tooltip }
9292
}
9393

94-
/**
95-
* The component starts with loading=true and only loads data when refresh()
96-
* is called via template ref. This wrapper auto-calls refresh on mount.
97-
*/
98-
const AutoRefreshWrapper = defineComponent({
99-
components: { UsageLogsTable },
100-
setup() {
101-
const tableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
102-
onMounted(async () => {
103-
await tableRef.value?.refresh()
104-
})
105-
return { tableRef }
106-
},
107-
template: '<UsageLogsTable ref="tableRef" />'
108-
})
109-
11094
async function flushMicrotasks() {
11195
await new Promise((resolve) => setTimeout(resolve, 0))
11296
await nextTick()
@@ -208,20 +192,34 @@ describe('UsageLogsTable', () => {
208192
return render(UsageLogsTable, { global: globalConfig })
209193
}
210194

211-
function renderWithAutoRefresh() {
212-
return render(AutoRefreshWrapper, { global: globalConfig })
213-
}
214-
215195
async function renderLoaded() {
216-
const result = renderWithAutoRefresh()
196+
const result = renderComponent()
217197
await waitFor(() => {
218198
expect(screen.getByRole('table')).toBeInTheDocument()
219199
})
220200
return result
221201
}
222202

223203
describe('loading states', () => {
224-
it('shows loading spinner before refresh is called', () => {
204+
it('loads activity on mount without an external refresh', async () => {
205+
await renderLoaded()
206+
207+
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledTimes(1)
208+
})
209+
210+
it('loads activity on mount on the workspace billing rail', async () => {
211+
mockBillingRouting.shouldUseWorkspaceBilling = true
212+
213+
await renderLoaded()
214+
215+
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledTimes(1)
216+
})
217+
218+
it('shows a loading spinner while the initial load is in flight', () => {
219+
mockCustomerEventsService.getMyEvents.mockReturnValue(
220+
new Promise(() => {})
221+
)
222+
225223
renderComponent()
226224

227225
expect(screen.getByRole('progressbar')).toBeInTheDocument()
@@ -232,7 +230,7 @@ describe('UsageLogsTable', () => {
232230
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
233231
mockCustomerEventsService.error.value = 'Failed to load events'
234232

235-
renderWithAutoRefresh()
233+
renderComponent()
236234

237235
await waitFor(() => {
238236
expect(screen.getByText('Failed to load events')).toBeInTheDocument()
@@ -244,7 +242,7 @@ describe('UsageLogsTable', () => {
244242
new Error('Network error')
245243
)
246244

247-
renderWithAutoRefresh()
245+
renderComponent()
248246

249247
await waitFor(() => {
250248
expect(
@@ -260,7 +258,7 @@ describe('UsageLogsTable', () => {
260258
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
261259
mockCustomerEventsService.error.value = null
262260

263-
renderWithAutoRefresh()
261+
renderComponent()
264262

265263
await waitFor(() => {
266264
expect(
@@ -312,7 +310,7 @@ describe('UsageLogsTable', () => {
312310
])
313311
)
314312

315-
renderWithAutoRefresh()
313+
renderComponent()
316314

317315
await waitFor(() => {
318316
expect(screen.getByText('Account initialized')).toBeInTheDocument()
@@ -400,7 +398,7 @@ describe('UsageLogsTable', () => {
400398
])
401399
)
402400

403-
renderWithAutoRefresh()
401+
renderComponent()
404402

405403
mockBillingRouting.shouldUseWorkspaceBilling = true
406404
await waitFor(() => {
@@ -442,7 +440,7 @@ describe('UsageLogsTable', () => {
442440
])
443441
)
444442

445-
renderWithAutoRefresh()
443+
renderComponent()
446444

447445
mockBillingRouting.shouldUseWorkspaceBilling = true
448446
await waitFor(() => {

src/components/dialog/content/setting/UsageLogsTable.vue

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,15 @@ const refresh = async () => {
212212
await loadEvents()
213213
}
214214
215-
watch(shouldUseWorkspaceBilling, () => {
216-
refresh().catch((error) => {
217-
console.error('Error loading events:', error)
218-
})
219-
})
215+
watch(
216+
shouldUseWorkspaceBilling,
217+
() => {
218+
refresh().catch((error) => {
219+
console.error('Error loading events:', error)
220+
})
221+
},
222+
{ immediate: true }
223+
)
220224
221225
defineExpose({
222226
refresh

0 commit comments

Comments
 (0)