Compare commits

...

12 Commits

Author SHA1 Message Date
dante01yoon
01b243fd87 test(billing): e2e regression for facade-driven billing surfaces
Boots the cloud app against fully mocked endpoints (creditsTile.spec.ts
pattern) and asserts the FE-933 repointed surfaces render from the
billing facade: avatar popover tier badge + balance, and the free-tier
dialog renewal-date line (T5) via the subscription-required boot flow.

Covers FE-933.
2026-06-10 21:04:49 +09:00
dante01yoon
261406de3f fix(billing): repoint PricingTable subscription state to the facade
Current-plan highlight and upgrade-vs-subscribe branching read the
legacy useSubscription store. Take isActiveSubscription/isFreeTier/tier
from useBillingContext and derive isYearlySubscription from the facade
subscription duration, matching PricingTableWorkspace. The billing
portal flow (accessBillingPortal deep-links + proration) stays as is —
facade manageSubscription is not behavior-identical.

Part of B3 (FE-933).
2026-06-10 18:34:24 +09:00
dante01yoon
b37884c465 fix(billing): repoint CurrentUserPopoverLegacy to the billing facade
The personal-workspace avatar popover read tier/balance state from the
legacy useSubscription store and authStore directly. Source it all from
useBillingContext (tier, subscription, balance, isLoading, fetchStatus,
fetchBalance) and derive the tier badge via useWorkspaceTierLabel
instead of duplicating the tier-name mapping.

Part of B3 (FE-933).
2026-06-10 18:34:16 +09:00
dante01yoon
fb6e84783f fix(billing): repoint FreeTierDialogContent renewal date to the facade
The next-refresh line read formattedRenewalDate from the legacy
useSubscription store, which is empty for team workspaces, so the date
silently disappeared for team users (T5). Read the facade's raw ISO
renewalDate and format at the display site with the same en-US format.

Part of B3 (FE-933).
2026-06-10 18:33:55 +09:00
dante01yoon
9e48c13d98 fix(billing): repoint balance refresh to the facade fetchBalance
useSubscriptionActions.handleRefresh and the SettingDialog credits-nav
refresh called the legacy authActions.fetchBalance (/customers only),
so team-workspace balances were never refreshed. Route both through
useBillingContext().fetchBalance, which is workspace-aware.

Part of B3 (FE-933).
2026-06-10 18:33:40 +09:00
dante01yoon
5924dd92b6 fix(billing): repoint SubscribeButton + PostHog tier to the billing facade
The subscribe-clicked telemetry `current_tier` (SubscribeButton) and the
PostHog `subscription_tier` person property read the legacy useSubscription
store, which is empty/stale for team workspaces — so telemetry and analytics
record the wrong tier for team users today. Source the tier from
useBillingContext().tier (added in B2) so it is workspace-aware.

B3 of the billing convergence plan (FE-933). Stacked on FE-904 (B2).
2026-06-10 18:13:57 +09:00
dante01yoon
4b3b6a841c test(billing): cover facade resubscribe/topup per review
- useWorkspaceBilling: resubscribe refreshes status+balance and surfaces
  errors; topup stays a pass-through (no double-fetch) and surfaces errors
- useBillingContext: legacy topup converts cents to whole dollars
- types: trim convenience-accessor JSDoc, note whole-dollar-cents contract
2026-06-10 13:08:20 +09:00
dante01yoon
ff628e49e3 fix(billing): expose raw ISO dates from the legacy billing adapter
The workspace adapter feeds subscription.renewalDate/endDate straight from
the API (ISO 8601), but the legacy adapter passed pre-formatted display
strings, so facade consumers like CreditsTile got different shapes per
adapter. Source both from subscriptionStatus and leave formatting to the
display site.
2026-06-10 13:03:58 +09:00
dante01yoon
71d1ece521 test(billing): mock resubscribe via useBillingContext facade
The billing facade refactor moved resubscribe() onto useBillingContext,
but useSubscriptionCheckout's test still stubbed it on workspaceApi, which
the composable no longer calls. resubscribe was undefined at runtime, so
handleResubscribe threw "resubscribe is not a function" -- failing the
"emits close on success" and "shows error toast on failure" cases.

Wire mockResubscribe into the useBillingContext mock where the composable
reads it. The workspaceApi mock stays to shield the test from the real
@/scripts/api -> app.ts import chain.
2026-06-05 15:44:50 +09:00
Dante
5efb45b407 Merge branch 'main' into jaewon/fe-904-b2-complete-the-billing-facade-resubscribe-topup-status 2026-06-04 15:06:41 +09:00
dante01yoon
99b5107275 fix(billing): make workspace topup a pass-through to avoid double balance fetch
The completed-topup balance refresh is already owned by the caller
(TopUpCreditsDialogContentWorkspace); refreshing inside the facade too caused
a redundant second fetch. Keep topup a thin pass-through that returns the
CreateTopupResponse so the caller orchestrates completed/pending.
2026-06-04 14:14:04 +09:00
dante01yoon
d46846410a refactor(billing): complete billing facade with resubscribe/topup + status fields
Add resubscribe(), topup(amountCents) and billingStatus/subscriptionStatus/
tier/renewalDate to the shared BillingContext so components stop bypassing
useBillingContext with raw workspaceApi calls (B2 of the billing-divergence
convergence plan, FE-904).

- types.ts: extend BillingActions + BillingState; export BillingStatus,
  BillingSubscriptionStatus, CreateTopupResponse from workspaceApi
- useWorkspaceBilling: real wiring (workspaceApi.resubscribe/createTopup,
  surface statusData fields)
- useLegacyBilling: legacy equivalents (resubscribe = fresh checkout;
  topup converts cents -> dollars via purchaseCredits; billing_status = null)
- useBillingContext: proxy the new members
- migrate orphaned callers (SubscriptionPanelContentWorkspace,
  TopUpCreditsDialogContentWorkspace, useSubscriptionCheckout) onto the facade

Facade standardizes topup on cents; the legacy adapter converts.
2026-06-03 20:58:22 +09:00
24 changed files with 743 additions and 222 deletions

View File

@@ -0,0 +1,161 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`, which in
* a personal workspace routes through the legacy `/customers/*` endpoints
* (mocked here). Drives a raw `page` (not the `comfyPage` fixture) so the
* cloud app boots against fully mocked endpoints — same pattern as
* creditsTile.spec.ts. `team_workspaces_enabled: false` keeps the topbar on
* the legacy popover variant that FE-933 repointed.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace: keeps the billing facade on the legacy
// `/customers/*` path when team workspaces are enabled.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
})
)
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Subscription gating is config-driven: with subscription_required on,
// the cloud subscription extension calls requireActiveSubscription() at
// boot, which opens the free-tier dialog for an inactive FREE user.
// (refreshRemoteConfig overwrites window.__CONFIG__ from /api/features,
// so the flag must come from the features mock, not an init script.)
// The free-tier dialog branch additionally requires an active personal
// workspace, so this boots with team workspaces enabled (production
// shape) — the facade still routes personal through `/customers/*`.
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -1,37 +1,18 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -50,7 +30,6 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -127,7 +96,6 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
default: defineComponent({
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
}
}
})
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
currency: 'usd'
}
mockAuthStoreState.isFetchingBalance = false
mockIsLoading.value = false
})
function renderComponent() {
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
currency: 'usd'
}
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
currency: 'usd'
}
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
currency: 'usd'
}
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
currency: 'usd'
}
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
renderComponent()
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,12 +32,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
const currentTier = tier.value
return (
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
)
})
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void authActions.fetchBalance()
void fetchBalance()
})
</script>

View File

@@ -2,6 +2,9 @@ import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
@@ -16,7 +19,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601; format at the display site. */
renewalDate: string | null
/** ISO 8601; format at the display site. */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -44,6 +49,20 @@ export interface BillingActions {
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
/**
* Reactivates a cancelled-but-still-active subscription. Legacy has no
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
* The workspace adapter refreshes status and balance internally on success.
*/
resubscribe: () => Promise<void>
/**
* Purchases additional credits. Standardized on **whole-dollar cents**
* (multiples of 100); the legacy adapter divides by 100 for the
* dollar-based /customers/credit endpoint.
* Pass-through by design: the caller owns the completed/pending follow-up
* (balance refresh or billing-op polling), so this does not refresh.
*/
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
@@ -65,16 +84,15 @@ export interface BillingState {
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
/** Reflects the active workspace's tier, not the user's personal tier. */
isFreeTier: ComputedRef<boolean>
/** Coarse funding state (`billing_status`); legacy reports null. */
billingStatus: ComputedRef<BillingStatus | null>
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -5,13 +5,17 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
@@ -50,8 +54,9 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
subscriptionStatus: {
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
},
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
@@ -70,6 +75,12 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mockPurchaseCredits
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
@@ -129,7 +140,7 @@ describe('useBillingContext', () => {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
renewalDate: '2025-01-01T00:00:00Z',
endDate: null,
isCancelled: false,
hasFunds: true
@@ -173,6 +184,13 @@ describe('useBillingContext', () => {
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
const { topup } = useBillingContext()
await topup(500)
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)

View File

@@ -122,6 +122,15 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -218,6 +227,14 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.cancelSubscription()
}
async function resubscribe() {
return activeContext.value.resubscribe()
}
async function topup(amountCents: number) {
return activeContext.value.topup(amountCents)
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
@@ -241,6 +258,10 @@ function useBillingContextInternal(): BillingContext {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
getMaxSeats,
initialize,
@@ -250,6 +271,8 @@ function useBillingContextInternal(): BillingContext {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,7 +1,10 @@
import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -24,8 +27,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
subscriptionStatus: legacySubscriptionStatus,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
@@ -34,6 +36,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
} = useSubscription()
const authStore = useAuthStore()
const authActions = useAuthActions()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -52,8 +55,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
endDate: legacySubscriptionStatus.value?.end_date ?? null,
isCancelled: isCancelled.value,
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
@@ -75,6 +78,18 @@ export function useLegacyBilling(): BillingState & BillingActions {
}
})
// Legacy has no coarse billing_status concept (workspace-only).
const billingStatus = computed<BillingStatus | null>(() => null)
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
if (isCancelled.value) return 'canceled'
if (legacyIsActiveSubscription.value) return 'active'
return null
})
const tier = computed(() => subscriptionTier.value)
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
@@ -152,6 +167,16 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
async function resubscribe(): Promise<void> {
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
await legacySubscribe()
}
async function topup(amountCents: number): Promise<void> {
// Facade standardizes on cents; legacy /customers/credit takes dollars.
await authActions.purchaseCredits(amountCents / 100)
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
@@ -179,6 +204,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -188,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
renewalDate: mockRenewalDate
}))
}))
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(FreeTierDialogContent, {
global: {
plugins: [i18n]
}
})
}
describe('FreeTierDialogContent', () => {
it('renders the next refresh line formatted from the facade renewalDate', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent()
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('hides the next refresh line when renewalDate is null', () => {
mockRenewalDate.value = null
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -102,9 +102,9 @@
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
@@ -116,7 +116,17 @@ defineEmits<{
upgrade: []
}>()
const { formattedRenewalDate } = useSubscription()
const { renewalDate } = useBillingContext()
const formattedRenewalDate = computed(() => {
if (!renewalDate.value) return ''
return new Date(renewalDate.value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
async function flushPromises() {
@@ -23,10 +24,8 @@ function createDeferredPromise<T>() {
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
@@ -65,13 +64,25 @@ Object.defineProperty(globalThis, 'localStorage', {
writable: true
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null)
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
tier: computed(() => mockSubscriptionTier.value),
subscription: computed(() =>
mockSubscriptionTier.value
? {
isActive: mockIsActiveSubscription.value,
tier: mockSubscriptionTier.value,
duration: mockSubscriptionDuration.value,
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true
}
: null
)
})
}))
@@ -217,7 +228,7 @@ describe('PricingTable', () => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockSubscriptionDuration.value = 'MONTHLY'
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
@@ -362,6 +373,7 @@ describe('PricingTable', () => {
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'ANNUAL'
renderComponent()
await flushPromises()
@@ -370,12 +382,29 @@ describe('PricingTable', () => {
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeDefined()
expect(currentPlanButton).toBeDisabled()
await userEvent.click(currentPlanButton!)
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'MONTHLY'
renderComponent()
await flushPromises()
const currentPlanButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeUndefined()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false

View File

@@ -263,8 +263,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
TIER_PRICING,
TIER_TO_KEY
@@ -361,9 +361,13 @@ const tiers: PricingTierConfig[] = [
const {
isActiveSubscription,
isFreeTier,
subscriptionTier,
isYearlySubscription
} = useSubscription()
tier: subscriptionTier,
subscription
} = useBillingContext()
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const telemetry = useTelemetry()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()

View File

@@ -16,7 +16,6 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@comfyorg/tailwind-utils'
@@ -38,8 +37,8 @@ const emit = defineEmits<{
subscribed: []
}>()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const { isActiveSubscription, showSubscriptionDialog, tier } =
useBillingContext()
const isAwaitingStripeSubscription = ref(false)
watch(
@@ -55,7 +54,7 @@ watch(
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
current_tier: tier.value?.toLowerCase()
})
}
isAwaitingStripeSubscription.value = true

View File

@@ -2,26 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
fetchBalance: mockAuthFetchBalance
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockBillingFetchBalance,
fetchStatus: mockFetchStatus
})
}))
@@ -84,19 +79,19 @@ describe('useSubscriptionActions', () => {
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
it('should refresh balance and status through the billing facade', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
})
})

View File

@@ -1,7 +1,6 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -12,10 +11,9 @@ import { useCommandStore } from '@/stores/commandStore'
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus } = useBillingContext()
const { fetchBalance, fetchStatus } = useBillingContext()
const isLoadingSupport = ref(false)
@@ -47,7 +45,7 @@ export function useSubscriptionActions() {
const handleRefresh = async () => {
try {
await Promise.all([authActions.fetchBalance(), fetchStatus()])
await Promise.all([fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}

View File

@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -129,7 +129,7 @@ const {
getSearchResults
} = useSettingSearch()
const authActions = useAuthActions()
const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
@@ -235,7 +235,7 @@ watch(activeCategoryKey, (newKey, oldKey) => {
activeCategoryKey.value = oldKey
}
if (newKey === 'credits') {
void authActions.fetchBalance()
void fetchBalance()
}
if (newKey) {
void nextTick(() => {

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { watch } from 'vue'
import { TelemetryEvents } from '../../types'
@@ -10,6 +11,7 @@ const hoisted = vi.hoisted(() => {
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
const mockTier = { value: null as string | null }
return {
mockCapture,
@@ -19,6 +21,7 @@ const hoisted = vi.hoisted(() => {
mockReset,
mockOnUserResolved,
mockOnUserLogout,
mockTier,
mockPosthog: {
default: {
init: mockInit,
@@ -56,14 +59,16 @@ vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
vi.mock('posthog-js', () => hoisted.mockPosthog)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
subscriptionTier: { value: null }
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
tier: hoisted.mockTier
})
}))
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
const watchMock = vi.mocked(watch)
function createProvider(
config: Partial<typeof window.__CONFIG__> = {}
): PostHogTelemetryProvider {
@@ -145,6 +150,30 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
})
it('sets the subscription_tier person property from the facade tier', async () => {
createProvider()
await vi.dynamicImportSettled()
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
onResolved({ id: 'user-123' })
const tierWatch = watchMock.mock.calls.find(
([source]) => source === hoisted.mockTier
)
expect(tierWatch).toBeDefined()
const handler = tierWatch?.[1] as unknown as (
value: string | null
) => void
handler('PRO')
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
subscription_tier: 'PRO'
})
handler(null)
expect(hoisted.mockPeopleSet).toHaveBeenCalledOnce()
})
})
describe('event tracking', () => {

View File

@@ -5,7 +5,7 @@ import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -268,12 +268,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
private setSubscriptionProperties(): void {
const { subscriptionTier } = useSubscription()
const { tier } = useBillingContext()
watch(
subscriptionTier,
(tier) => {
if (tier && this.posthog) {
this.posthog.people.set({ subscription_tier: tier })
tier,
(value) => {
if (value && this.posthog) {
this.posthog.people.set({ subscription_tier: value })
}
},
{ immediate: true }

View File

@@ -196,9 +196,13 @@ export interface PreviewSubscribeResponse {
new_plan: PreviewPlanInfo
}
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
export type BillingSubscriptionStatus =
| 'active'
| 'scheduled'
| 'ended'
| 'canceled'
type BillingStatus =
export type BillingStatus =
| 'awaiting_payment_method'
| 'pending_payment'
| 'paid'
@@ -233,7 +237,7 @@ interface CreateTopupRequest {
type TopupStatus = 'pending' | 'completed' | 'failed'
interface CreateTopupResponse {
export interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus

View File

@@ -371,7 +371,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import {
DEFAULT_TIER_KEY,
@@ -404,7 +403,8 @@ const {
manageSubscription,
fetchStatus,
fetchBalance,
getMaxSeats
getMaxSeats,
resubscribe
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
@@ -415,13 +415,13 @@ const isResubscribing = ref(false)
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
// facade resubscribe() refreshes status + balance internally
await resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to resubscribe'

View File

@@ -161,7 +161,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -177,7 +176,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()
const { fetchBalance, topup } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -257,7 +256,9 @@ async function handleBuy() {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
const response = await topup(amountCents)
// Workspace topup always returns a response; void only on the legacy path.
if (!response) return
if (response.status === 'completed') {
toast.add({

View File

@@ -91,10 +91,14 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
previewSubscribe: mockPreviewSubscribe,
plans: computed(() => mockPlans.value),
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
fetchBalance: mockFetchBalance,
resubscribe: mockResubscribe
})
}))
// Mocked to shield the test from the real workspaceApi → @/scripts/api → app.ts
// import chain. The composable reads resubscribe from useBillingContext (above),
// not from workspaceApi directly.
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { resubscribe: mockResubscribe }
}))

View File

@@ -11,7 +11,6 @@ import type {
Plan,
PreviewSubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
type CheckoutStep = 'pricing' | 'preview'
@@ -35,8 +34,14 @@ export function useSubscriptionCheckout(emit: {
}) {
const { t } = useI18n()
const toast = useToast()
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
useBillingContext()
const {
subscribe,
previewSubscribe,
plans,
fetchStatus,
fetchBalance,
resubscribe
} = useBillingContext()
const telemetry = useTelemetry()
const billingOperationStore = useBillingOperationStore()
@@ -170,13 +175,13 @@ export function useSubscriptionCheckout(emit: {
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
// facade resubscribe() refreshes status + balance internally
await resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} catch (error) {
const message =

View File

@@ -11,7 +11,9 @@ const mockWorkspaceApi = vi.hoisted(() => ({
subscribe: vi.fn(),
previewSubscribe: vi.fn(),
getPaymentPortalUrl: vi.fn(),
cancelSubscription: vi.fn()
cancelSubscription: vi.fn(),
resubscribe: vi.fn(),
createTopup: vi.fn()
}))
const mockBillingPlans = vi.hoisted(() => ({
@@ -622,6 +624,92 @@ describe('useWorkspaceBilling', () => {
})
})
describe('resubscribe', () => {
it('refreshes status and balance after a successful resubscribe', async () => {
mockWorkspaceApi.resubscribe.mockResolvedValue(undefined)
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
const billing = setupBilling()
await billing.resubscribe()
expect(mockWorkspaceApi.resubscribe).toHaveBeenCalledTimes(1)
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1)
expect(billing.subscription.value?.tier).toBe('CREATOR')
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
expect(billing.error.value).toBeNull()
expect(billing.isLoading.value).toBe(false)
})
it('sets error, rethrows, and skips the refresh when the API call fails', async () => {
mockWorkspaceApi.resubscribe.mockRejectedValue(
new Error('reactivation failed')
)
const billing = setupBilling()
await expect(billing.resubscribe()).rejects.toThrow('reactivation failed')
expect(billing.error.value).toBe('reactivation failed')
expect(billing.isLoading.value).toBe(false)
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
})
it('falls back to a generic error message for non-Error rejections', async () => {
mockWorkspaceApi.resubscribe.mockRejectedValue('boom')
const billing = setupBilling()
await expect(billing.resubscribe()).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to resubscribe')
})
})
describe('topup', () => {
const topupResponse = {
billing_op_id: 'op-topup',
topup_id: 'topup-1',
status: 'completed' as const,
amount_cents: 500
}
it('returns the createTopup response without refreshing status or balance', async () => {
mockWorkspaceApi.createTopup.mockResolvedValue(topupResponse)
const billing = setupBilling()
const result = await billing.topup(500)
expect(mockWorkspaceApi.createTopup).toHaveBeenCalledWith(500)
expect(result).toBe(topupResponse)
// Pass-through: the caller owns the completed/pending follow-up, so the
// facade must not double-fetch here.
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
expect(billing.error.value).toBeNull()
expect(billing.isLoading.value).toBe(false)
})
it('sets error and rethrows when the API call fails', async () => {
mockWorkspaceApi.createTopup.mockRejectedValue(new Error('card declined'))
const billing = setupBilling()
await expect(billing.topup(500)).rejects.toThrow('card declined')
expect(billing.error.value).toBe('card declined')
expect(billing.isLoading.value).toBe(false)
})
it('falls back to a generic error message for non-Error rejections', async () => {
mockWorkspaceApi.createTopup.mockRejectedValue('boom')
const billing = setupBilling()
await expect(billing.topup(500)).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to top up credits')
})
})
describe('plans / currentPlanSlug / fetchPlans', () => {
it('prefers the plan slug from status over the billingPlans fallback', async () => {
mockBillingPlans.currentPlanSlug.value = 'plans-fallback'

View File

@@ -5,6 +5,7 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
import type {
BillingBalanceResponse,
BillingStatusResponse,
CreateTopupResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -70,6 +71,13 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
}
})
const billingStatus = computed(() => statusData.value?.billing_status ?? null)
const subscriptionStatus = computed(
() => statusData.value?.subscription_status ?? null
)
const tier = computed(() => statusData.value?.subscription_tier ?? null)
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
@@ -262,6 +270,37 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
}
}
async function resubscribe(): Promise<void> {
isLoading.value = true
error.value = null
try {
await workspaceApi.resubscribe()
await Promise.all([fetchStatus(), fetchBalance()])
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to resubscribe'
throw err
} finally {
isLoading.value = false
}
}
async function topup(amountCents: number): Promise<CreateTopupResponse> {
// Pass-through: the caller orchestrates the completed/pending branches
// (balance refresh on completed, billing-op polling on pending), so the
// facade must not refresh here or it double-fetches.
isLoading.value = true
error.value = null
try {
return await workspaceApi.createTopup(amountCents)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to top up credits'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
@@ -303,6 +342,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -312,6 +355,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog