Feat/workspaces 6 billing (#8508)

## Summary

Implements billing infrastructure for team workspaces, separate from
legacy personal billing.

## Changes

- **Billing abstraction**: New `useBillingContext` composable that
switches between legacy (personal) and workspace billing based on
context
- **Workspace subscription flows**: Pricing tables, plan transitions,
cancellation dialogs, and payment preview components for workspace
billing
- **Top-up credits**: Workspace-specific top-up dialog with polling for
payment confirmation
- **Workspace API**: Extended with billing endpoints (subscriptions,
invoices, payment methods, credits top-up)
- **Workspace switcher**: Now displays tier badges for each workspace
- **Subscribe polling**: Added polling mechanisms
(`useSubscribePolling`, `useTopupPolling`) for async payment flows

## Review Focus

- Billing flow correctness for workspace vs legacy contexts
- Polling timeout and error handling in payment flows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8508-Feat-workspaces-6-billing-2f96d73d365081f69f65c1ddf369010d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simula_r
2026-02-06 20:52:53 -08:00
committed by GitHub
parent 030d4fd4d5
commit c5431de123
54 changed files with 4861 additions and 568 deletions

View File

@@ -64,10 +64,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock the CurrentUserPopover component
vi.mock('./CurrentUserPopover.vue', () => ({
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
default: {
name: 'CurrentUserPopoverMock',
name: 'CurrentUserPopoverLegacyMock',
render() {
return h('div', 'Popover Content')
},

View File

@@ -45,14 +45,16 @@
class: 'rounded-lg w-80'
}
}"
@show="onPopoverShow"
>
<!-- Workspace mode: workspace-aware popover (only when ready) -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled && initState === 'ready'"
ref="workspacePopoverContent"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopover
<CurrentUserPopoverLegacy
v-else-if="!teamWorkspacesEnabled"
@close="closePopover"
/>
@@ -75,7 +77,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
@@ -112,8 +114,15 @@ const workspaceName = computed(() => {
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const workspacePopoverContent = ref<{
refreshBalance: () => void
} | null>(null)
const closePopover = () => {
popover.value?.hide()
}
const onPopoverShow = () => {
workspacePopoverContent.value?.refreshBalance()
}
</script>

View File

@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
@@ -172,7 +172,7 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
}
}))
describe('CurrentUserPopover', () => {
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthStoreState.balance = {
@@ -190,7 +190,7 @@ describe('CurrentUserPopover', () => {
messages: { en: enMessages }
})
return mount(CurrentUserPopover, {
return mount(CurrentUserPopoverLegacy, {
global: {
plugins: [i18n],
stubs: {

View File

@@ -87,18 +87,26 @@
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Navigate to workspace settings -->
<!-- Non-personal workspace: Show pricing table -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlanAndCreditsSettings"
@click="handleOpenPlansAndPricing"
>
{{ $t('workspaceSwitcher.subscribe') }}
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
@@ -196,18 +204,19 @@ import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -233,22 +242,33 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, subscriptionStatus } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const isLoadingBalance = isLoading
const displayedCredits = computed(() => {
if (initState.value !== 'ready') return ''
// Only personal workspaces have subscription status from useSubscription()
// Team workspaces don't have backend subscription data yet
if (isPersonalWorkspace.value) {
// Wait for subscription status to load
if (subscriptionStatus.value === null) return ''
return isActiveSubscription.value ? totalCredits.value : '0'
}
return '0'
// Wait for subscription to load
if (subscription.value === null) return ''
if (!isActiveSubscription.value) return '0'
// API field is named _micros but contains cents (naming inconsistency)
const cents =
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
})
const canUpgrade = computed(() => {
@@ -322,7 +342,11 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})
const refreshBalance = () => {
if (isActiveSubscription.value) {
void fetchBalance()
}
}
defineExpose({ refreshBalance })
</script>

View File

@@ -113,24 +113,24 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
SubscriptionTier,
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
subscriptionPlan: string | null
subscriptionTier: SubscriptionTier | null
}
const emit = defineEmits<{
@@ -140,7 +140,34 @@ const emit = defineEmits<{
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const { subscription } = useBillingContext()
const tierKeyMap: Record<string, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDER: 'founder',
FOUNDERS_EDITION: 'founder'
}
function formatTierName(
tier: string | null | undefined,
isYearly: boolean
): string {
if (!tier) return ''
const key = tierKeyMap[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
return isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
}
const currentSubscriptionTierName = computed(() => {
const tier = subscription.value?.tier
if (!tier) return ''
const isYearly = subscription.value?.duration === 'ANNUAL'
return formatTierName(tier, isYearly)
})
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
@@ -153,7 +180,8 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
type: w.type,
role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan
subscriptionPlan: w.subscriptionPlan,
subscriptionTier: w.subscriptionTier
}))
)
@@ -168,19 +196,32 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
}
function getTierLabel(workspace: AvailableWorkspace): string | null {
// Personal workspace: use user's subscription tier
if (workspace.type === 'personal') {
return userSubscriptionTierName.value || null
// For the current/active workspace, use billing context directly
// This ensures we always have the most up-to-date subscription info
if (isCurrentWorkspace(workspace)) {
return currentSubscriptionTierName.value || null
}
// Team workspace: use workspace subscription plan
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (workspace.subscriptionPlan === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
// For non-active workspaces, use cached store data
if (!workspace.isSubscribed) return null
if (workspace.subscriptionTier) {
return formatTierName(workspace.subscriptionTier, false)
}
if (!workspace.subscriptionPlan) return null
// Parse plan slug (format: TIER_DURATION, e.g. "CREATOR_MONTHLY", "PRO_YEARLY")
const planSlug = workspace.subscriptionPlan
// Extract tier from plan slug (e.g., "CREATOR_MONTHLY" -> "CREATOR")
const tierMatch = Object.keys(tierKeyMap).find((tier) =>
planSlug.startsWith(tier)
)
if (!tierMatch) return null
const isYearly = planSlug.includes('YEARLY') || planSlug.includes('ANNUAL')
return formatTierName(tierMatch, isYearly)
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {