[backport cloud/1.38] Feat/workspaces 6 billing (#8713)

Backport of #8508 to `cloud/1.38`

Automatically created by manual backport (cherry-pick of
c5431de123).

Conflicts resolved:
-
`src/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts`
— accepted PR version (uses `useBillingContext` mock instead of
pinia/firebase auth)
- `src/services/dialogService.ts` — merged: kept cloud/1.38 eager
imports for dialog components, replaced `TopUpCreditsDialogContent` with
Legacy/Workspace variants, replaced `useSubscription` with
`useBillingContext`, added workspace/legacy component selection in
`showTopUpCreditsDialog`; skipped lazy loader refactor (separate PR, not
part of #8508)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simula_r
2026-02-06 21:16:21 -08:00
committed by GitHub
parent 5c43c933d7
commit 9691f5fd00
54 changed files with 4862 additions and 564 deletions

2
.gitignore vendored
View File

@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp

View File

@@ -8,10 +8,10 @@
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton

View File

@@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -176,8 +177,9 @@ const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
const { flags } = useFeatureFlags()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
@@ -256,9 +258,15 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -0,0 +1,295 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>
<!-- Warnings -->
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
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 { useDialogService } from '@/services/dialogService'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
if (loading.value || !isValidAmount.value) return
loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
if (response.status === 'completed') {
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
life: 5000
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError'),
life: 5000
})
}
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)

View File

@@ -0,0 +1,108 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('subscription.cancelDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
:disabled="isLoading"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
{{ $t('subscription.cancelDialog.keepSubscription') }}
</Button>
<Button
variant="destructive"
size="lg"
:loading="isLoading"
@click="onConfirmCancel"
>
{{ $t('subscription.cancelDialog.confirmCancel') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
cancelAt?: string
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const formattedEndDate = computed(() => {
const dateStr = props.cancelAt ?? subscription.value?.endDate
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
})
const description = computed(() =>
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
)
function onClose() {
if (isLoading.value) return
dialogStore.closeDialog({ key: 'cancel-subscription' })
}
async function onConfirmCancel() {
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h2 class="px-4">
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
<i class="pi pi-cog" />
<span>{{ $t('g.settings') }}</span>
<Tag
@@ -16,6 +16,9 @@
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { cn } from '@comfyorg/tailwind-utils'
const { flags } = useFeatureFlags()
</script>
<style scoped>

View File

@@ -1,5 +1,13 @@
<template>
<Toast />
<Toast group="billing-operation" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2">
<i class="pi pi-spin pi-spinner text-primary" />
<span>{{ slotProps.message.summary }}</span>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">

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) {

View File

@@ -2,11 +2,11 @@ import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
@@ -83,7 +83,7 @@ export const useFirebaseAuthActions = () => {
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
if (!isActiveSubscription.value) return
const response = await authStore.initiateCreditPurchase({

View File

@@ -0,0 +1,76 @@
import type { ComputedRef, Ref } from 'vue'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
export interface SubscriptionInfo {
isActive: boolean
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
renewalDate: string | null
endDate: string | null
isCancelled: boolean
hasFunds: boolean
}
export interface BalanceInfo {
amountMicros: number
currency: string
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
}
export interface BillingActions {
initialize: () => Promise<void>
fetchStatus: () => Promise<void>
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
* Shows subscription dialog if not subscribed.
* Use this in extensions/entry points that require active subscription.
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
*/
showSubscriptionDialog: () => void
}
export interface BillingState {
isInitialized: Ref<boolean>
subscription: ComputedRef<SubscriptionInfo | null>
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
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>
}
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
}

View File

@@ -0,0 +1,164 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
showSubscriptionDialog: vi.fn()
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn(),
hide: vi.fn()
})
})
)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
balance: { amount_micros: 5000000 },
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
}),
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
}
}))
describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('returns legacy type for personal workspace', () => {
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
expect(subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
endDate: null,
isCancelled: false,
hasFunds: true
})
})
it('provides balance info from legacy billing', () => {
const { balance } = useBillingContext()
expect(balance.value).toEqual({
amountMicros: 5000000,
currency: 'usd',
effectiveBalanceMicros: 5000000,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
})
it('exposes initialize action', async () => {
const { initialize } = useBillingContext()
await expect(initialize()).resolves.toBeUndefined()
})
it('exposes fetchStatus action', async () => {
const { fetchStatus } = useBillingContext()
await expect(fetchStatus()).resolves.toBeUndefined()
})
it('exposes fetchBalance action', async () => {
const { fetchBalance } = useBillingContext()
await expect(fetchBalance()).resolves.toBeUndefined()
})
it('exposes subscribe action', async () => {
const { subscribe } = useBillingContext()
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
})
it('exposes manageSubscription action', async () => {
const { manageSubscription } = useBillingContext()
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
})
it('exposes requireActiveSubscription action', async () => {
const { requireActiveSubscription } = useBillingContext()
await expect(requireActiveSubscription()).resolves.toBeUndefined()
})
it('exposes showSubscriptionDialog action', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
})

View File

@@ -0,0 +1,242 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from './useWorkspaceBilling'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
*
* @example
* ```typescript
* const {
* type,
* subscription,
* balance,
* isInitialized,
* initialize,
* subscribe
* } = useBillingContext()
*
* // Wait for initialization
* await initialize()
*
* // Check subscription status
* if (subscription.value?.isActive) {
* console.log(`Tier: ${subscription.value.tier}`)
* }
*
* // Check balance
* if (balance.value) {
* const dollars = balance.value.amountMicros / 1_000_000
* console.log(`Balance: $${dollars.toFixed(2)}`)
* }
* ```
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
)
const workspaceBillingRef = shallowRef<
(BillingState & BillingActions) | null
>(null)
const getLegacyBilling = () => {
if (!legacyBillingRef.value) {
legacyBillingRef.value = useLegacyBilling()
}
return legacyBillingRef.value
}
const getWorkspaceBilling = () => {
if (!workspaceBillingRef.value) {
workspaceBillingRef.value = useWorkspaceBilling()
}
return workspaceBillingRef.value
}
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
// Proxy state from active context
const subscription = computed<SubscriptionInfo | null>(() =>
toValue(activeContext.value.subscription)
)
const balance = computed<BalanceInfo | null>(() =>
toValue(activeContext.value.balance)
)
const plans = computed(() => toValue(activeContext.value.plans))
const currentPlanSlug = computed(() =>
toValue(activeContext.value.currentPlanSlug)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
subscriptionPlan: sub.planSlug
})
},
{ immediate: true }
)
// Initialize billing when workspace changes
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
}
},
{ immediate: true }
)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
return activeContext.value.fetchStatus()
}
async function fetchBalance(): Promise<void> {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
}
async function manageSubscription() {
return activeContext.value.manageSubscription()
}
async function cancelSubscription() {
return activeContext.value.cancelSubscription()
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
async function requireActiveSubscription() {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {
type,
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}
export const useBillingContext = createSharedComposable(
useBillingContextInternal
)

View File

@@ -0,0 +1,189 @@
import { computed, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for legacy user-scoped billing via /customers/* endpoints.
* Used for personal workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useLegacyBilling(): BillingState & BillingActions {
const {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
subscribe: legacySubscribe,
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const firebaseAuthStore = useFirebaseAuthStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
return null
}
return {
isActive: legacyIsActiveSubscription.value,
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = firebaseAuthStore.balance
if (!legacyBalance) return null
return {
amountMicros: legacyBalance.amount_micros ?? 0,
currency: legacyBalance.currency ?? 'usd',
effectiveBalanceMicros:
legacyBalance.effective_balance_micros ??
legacyBalance.amount_micros ??
0,
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
}
})
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
await legacyFetchStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
await firebaseAuthStore.fetchBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null
}
async function manageSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function cancelSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
}
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -0,0 +1,311 @@
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.
* Used for team workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useWorkspaceBilling(): BillingState & BillingActions {
const billingPlans = useBillingPlans()
const workspaceStore = useTeamWorkspaceStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const statusData = shallowRef<BillingStatusResponse | null>(null)
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
const isActiveSubscription = computed(
() => statusData.value?.is_active ?? false
)
const subscription = computed<SubscriptionInfo | null>(() => {
const status = statusData.value
if (!status) return null
return {
isActive: status.is_active,
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: null, // Workspace billing uses cancel_at for end date
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds
}
})
const balance = computed<BalanceInfo | null>(() => {
const data = balanceData.value
if (!data) return null
return {
amountMicros: data.amount_micros,
currency: data.currency,
effectiveBalanceMicros: data.effective_balance_micros,
prepaidBalanceMicros: data.prepaid_balance_micros,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
}
})
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const pendingCancelOpId = ref<string | null>(null)
let cancelPollTimeout: number | null = null
const stopCancelPolling = () => {
if (cancelPollTimeout !== null) {
window.clearTimeout(cancelPollTimeout)
cancelPollTimeout = null
}
}
async function pollCancelStatus(opId: string): Promise<void> {
stopCancelPolling()
const maxAttempts = 30
let attempt = 0
const poll = async () => {
if (pendingCancelOpId.value !== opId) return
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
workspaceStore.updateActiveWorkspace({
isSubscribed: false
})
return
}
if (response.status === 'failed') {
pendingCancelOpId.value = null
stopCancelPolling()
throw new Error(
response.error_message ?? 'Failed to cancel subscription'
)
}
attempt += 1
if (attempt >= maxAttempts) {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
return
}
} catch (err) {
pendingCancelOpId.value = null
stopCancelPolling()
throw err
}
cancelPollTimeout = window.setTimeout(
() => {
void poll()
},
Math.min(1000 * 2 ** attempt, 5000)
)
}
await poll()
}
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
statusData.value = await workspaceApi.getBillingStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch billing status'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
balanceData.value = await workspaceApi.getBillingBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.subscribe(
planSlug,
returnUrl,
cancelUrl
)
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
throw err
} finally {
isLoading.value = false
}
}
async function previewSubscribe(
planSlug: string
): Promise<PreviewSubscribeResponse | null> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.previewSubscribe(planSlug)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to preview subscription'
throw err
} finally {
isLoading.value = false
}
}
async function manageSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const returnUrl = window.location.href
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
if (response.url) {
window.open(response.url, '_blank')
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to open billing portal'
throw err
} finally {
isLoading.value = false
}
}
async function cancelSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.cancelSubscription()
pendingCancelOpId.value = response.billing_op_id
await pollCancelStatus(response.billing_op_id)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to cancel subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
try {
await billingPlans.fetchPlans()
if (billingPlans.error.value) {
error.value = billingPlans.error.value
}
} finally {
isLoading.value = false
}
}
const subscriptionDialog = useSubscriptionDialog()
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
onBeforeUnmount(() => {
stopCancelPolling()
})
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -107,6 +107,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: true },
showSubscriptionDialog: vi.fn()
}))
}))
describe('useCoreCommands', () => {
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
const baseNode = createMockLGraphNode({ id })

View File

@@ -17,9 +17,9 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -69,7 +69,7 @@ import { useDialogStore } from '@/stores/dialogStore'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()

View File

@@ -1,7 +1,7 @@
import { watchDebounced } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useExtensionService } from '@/services/extensionService'
@@ -14,7 +14,7 @@ useExtensionService().registerExtension({
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
// Refresh config when auth or subscription status changes
// Primary auth refresh is handled by WorkspaceAuthGate on mount

View File

@@ -1,7 +1,7 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExtensionService } from '@/services/extensionService'
/**
@@ -12,7 +12,7 @@ useExtensionService().registerExtension({
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { requireActiveSubscription } = useSubscription()
const { requireActiveSubscription } = useBillingContext()
const checkSubscriptionStatus = () => {
if (!isLoggedIn.value) return

View File

@@ -1983,6 +1983,7 @@
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseSuccess": "Credits added successfully!",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred",
@@ -2015,19 +2016,49 @@
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
}
},
"billingOperation": {
"subscriptionProcessing": "Processing payment — setting up your workspace...",
"subscriptionSuccess": "Subscription updated successfully",
"subscriptionFailed": "Subscription update failed",
"subscriptionTimeout": "Subscription verification timed out",
"topupProcessing": "Processing payment — adding credits...",
"topupSuccess": "Credits added successfully",
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out"
},
"subscription": {
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"member": "member",
"usdPerMonth": "USD / mo",
"usdPerMonthPerMember": "USD / mo / member",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"canceled": "Canceled",
"resubscribe": "Resubscribe",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
},
"cancelSuccess": "Subscription cancelled successfully",
"cancelDialog": {
"title": "Cancel subscription",
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
"endOfBillingPeriod": "end of billing period",
"keepSubscription": "Keep subscription",
"confirmCancel": "Cancel subscription",
"failed": "Failed to cancel subscription"
},
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
@@ -2076,7 +2107,10 @@
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
"subscribe": "Subscribe",
"pollingSuccess": "Subscription activated successfully!",
"pollingFailed": "Subscription activation failed",
"pollingTimeout": "Timed out waiting for subscription. Please refresh and try again."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
@@ -2086,6 +2120,7 @@
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"descriptionWorkspace": "Choose the best plan for your workspace",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "View enterprise",
@@ -2097,7 +2132,13 @@
"currentPlan": "Current Plan",
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
"maxMembersLabel": "Max. members",
"yearlyCreditsLabel": "Total yearly credits",
"membersLabel": "Up to {count} members",
"nextMonthInvoice": "Next month invoice",
"invoiceHistory": "Invoice history",
"memberCount": "{count} member | {count} members",
"maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
@@ -2119,6 +2160,27 @@
"billingComingSoon": {
"title": "Coming Soon",
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
},
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starting today",
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"perMember": "/ member",
"showMoreFeatures": "Show more features",
"hideFeatures": "Hide features",
"proratedRefund": "Prorated refund for {plan}",
"proratedCharge": "Prorated charge for {plan}",
"totalDueToday": "Total due today",
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
"terms": "Terms",
"privacyPolicy": "Privacy Policy",
"addCreditCard": "Add credit card",
"confirm": "Confirm",
"backToAllPlans": "Back to all plans"
}
},
"userSettings": {

View File

@@ -12,13 +12,13 @@
</template>
<script setup lang="ts">
import { useBillingContext } from '@/composables/billing/useBillingContext'
import UploadModelUpgradeModalBody from '@/platform/assets/components/UploadModelUpgradeModalBody.vue'
import UploadModelUpgradeModalFooter from '@/platform/assets/components/UploadModelUpgradeModalFooter.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { showSubscriptionDialog } = useSubscription()
const { showSubscriptionDialog } = useBillingContext()
function handleClose() {
dialogStore.closeDialog({ key: 'upload-model-upgrade' })

View File

@@ -47,6 +47,10 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => subscriptionMocks
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => subscriptionMocks
}))
// Avoid real network / isCloud behavior
const mockPerformSubscriptionCheckout = vi.fn()
vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({

View File

@@ -6,9 +6,9 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
@@ -20,7 +20,7 @@ const router = useRouter()
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isActiveSubscription, isInitialized } = useSubscription()
const { isActiveSubscription, isInitialized } = useBillingContext()
const selectedTierKey = ref<TierKey | null>(null)

View File

@@ -0,0 +1,527 @@
<template>
<div class="flex flex-col gap-8">
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center">
{{ t('subscription.chooseBestPlanWorkspace') }}
</h2>
<div class="flex justify-center">
<SelectButton
v-model="currentBillingCycle"
:options="billingCycleOptions"
option-label="label"
option-value="value"
:allow-empty="false"
unstyled
:pt="{
root: {
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
},
pcToggleButton: {
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
class: [
'w-36 h-8 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
context.active
? 'bg-base-foreground text-base-background'
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
]
}),
label: { class: 'flex items-center gap-2 ' }
}
}"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="bg-primary-background text-white text-[11px] px-1 py-0.5 rounded-full flex items-center font-bold"
>
-20%
</div>
</div>
</template>
</SelectButton>
</div>
<div class="flex flex-col xl:flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
:class="
cn(
'flex-1 flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)]',
tier.isPopular ? 'border-muted-foreground' : ''
)
"
>
<div class="p-8 pb-0 flex flex-col gap-8">
<div class="flex flex-row items-center gap-2 justify-between">
<span
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-base-foreground px-1.5 text-[11px] font-bold uppercase text-base-background h-5 tracking-tight flex items-center"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
<span
v-show="currentBillingCycle === 'yearly'"
class="line-through text-2xl text-muted-foreground"
>
${{ getMonthlyPrice(tier) }}
</span>
${{ getPrice(tier) }}
</span>
<span
class="font-inter text-sm leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{
currentBillingCycle === 'yearly'
? t('subscription.billedYearly', {
total: `$${getAnnualTotal(tier)}`
})
: t('subscription.billedMonthly')
}}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-4 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ n(getMonthlyCreditsPerMember(tier)) }}
</span>
</div>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.maxMembersLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ getMaxMembers(tier) }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-foreground" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span
class="text-sm font-normal text-foreground leading-relaxed"
>
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
~{{ n(tier.pricing.videoEstimate) }}
</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:variant="getButtonSeverity(tier)"
:disabled="isButtonDisabled(tier)"
:loading="props.loadingTier === tier.key"
:class="
cn(
'h-10 w-full',
getButtonTextClass(tier),
tier.key === 'creator'
? 'bg-base-foreground border-transparent hover:bg-inverted-background-hover'
: 'bg-secondary-background border-transparent hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
)
"
@click="() => handleSubscribe(tier.key)"
>
{{ getButtonLabel(tier) }}
</Button>
</div>
</div>
</div>
<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
>
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a>
</div>
</Popover>
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
<p class="text-sm text-text-secondary m-0">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-1.5">
<Button
variant="muted-textonly"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
@click="handleContactUs"
>
{{ $t('subscription.contactUs') }}
<i class="pi pi-comments" />
</Button>
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
<Button
variant="muted-textonly"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
@click="handleViewEnterprise"
>
{{ $t('subscription.viewEnterprise') }}
<i class="pi pi-external-link" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Popover from 'primevue/popover'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import {
TIER_PRICING,
TIER_TO_KEY
} from '@/platform/cloud/subscription/constants/tierPricing'
import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'>
interface Props {
isLoading?: boolean
loadingTier?: CheckoutTierKey | null
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
loadingTier: null
})
const emit = defineEmits<{
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
resubscribe: []
}>()
interface BillingCycleOption {
label: string
value: BillingCycle
}
interface PricingTierConfig {
id: SubscriptionTier
key: CheckoutTierKey
name: string
pricing: TierPricing
maxDuration: string
customLoRAs: boolean
maxMembers: number
isPopular?: boolean
}
const billingCycleOptions: BillingCycleOption[] = [
{ label: t('subscription.yearly'), value: 'yearly' },
{ label: t('subscription.monthly'), value: 'monthly' }
]
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
pricing: TIER_PRICING.standard,
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
maxMembers: 1,
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
pricing: TIER_PRICING.creator,
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
maxMembers: 5,
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
pricing: TIER_PRICING.pro,
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
maxMembers: 20,
isPopular: false
}
]
const { n } = useI18n()
const {
plans: apiPlans,
currentPlanSlug,
fetchPlans,
subscription
} = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly')
onMounted(() => {
void fetchPlans()
})
function getApiPlanForTier(
tierKey: CheckoutTierKey,
duration: BillingCycle
): Plan | undefined {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase() as Plan['tier']
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getPriceFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, currentBillingCycle.value)
if (!plan) return null
const price = plan.price_cents / 100
return currentBillingCycle.value === 'yearly' ? price / 12 : price
}
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.max_seats : null
}
const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
// Use API current_plan_slug if available
if (currentPlanSlug.value) {
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
return plan?.slug === currentPlanSlug.value
}
// Fallback to tier-based detection
if (!currentTierKey.value) return false
const selectedIsYearly = currentBillingCycle.value === 'yearly'
return (
currentTierKey.value === tierKey &&
isYearlySubscription.value === selectedIsYearly
)
}
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
const getButtonLabel = (tier: PricingTierConfig): string => {
const planName =
currentBillingCycle.value === 'yearly'
? t('subscription.tierNameYearly', { name: tier.name })
: tier.name
if (isCurrentPlan(tier.key)) {
return isCancelled.value
? t('subscription.resubscribeTo', { plan: planName })
: t('subscription.currentPlan')
}
return currentTierKey.value
? t('subscription.changeTo', { plan: planName })
: t('subscription.subscribeTo', { plan: planName })
}
const getButtonSeverity = (
tier: PricingTierConfig
): 'primary' | 'secondary' => {
if (isCurrentPlan(tier.key)) {
return isCancelled.value ? 'primary' : 'secondary'
}
if (tier.key === 'creator') return 'primary'
return 'secondary'
}
const isButtonDisabled = (tier: PricingTierConfig): boolean => {
if (props.isLoading) return true
if (isCurrentPlan(tier.key)) {
// Allow clicking current plan button when cancelled (for resubscribe)
return !isCancelled.value
}
return false
}
const getButtonTextClass = (tier: PricingTierConfig): string =>
tier.key === 'creator'
? 'font-inter text-sm font-bold leading-normal text-base-background'
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'
const getPrice = (tier: PricingTierConfig): number =>
getPriceFromApi(tier) ?? tier.pricing[currentBillingCycle.value]
const getMonthlyPrice = (tier: PricingTierConfig): number => {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.price_cents / 100 : tier.pricing.monthly
}
const getAnnualTotal = (tier: PricingTierConfig): number => {
const plan = getApiPlanForTier(tier.key, 'yearly')
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
}
const getMaxMembers = (tier: PricingTierConfig): number =>
getMaxSeatsFromApi(tier) ?? tier.maxMembers
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
tier.pricing.credits
function handleSubscribe(tierKey: CheckoutTierKey) {
if (props.isLoading) return
// Handle resubscribe for cancelled subscription on current plan
if (isCurrentPlan(tierKey)) {
if (isCancelled.value) {
emit('resubscribe')
}
return
}
emit('subscribe', {
tierKey,
billingCycle: currentBillingCycle.value
})
}
function handleContactUs() {
window.open('https://www.comfy.org/discord', '_blank')
}
function handleViewEnterprise() {
window.open('https://www.comfy.org/enterprise', '_blank')
}
</script>

View File

@@ -1,8 +1,7 @@
<template>
<Button
:size
:loading="isLoading"
:disabled="disabled || isPolling"
:disabled="disabled"
variant="primary"
:style="
variant === 'gradient'
@@ -23,7 +22,7 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil'
@@ -46,60 +45,9 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const isAwaitingStripeSubscription = ref(false)
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
const startPollingSubscriptionStatus = () => {
isPolling.value = true
isLoading.value = true
const startTime = Date.now()
const poll = async () => {
try {
if (Date.now() - startTime > MAX_POLL_DURATION_MS) {
stopPolling()
return
}
await fetchStatus()
if (isActiveSubscription.value) {
stopPolling()
telemetry?.trackMonthlySubscriptionSucceeded()
emit('subscribed')
}
} catch (error) {
console.error(
'[SubscribeButton] Error polling subscription status:',
error
)
}
}
void poll()
pollInterval = window.setInterval(poll, POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
isPolling.value = false
isLoading.value = false
}
watch(
[isAwaitingStripeSubscription, isActiveSubscription],
([awaiting, isActive]) => {
@@ -110,27 +58,15 @@ watch(
}
)
const handleSubscribe = async () => {
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}
isLoading.value = true
try {
await subscribe()
startPollingSubscriptionStatus()
} catch (error) {
console.error('[SubscribeButton] Error initiating subscription:', error)
isLoading.value = false
}
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
}
onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>

View File

@@ -26,7 +26,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -40,7 +40,7 @@ const buttonLabel = computed(() =>
: t('subscription.subscribeToRun')
)
const { showSubscriptionDialog } = useSubscription()
const { showSubscriptionDialog } = useBillingContext()
const handleSubscribeToRun = () => {
if (isCloud) {

View File

@@ -0,0 +1,255 @@
<template>
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center mb-8">
{{ $t('subscription.preview.confirmPayment') }}
</h2>
<div
class="flex flex-col justify-between items-stretch max-w-[400px] mx-auto text-sm h-full"
>
<div class="">
<!-- Plan Header -->
<div class="flex flex-col gap-2">
<span class="text-base-foreground text-sm">
{{ tierName }}
</span>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-semibold text-base-foreground">
${{ displayPrice }}
</span>
<span class="text-xl text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<span class="text-muted-foreground">
{{ $t('subscription.preview.startingToday') }}
</span>
</div>
<!-- Credits Section -->
<div class="flex flex-col gap-3 pt-16 pb-8">
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="font-bold text-base-foreground">
{{ displayCredits }}
</span>
<span class="text-base-foreground">
{{ $t('subscription.preview.perMember') }}
</span>
</div>
</div>
<!-- Expandable Features -->
<button
class="flex items-center justify-end gap-1 text-sm text-muted-foreground hover:text-base-foreground cursor-pointer bg-transparent border-none p-0"
@click="isFeaturesCollapsed = !isFeaturesCollapsed"
>
<span>
{{
isFeaturesCollapsed
? $t('subscription.preview.showMoreFeatures')
: $t('subscription.preview.hideFeatures')
}}
</span>
<i
:class="
cn(
'pi text-xs',
isFeaturesCollapsed ? 'pi-chevron-down' : 'pi-chevron-up'
)
"
/>
</button>
<div v-show="!isFeaturesCollapsed" class="flex flex-col gap-2 pt-2">
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.maxDurationLabel') }}
</span>
<span class="text-sm font-bold text-base-foreground">
{{ maxDuration }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="hasCustomLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
</div>
</div>
<!-- Total Due Section -->
<div class="flex flex-col gap-2 border-t border-border-subtle pt-8">
<div class="flex text-base items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.totalDueToday') }}
</span>
<span class="font-bold text-base-foreground">
${{ totalDueToday }}
</span>
</div>
<span class="text-muted-foreground text-sm">
{{
$t('subscription.preview.nextPaymentDue', {
date: nextPaymentDate
})
}}
</span>
</div>
</div>
<!-- Footer -->
<div class="flex flex-col gap-2 pt-8">
<!-- Terms Agreement -->
<p class="text-xs text-muted-foreground text-center">
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
</p>
<!-- Add Credit Card Button -->
<Button
variant="secondary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@click="$emit('addCreditCard')"
>
{{ $t('subscription.preview.addCreditCard') }}
</Button>
<!-- Back Link -->
<Button
variant="textonly"
class="text-muted-foreground hover:text-base-foreground hover:bg-none text-center cursor-pointer transition-colors text-xs"
@click="$emit('back')"
>
{{ $t('subscription.preview.backToAllPlans') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { cn } from '@/utils/tailwindUtil'
interface Props {
tierKey: Exclude<TierKey, 'founder'>
billingCycle?: BillingCycle
isLoading?: boolean
previewData?: PreviewSubscribeResponse | null
}
const {
tierKey,
billingCycle = 'monthly',
isLoading = false,
previewData = null
} = defineProps<Props>()
defineEmits<{
addCreditCard: []
back: []
}>()
const { t, n } = useI18n()
const isFeaturesCollapsed = ref(true)
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
const displayPrice = computed(() => {
if (previewData?.new_plan) {
return (previewData.new_plan.price_cents / 100).toFixed(0)
}
return getTierPrice(tierKey, billingCycle === 'yearly')
})
const displayCredits = computed(() => n(getTierCredits(tierKey)))
const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs)
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
const totalDueToday = computed(() => {
if (previewData) {
return (previewData.cost_today_cents / 100).toFixed(2)
}
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
if (billingCycle === 'yearly') {
return (priceValue * 12).toFixed(2)
}
return priceValue.toFixed(2)
})
const nextPaymentDate = computed(() => {
if (previewData?.new_plan?.period_end) {
return new Date(previewData.new_plan.period_end).toLocaleDateString(
'en-US',
{
month: 'short',
day: 'numeric',
year: 'numeric'
}
)
}
const date = new Date()
if (billingCycle === 'yearly') {
date.setFullYear(date.getFullYear() + 1)
} else {
date.setMonth(date.getMonth() + 1)
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
</script>

View File

@@ -90,6 +90,13 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
}))
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
manageSubscription: vi.fn()
})
}))
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,

View File

@@ -72,10 +72,10 @@ import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { isCloud } from '@/platform/distribution/types'
@@ -91,11 +91,15 @@ const teamWorkspacesEnabled = computed(
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
const { isActiveSubscription, manageSubscription } = useBillingContext()
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
const handleInvoiceHistory = async () => {
await manageSubscription()
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),

View File

@@ -1,246 +1,336 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
class="rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-center gap-2 text-muted-foreground py-4">
<i class="pi pi-spin pi-spinner" />
<span>{{ $t('billingOperation.subscriptionProcessing') }}</span>
</div>
</div>
<template v-else>
<!-- Cancelled subscription info card -->
<div
v-if="isCancelled"
class="mb-6 flex gap-1 rounded-2xl border border-warning-background bg-warning-background/20 p-4"
>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
class="flex size-8 shrink-0 items-center justify-center rounded-full text-warning-background"
>
<!-- OWNER Unsubscribed State -->
<template v-if="showSubscribePrompt">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.subscriptionRequiredMessage') }}
</div>
</div>
<Button
variant="primary"
size="lg"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<i class="pi pi-info-circle" />
</div>
<div class="flex flex-col gap-2">
<h2 class="text-sm font-bold text-text-primary m-0 pt-1.5">
{{ $t('subscription.canceledCard.title') }}
</h2>
<p class="text-sm text-text-secondary m-0">
{{
$t('subscription.canceledCard.description', {
date: formattedEndDate
})
}}
</p>
</div>
</div>
<!-- MEMBER View - read-only, no subscription data yet -->
<template v-else-if="isMemberView">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
>
<!-- OWNER Unsubscribed State -->
<template v-if="showSubscribePrompt">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.subscriptionRequiredMessage') }}
</div>
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.contactOwnerToSubscribe') }}
</div>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription) -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<div
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<Button
variant="primary"
size="lg"
variant="secondary"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<!-- MEMBER View - read-only, workspace not subscribed -->
<template v-else-if="isMemberView">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.contactOwnerToSubscribe') }}
</div>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription, or member viewing subscribed workspace) -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</span>
<StatusBadge
v-if="isCancelled"
:label="$t('subscription.canceled')"
severity="warn"
/>
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base"
>{{ $t('subscription.perMonth') }} /
{{ $t('subscription.member') }}</span
>
</div>
<div
v-if="isActiveSubscription"
:class="
cn(
'text-sm',
isCancelled
? 'text-warning-background'
: 'text-text-secondary'
)
"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<div
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: show only Resubscribe button -->
<template v-if="isCancelled">
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('subscription.resubscribe') }}
</Button>
</template>
<!-- Active state: show Manage Payment, Upgrade, and menu -->
<template v-else>
<Button
size="lg"
variant="secondary"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="manageSubscription"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
</div>
</template>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</div>
</template>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription && !showZeroState"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
{{ $t('subscription.addCredits') }}
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<Button
v-if="isActiveSubscription && !showZeroState"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<i
v-else-if="benefit.type === 'icon' && benefit.icon"
:class="[benefit.icon, 'text-xs text-text-primary']"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Members invoice card -->
<div
v-if="isActiveSubscription && !isInPersonalWorkspace"
class="mt-6 flex gap-1 rounded-2xl border border-interface-stroke p-6 justify-between items-center text-sm"
>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
<h4 class="text-sm text-text-primary m-0">
{{ $t('subscription.nextMonthInvoice') }}
</h4>
<span
class="text-muted-foreground underline cursor-pointer"
@click="manageSubscription"
>
{{ $t('subscription.invoiceHistory') }}
</span>
</div>
<div class="flex flex-col gap-2 items-end">
<h4 class="m-0 font-bold">${{ nextMonthInvoice }}</h4>
<h5 class="m-0 text-muted-foreground">
{{ $t('subscription.memberCount', memberCount) }}
</h5>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-6">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</template>
</div>
</template>
@@ -251,13 +341,17 @@ import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'primevue/usetoast'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
@@ -269,51 +363,121 @@ import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
const { showBillingComingSoonDialog } = useDialogService()
const toast = useToast()
const billingOperationStore = useBillingOperationStore()
const isSettingUp = computed(() => billingOperationStore.isSettingUp)
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
manageSubscription,
fetchStatus,
fetchBalance,
plans: apiPlans
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const isResubscribing = ref(false)
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.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'
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message,
life: 5000
})
} finally {
isResubscribing.value = false
}
}
// Only show cancelled state for team workspaces (workspace billing)
// Personal workspaces use legacy billing which has different cancellation semantics
const isCancelled = computed(
() =>
!isInPersonalWorkspace.value && (subscription.value?.isCancelled ?? false)
)
// Show subscribe prompt to owners without active subscription
// Don't show if subscription is cancelled (still active until end date)
const showSubscribePrompt = computed(() => {
if (workspaceRole.value !== 'owner') return false
if (isCancelled.value) return false
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
return !isWorkspaceSubscribed.value
})
// MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription)
// MEMBER view without subscription - members can't manage subscription
const isMemberView = computed(
() =>
!permissions.value.canManageSubscription &&
!isActiveSubscription.value &&
!isWorkspaceSubscribed.value
)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => showSubscribePrompt.value || isMemberView.value
)
// Subscribe workspace - show billing coming soon dialog for team workspaces
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
function handleSubscribeWorkspace() {
if (!isInPersonalWorkspace.value) {
showBillingComingSoonDialog()
return
}
subscribeWorkspace('PRO_MONTHLY')
showSubscriptionDialog()
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const formattedRenewalDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const renewalDate = new Date(subscription.value.renewalDate)
return renewalDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const formattedEndDate = computed(() => {
if (!subscription.value?.endDate) return ''
const endDate = new Date(subscription.value.endDate)
return endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
if (!tier) return ''
const key = TIER_TO_KEY[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
return isYearlySubscription.value
? t('subscription.tierNameYearly', { name: baseName })
: baseName
})
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
@@ -321,8 +485,8 @@ const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: async () => {
await authActions.accessBillingPortal()
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
}
}
])
@@ -336,9 +500,29 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
const plan = getApiPlanForTier(tierKey, 'monthly')
return plan ? plan.max_seats : null
}
function getMaxMembers(tierKey: TierKey): number {
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
}
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
@@ -366,19 +550,26 @@ const includedCreditsDisplay = computed(
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
type BenefitType = 'metric' | 'feature' | 'icon'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
icon?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'members',
type: 'icon',
label: t('subscription.membersLabel', { count: getMaxMembers(key) }),
icon: 'pi pi-user'
},
{
key: 'maxDuration',
type: 'metric',
@@ -436,6 +627,7 @@ function handleWindowFocus() {
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
void Promise.all([fetchStatus(), fetchBalance()])
})
onBeforeUnmount(() => {

View File

@@ -127,7 +127,8 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
@@ -139,8 +140,10 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { fetchStatus, isActiveSubscription, isSubscriptionEnabled } =
useSubscription()
const { fetchStatus, isActiveSubscription } = useBillingContext()
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
// Legacy price for non-tier flow with locale-aware formatting
const formattedMonthlyPrice = new Intl.NumberFormat(

View File

@@ -0,0 +1,329 @@
<template>
<div
class="relative flex flex-col p-4 pt-8 md:p-16 !overflow-y-auto h-full gap-8"
>
<Button
v-if="checkoutStep === 'preview'"
size="icon"
variant="muted-textonly"
class="rounded-full shrink-0 text-text-secondary hover:bg-white/10 absolute left-2.5 top-2.5"
:aria-label="$t('g.back')"
@click="handleBackToPricing"
>
<i class="pi pi-arrow-left text-xl" />
</Button>
<Button
size="icon"
variant="muted-textonly"
class="rounded-full shrink-0 text-text-secondary hover:bg-white/10 absolute right-2.5 top-2.5"
:aria-label="$t('g.close')"
@click="handleClose"
>
<i class="pi pi-times text-xl" />
</Button>
<!-- Pricing Table Step -->
<PricingTableWorkspace
v-if="checkoutStep === 'pricing'"
class="flex-1"
:is-loading="isLoadingPreview || isResubscribing"
:loading-tier="loadingTier"
@subscribe="handleSubscribeClick"
@resubscribe="handleResubscribe"
/>
<!-- Subscription Preview Step - New Subscription -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type === 'new_subscription'
"
:preview-data="previewData"
:tier-key="selectedTierKey!"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleAddCreditCard"
@back="handleBackToPricing"
/>
<!-- Subscription Preview Step - Plan Transition -->
<SubscriptionTransitionPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type !== 'new_subscription'
"
:preview-data="previewData"
:is-loading="isSubscribing || isPolling"
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
type CheckoutStep = 'pricing' | 'preview'
type CheckoutTierKey = Exclude<TierKey, 'founder'>
const props = defineProps<{
onClose: () => void
}>()
const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
const checkoutStep = ref<CheckoutStep>('pricing')
const isLoadingPreview = ref(false)
const loadingTier = ref<CheckoutTierKey | null>(null)
const isSubscribing = ref(false)
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
function getApiPlanSlug(
tierKey: CheckoutTierKey,
billingCycle: BillingCycle
): string | null {
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
return plan?.slug ?? null
}
async function handleSubscribeClick(payload: {
tierKey: CheckoutTierKey
billingCycle: BillingCycle
}) {
const { tierKey, billingCycle } = payload
isLoadingPreview.value = true
loadingTier.value = tierKey
selectedTierKey.value = tierKey
selectedBillingCycle.value = billingCycle
try {
const planSlug = getApiPlanSlug(tierKey, billingCycle)
if (!planSlug) {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: 'This plan is not available',
life: 5000
})
return
}
const response = await previewSubscribe(planSlug)
if (!response || !response.allowed) {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: response?.reason || 'This plan is not available',
life: 5000
})
return
}
previewData.value = response
checkoutStep.value = 'preview'
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Failed to load subscription preview'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isLoadingPreview.value = false
loadingTier.value = null
}
}
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
}
async function handleAddCreditCard() {
if (!selectedTierKey.value) return
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
)
if (!response) return
if (response.status === 'subscribed') {
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} else if (
response.status === 'needs_payment_method' &&
response.payment_method_url
) {
window.open(response.payment_method_url, '_blank')
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
} else if (response.status === 'pending_payment') {
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to subscribe'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isSubscribing.value = false
}
}
async function handleConfirmTransition() {
if (!selectedTierKey.value) return
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
)
if (!response) return
if (response.status === 'subscribed') {
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} else if (
response.status === 'needs_payment_method' &&
response.payment_method_url
) {
window.open(response.payment_method_url, '_blank')
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
} else if (response.status === 'pending_payment') {
billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to update subscription'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isSubscribing.value = false
}
}
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to resubscribe'
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
})
} finally {
isResubscribing.value = false
}
}
function handleClose() {
props.onClose()
}
</script>
<style scoped>
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
.legacy-dialog :deep(.p-button) {
color: white;
}
</style>

View File

@@ -0,0 +1,264 @@
<template>
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center mb-8">
{{ $t('subscription.preview.confirmPlanChange') }}
</h2>
<div
class="flex flex-col justify-between items-stretch mx-auto text-sm h-full"
>
<div>
<!-- Plan Comparison Header -->
<div class="flex items-center gap-4">
<!-- Current Plan -->
<div class="flex flex-col gap-1 w-[250px]">
<span class="text-base-foreground text-sm">
{{ currentTierName }}
</span>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ currentDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<i class="icon-[lucide--component] text-amber-400 text-xs" />
<span
>{{ currentDisplayCredits }}
{{ $t('subscription.perMonth') }}</span
>
</div>
<span class="text-muted-foreground text-sm inline">
{{
$t('subscription.preview.ends', { date: currentPeriodEndDate })
}}
</span>
</div>
<!-- Arrow -->
<i class="pi pi-arrow-right text-muted-foreground w-8 h-8" />
<!-- New Plan -->
<div class="flex flex-col gap-1">
<span class="text-base-foreground text-sm font-semibold">
{{ newTierName }}
</span>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ newDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<i class="icon-[lucide--component] text-amber-400 text-xs" />
<span
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
>
</div>
<span class="text-muted-foreground text-sm">
{{ $t('subscription.preview.starting', { date: effectiveDate }) }}
</span>
</div>
</div>
<!-- Credits Section -->
<div class="flex flex-col gap-3 pt-12 pb-6">
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="font-bold text-base-foreground">
{{ newDisplayCredits }}
</span>
</div>
</div>
</div>
<!-- Proration Section -->
<div
v-if="showProration"
class="flex flex-col gap-2 border-t border-border-subtle pt-6 pb-6"
>
<div
v-if="proratedRefundCents > 0"
class="flex items-center justify-between"
>
<span class="text-muted-foreground">
{{
$t('subscription.preview.proratedRefund', {
plan: currentTierName
})
}}
</span>
<span class="text-muted-foreground">-${{ proratedRefund }}</span>
</div>
<div
v-if="proratedChargeCents > 0"
class="flex items-center justify-between"
>
<span class="text-muted-foreground">
{{
$t('subscription.preview.proratedCharge', { plan: newTierName })
}}
</span>
<span class="text-muted-foreground">${{ proratedCharge }}</span>
</div>
</div>
<!-- Total Due Section -->
<div class="flex flex-col gap-2 border-t border-border-subtle pt-6">
<div class="flex text-base items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.totalDueToday') }}
</span>
<span class="font-bold text-base-foreground">
${{ totalDueToday }}
</span>
</div>
<span class="text-muted-foreground text-sm">
{{
$t('subscription.preview.nextPaymentDue', {
date: nextPaymentDate
})
}}
</span>
</div>
</div>
<!-- Footer -->
<div class="flex flex-col gap-2 pt-8">
<Button
variant="secondary"
size="lg"
class="w-full rounded-lg"
:loading="isLoading"
@click="$emit('confirm')"
>
{{ $t('subscription.preview.confirm') }}
</Button>
<Button
variant="textonly"
class="text-muted-foreground hover:text-base-foreground hover:bg-none text-center cursor-pointer transition-colors text-xs"
@click="$emit('back')"
>
{{ $t('subscription.preview.backToAllPlans') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
interface Props {
previewData: PreviewSubscribeResponse
isLoading?: boolean
}
const { previewData, isLoading = false } = defineProps<Props>()
defineEmits<{
confirm: []
back: []
}>()
const { t, n } = useI18n()
function formatTierName(tier: string): string {
return t(`subscription.tiers.${tier.toLowerCase()}.name`)
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const currentTierName = computed(() =>
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
)
const newTierName = computed(() => formatTierName(previewData.new_plan.tier))
const currentDisplayPrice = computed(() =>
previewData.current_plan
? (previewData.current_plan.price_cents / 100).toFixed(0)
: '0'
)
const newDisplayPrice = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(0)
)
const currentDisplayCredits = computed(() => {
if (!previewData.current_plan) return n(0)
const tierKey = previewData.current_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey))
})
const newDisplayCredits = computed(() => {
const tierKey = previewData.new_plan.tier.toLowerCase() as
| 'standard'
| 'creator'
| 'pro'
return n(getTierCredits(tierKey))
})
const currentPeriodEndDate = computed(() =>
previewData.current_plan?.period_end
? formatDate(previewData.current_plan.period_end)
: ''
)
const effectiveDate = computed(() => formatDate(previewData.effective_at))
const showProration = computed(() => previewData.is_immediate)
const proratedRefundCents = computed(() => {
if (!previewData.current_plan || !previewData.is_immediate) return 0
const chargeToday = previewData.cost_today_cents
const newPlanCost = previewData.new_plan.price_cents
if (chargeToday < newPlanCost) {
return newPlanCost - chargeToday
}
return 0
})
const proratedRefund = computed(() =>
(proratedRefundCents.value / 100).toFixed(2)
)
const proratedChargeCents = computed(() => {
if (!previewData.is_immediate) return 0
return previewData.cost_today_cents
})
const proratedCharge = computed(() =>
(proratedChargeCents.value / 100).toFixed(2)
)
const totalDueToday = computed(() =>
(previewData.cost_today_cents / 100).toFixed(2)
)
const nextPaymentDate = computed(() =>
previewData.new_plan.period_end
? formatDate(previewData.new_plan.period_end)
: formatDate(previewData.effective_at)
)
</script>

View File

@@ -0,0 +1,60 @@
import { computed, ref } from 'vue'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
const plans = ref<Plan[]>([])
const currentPlanSlug = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
export function useBillingPlans() {
async function fetchPlans() {
if (isLoading.value) return
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.getBillingPlans()
plans.value = response.plans
currentPlanSlug.value = response.current_plan_slug ?? null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch plans'
console.error('[useBillingPlans] Failed to fetch plans:', err)
} finally {
isLoading.value = false
}
}
const monthlyPlans = computed(() =>
plans.value.filter((p) => p.duration === 'MONTHLY')
)
const annualPlans = computed(() =>
plans.value.filter((p) => p.duration === 'ANNUAL')
)
function getPlanBySlug(slug: string) {
return plans.value.find((p) => p.slug === slug)
}
function getPlansForTier(tier: Plan['tier']) {
return plans.value.filter((p) => p.tier === tier)
}
const isCurrentPlan = (slug: string) => currentPlanSlug.value === slug
return {
plans,
currentPlanSlug,
isLoading,
error,
monthlyPlans,
annualPlans,
fetchPlans,
getPlanBySlug,
getPlansForTier,
isCurrentPlan
}
}

View File

@@ -20,6 +20,12 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchStatus: mockFetchStatus
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog

View File

@@ -1,7 +1,7 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -15,7 +15,7 @@ export function useSubscriptionActions() {
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus } = useSubscription()
const { fetchStatus } = useBillingContext()
const isLoadingSupport = ref(false)

View File

@@ -1,14 +1,17 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import type * as VueI18nModule from 'vue-i18n'
import * as comfyCredits from '@/base/credits/comfyCredits'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
// Shared mock state (reset in beforeEach)
let mockBillingBalance: {
amountMicros: number
cloudCreditBalanceMicros?: number
prepaidBalanceMicros?: number
} | null = null
let mockBillingIsLoading = false
vi.mock(
'vue-i18n',
@@ -24,92 +27,41 @@ vi.mock(
}
)
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn(() => ({
onAuthStateChanged: vi.fn(),
setPersistence: vi.fn()
}))
}))
vi.mock('firebase/auth', () => ({
onAuthStateChanged: vi.fn(() => {
// Mock the callback to be called immediately for testing
return vi.fn()
}),
onIdTokenChanged: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined),
browserLocalPersistence: {},
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
}
}))
// Mock other dependencies
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
track: vi.fn()
})
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
headers: {}
// Mock useBillingContext - returns computed refs that read from module-level state
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => mockBillingBalance),
isLoading: computed(() => mockBillingIsLoading)
})
}))
describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
beforeEach(() => {
setActivePinia(createPinia())
authStore = useFirebaseAuthStore()
mockBillingBalance = null
mockBillingIsLoading = false
vi.clearAllMocks()
})
describe('totalCredits', () => {
it('should return "0" when balance is null', () => {
authStore.balance = null
mockBillingBalance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0')
})
it('should return "0" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
it('should format amountMicros correctly', () => {
mockBillingBalance = { amountMicros: 100 }
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('211')
})
it('should handle formatting errors by throwing', async () => {
it('should handle formatting errors by throwing', () => {
const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents')
formatSpy.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
mockBillingBalance = { amountMicros: 100 }
const { totalCredits } = useSubscriptionCredits()
expect(() => totalCredits.value).toThrow('Formatting error')
formatSpy.mockRestore()
@@ -117,45 +69,49 @@ describe('useSubscriptionCredits', () => {
})
describe('monthlyBonusCredits', () => {
it('should return "0" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
it('should return "0" when cloudCreditBalanceMicros is missing', () => {
mockBillingBalance = { amountMicros: 100 }
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
it('should format cloudCreditBalanceMicros correctly', () => {
mockBillingBalance = {
amountMicros: 300,
cloudCreditBalanceMicros: 200
}
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('422')
})
})
describe('prepaidCredits', () => {
it('should return "0" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
it('should return "0" when prepaidBalanceMicros is missing', () => {
mockBillingBalance = { amountMicros: 100 }
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
it('should format prepaidBalanceMicros correctly', () => {
mockBillingBalance = {
amountMicros: 500,
prepaidBalanceMicros: 300
}
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('633')
})
})
describe('isLoadingBalance', () => {
it('should reflect authStore.isFetchingBalance', () => {
authStore.isFetchingBalance = true
it('should reflect billingContext.isLoading', () => {
mockBillingIsLoading = true
const { isLoadingBalance } = useSubscriptionCredits()
expect(isLoadingBalance.value).toBe(true)
authStore.isFetchingBalance = false
expect(isLoadingBalance.value).toBe(false)
mockBillingIsLoading = false
// Need to re-get the composable since computed caches the value
const { isLoadingBalance: reloaded } = useSubscriptionCredits()
expect(reloaded.value).toBe(false)
})
})
})

View File

@@ -1,43 +1,54 @@
import { computed } from 'vue'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
/**
* Composable for handling subscription credit calculations and formatting
* Composable for handling subscription credit calculations and formatting.
*
* Uses useBillingContext which automatically selects the correct billing source:
* - If team workspaces feature is disabled: uses legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: uses legacy (/customers)
* - Team workspace: uses workspace (/billing)
*/
/**
* Formats a cent value to display credits.
* Backend returns cents despite the *_micros naming convention.
*/
function formatBalance(maybeCents: number | undefined, locale: string): string {
const cents = maybeCents ?? 0
return formatCreditsFromCents({
cents,
locale,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
}
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const billingContext = useBillingContext()
const { locale } = useI18n()
const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
return amount
}
const totalCredits = computed(() => {
const balance = toValue(billingContext.balance)
return formatBalance(balance?.amountMicros, locale.value)
})
const totalCredits = computed(() =>
formatBalance(authStore.balance?.amount_micros)
)
const monthlyBonusCredits = computed(() => {
const balance = toValue(billingContext.balance)
return formatBalance(balance?.cloudCreditBalanceMicros, locale.value)
})
const monthlyBonusCredits = computed(() =>
formatBalance(authStore.balance?.cloud_credit_balance_micros)
)
const prepaidCredits = computed(() => {
const balance = toValue(billingContext.balance)
return formatBalance(balance?.prepaidBalanceMicros, locale.value)
})
const prepaidCredits = computed(() =>
formatBalance(authStore.balance?.prepaid_balance_micros)
)
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
const isLoadingBalance = computed(() => toValue(billingContext.isLoading))
return {
totalCredits,

View File

@@ -1,36 +1,50 @@
import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const DIALOG_KEY = 'subscription-required'
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
const useWorkspaceVariant =
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
const component = useWorkspaceVariant
? defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContentWorkspace.vue')
)
: defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
)
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
),
component,
props: {
onClose: hide
},
dialogComponentProps: {
style: 'width: min(1328px, 95vw); max-height: 90vh;',
style: 'width: min(1328px, 95vw); max-height: 958px;',
pt: {
root: {
class: 'rounded-2xl bg-transparent'
class: 'rounded-2xl bg-transparent h-full'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
}
}
}

View File

@@ -26,13 +26,14 @@ export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
interface TierFeatures {
customLoRAs: boolean
maxMembers: number
}
const TIER_FEATURES: Record<TierKey, TierFeatures> = {
standard: { customLoRAs: false },
creator: { customLoRAs: true },
pro: { customLoRAs: true },
founder: { customLoRAs: false }
standard: { customLoRAs: false, maxMembers: 1 },
creator: { customLoRAs: true, maxMembers: 5 },
pro: { customLoRAs: true, maxMembers: 20 },
founder: { customLoRAs: false, maxMembers: 1 }
}
export const DEFAULT_TIER_KEY: TierKey = 'standard'

View File

@@ -3,13 +3,13 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -39,7 +39,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -266,8 +266,7 @@ export function useSettingUI(
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : [])
: [])
].map(translateCategory)
}),
// General settings - Profile + all core settings + special panels
@@ -276,7 +275,11 @@ export function useSettingUI(
label: 'General',
children: [
translateCategory(userPanel.node),
...coreSettingCategories.value.map(translateCategory),
...coreSettingCategories.value.slice(0, 1).map(translateCategory),
...(shouldShowSecretsPanel.value
? [translateCategory(secretsPanel.node)]
: []),
...coreSettingCategories.value.slice(1).map(translateCategory),
translateCategory(keybindingPanel.node),
translateCategory(extensionPanel.node),
translateCategory(aboutPanel.node),

View File

@@ -15,6 +15,7 @@ interface Workspace {
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
subscription_tier?: SubscriptionTier
}
export interface Member {
@@ -62,14 +63,6 @@ interface AcceptInviteResponse {
workspace_name: string
}
interface BillingPortalRequest {
return_url: string
}
interface BillingPortalResponse {
billing_portal_url: string
}
interface CreateWorkspacePayload {
name: string
}
@@ -82,6 +75,206 @@ interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}
export type SubscriptionTier =
| 'STANDARD'
| 'CREATOR'
| 'PRO'
| 'FOUNDERS_EDITION'
export type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
type PlanAvailabilityReason =
| 'same_plan'
| 'incompatible_transition'
| 'requires_team'
| 'requires_personal'
| 'exceeds_max_seats'
interface PlanAvailability {
available: boolean
reason?: PlanAvailabilityReason
}
interface PlanSeatSummary {
seat_count: number
total_cost_cents: number
total_credits_cents: number
}
export interface Plan {
slug: string
tier: SubscriptionTier
duration: SubscriptionDuration
price_cents: number
credits_cents: number
max_seats: number
availability: PlanAvailability
seat_summary: PlanSeatSummary
}
interface BillingPlansResponse {
current_plan_slug?: string
plans: Plan[]
}
type SubscriptionTransitionType =
| 'new_subscription'
| 'upgrade'
| 'downgrade'
| 'duration_change'
interface PreviewSubscribeRequest {
plan_slug: string
}
interface SubscribeRequest {
plan_slug: string
idempotency_key?: string
return_url?: string
cancel_url?: string
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
export interface SubscribeResponse {
billing_op_id: string
status: SubscribeStatus
effective_at?: string
payment_method_url?: string
}
interface CancelSubscriptionRequest {
idempotency_key?: string
}
interface CancelSubscriptionResponse {
billing_op_id: string
cancel_at: string
}
interface ResubscribeRequest {
idempotency_key?: string
}
interface ResubscribeResponse {
billing_op_id: string
status: 'active'
message?: string
}
interface PaymentPortalRequest {
return_url?: string
}
interface PaymentPortalResponse {
url: string
}
interface PreviewPlanInfo {
slug: string
tier: SubscriptionTier
duration: SubscriptionDuration
price_cents: number
credits_cents: number
seat_summary: PlanSeatSummary
period_start?: string
period_end?: string
}
export interface PreviewSubscribeResponse {
allowed: boolean
reason?: string
transition_type: SubscriptionTransitionType
effective_at: string
is_immediate: boolean
cost_today_cents: number
cost_next_period_cents: number
credits_today_cents: number
credits_next_period_cents: number
current_plan?: PreviewPlanInfo
new_plan: PreviewPlanInfo
}
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
type BillingStatus =
| 'awaiting_payment_method'
| 'pending_payment'
| 'paid'
| 'payment_failed'
| 'inactive'
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
subscription_tier?: SubscriptionTier
subscription_duration?: SubscriptionDuration
plan_slug?: string
billing_status?: BillingStatus
has_funds: boolean
cancel_at?: string
}
export interface BillingBalanceResponse {
amount_micros: number
prepaid_balance_micros?: number
cloud_credit_balance_micros?: number
pending_charges_micros?: number
effective_balance_micros?: number
currency: string
}
interface CreateTopupRequest {
amount_cents: number
idempotency_key?: string
}
type TopupStatus = 'pending' | 'completed' | 'failed'
interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
}
interface TopupStatusResponse {
topup_id: string
status: TopupStatus
amount_cents: number
error_message?: string
created_at: string
completed_at?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
export interface BillingOpStatusResponse {
id: string
status: BillingOpStatus
error_message?: string
started_at: string
completed_at?: string
}
interface BillingEvent {
event_type: string
event_id: string
params?: Record<string, unknown>
createdAt: string
}
interface BillingEventsResponse {
total: number
events: BillingEvent[]
page: number
limit: number
totalPages: number
}
interface GetBillingEventsParams {
page?: number
limit?: number
}
class WorkspaceApiError extends Error {
constructor(
message: string,
@@ -326,19 +519,230 @@ export const workspaceApi = {
},
/**
* Access the billing portal for the current workspace.
* POST /api/billing/portal
* Get billing status for the current workspace
* GET /api/billing/status
*/
async accessBillingPortal(
returnUrl?: string
): Promise<BillingPortalResponse> {
async getBillingStatus(): Promise<BillingStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<BillingPortalResponse>(
api.apiURL('/billing/portal'),
const response = await workspaceApiClient.get<BillingStatusResponse>(
api.apiURL('/billing/status'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get credit balance for the current workspace
* GET /api/billing/balance
*/
async getBillingBalance(): Promise<BillingBalanceResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingBalanceResponse>(
api.apiURL('/billing/balance'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get available subscription plans
* GET /api/billing/plans
*/
async getBillingPlans(): Promise<BillingPlansResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingPlansResponse>(
api.apiURL('/billing/plans'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Preview subscription change
* POST /api/billing/preview-subscribe
*/
async previewSubscribe(planSlug: string): Promise<PreviewSubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PreviewSubscribeResponse>(
api.apiURL('/billing/preview-subscribe'),
{ plan_slug: planSlug } satisfies PreviewSubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Subscribe to a billing plan
* POST /api/billing/subscribe
*/
async subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<SubscribeResponse>(
api.apiURL('/billing/subscribe'),
{
return_url: returnUrl ?? window.location.href
} satisfies BillingPortalRequest,
plan_slug: planSlug,
return_url: returnUrl,
cancel_url: cancelUrl
} satisfies SubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Cancel current subscription
* POST /api/billing/subscription/cancel
*/
async cancelSubscription(
idempotencyKey?: string
): Promise<CancelSubscriptionResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response =
await workspaceApiClient.post<CancelSubscriptionResponse>(
api.apiURL('/billing/subscription/cancel'),
{
idempotency_key: idempotencyKey
} satisfies CancelSubscriptionRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Resubscribe (undo cancel) before period ends
* POST /api/billing/subscription/resubscribe
*/
async resubscribe(idempotencyKey?: string): Promise<ResubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<ResubscribeResponse>(
api.apiURL('/billing/subscription/resubscribe'),
{ idempotency_key: idempotencyKey } satisfies ResubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get Stripe payment portal URL for managing payment methods
* POST /api/billing/payment-portal
*/
async getPaymentPortalUrl(
returnUrl?: string
): Promise<PaymentPortalResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PaymentPortalResponse>(
api.apiURL('/billing/payment-portal'),
{ return_url: returnUrl } satisfies PaymentPortalRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create a credit top-up
* POST /api/billing/topup
*/
async createTopup(
amountCents: number,
idempotencyKey?: string
): Promise<CreateTopupResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<CreateTopupResponse>(
api.apiURL('/billing/topup'),
{
amount_cents: amountCents,
idempotency_key: idempotencyKey
} satisfies CreateTopupRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get top-up status
* GET /api/billing/topup/:id
*/
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<TopupStatusResponse>(
api.apiURL(`/billing/topup/${topupId}`),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing events
* GET /api/billing/events
*/
async getBillingEvents(
params?: GetBillingEventsParams
): Promise<BillingEventsResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingEventsResponse>(
api.apiURL('/billing/events'),
{ headers, params }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing operation status
* GET /api/billing/ops/:id
*/
async getBillingOpStatus(opId: string): Promise<BillingOpStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingOpStatusResponse>(
api.apiURL(`/billing/ops/${opId}`),
{ headers }
)
return response.data

View File

@@ -10,6 +10,7 @@ import type {
ListMembersParams,
Member,
PendingInvite as ApiPendingInvite,
SubscriptionTier,
WorkspaceWithRole
} from '../api/workspaceApi'
import { workspaceApi } from '../api/workspaceApi'
@@ -30,11 +31,12 @@ export interface PendingInvite {
expiryDate: Date
}
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
type SubscriptionPlan = string | null
interface WorkspaceState extends WorkspaceWithRole {
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
subscriptionTier: SubscriptionTier | null
members: WorkspaceMember[]
pendingInvites: PendingInvite[]
}
@@ -65,8 +67,10 @@ function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return {
...workspace,
// Personal workspaces use user-scoped subscription from useSubscription()
isSubscribed: workspace.type === 'personal',
isSubscribed:
workspace.type === 'personal' || !!workspace.subscription_tier,
subscriptionPlan: null,
subscriptionTier: workspace.subscription_tier ?? null,
members: [],
pendingInvites: []
}
@@ -561,10 +565,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
}
}
// ════════════════════════════════════════════════════════════
// INVITE LINK HELPERS
// ════════════════════════════════════════════════════════════
function buildInviteLink(token: string): string {
const baseUrl = window.location.origin
return `${baseUrl}?invite=${encodeURIComponent(token)}`
@@ -671,6 +671,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
copyInviteLink,
// Subscription
subscribeWorkspace
subscribeWorkspace,
updateActiveWorkspace
}
})

View File

@@ -9,8 +9,8 @@ import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -29,7 +29,7 @@ import { cn } from '@/utils/tailwindUtil'
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore()
const props = defineProps<{

View File

@@ -48,7 +48,7 @@ import {
DOMWidgetImpl
} from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphService } from '@/services/subgraphService'
@@ -681,7 +681,7 @@ export class ComfyApp {
'Payment Required: Please add credits to your account to use this node.'
)
) {
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
if (isActiveSubscription.value) {
useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true

View File

@@ -10,14 +10,15 @@ import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.v
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue'
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
import TopUpCreditsDialogContentWorkspace from '@/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue'
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type {
@@ -343,12 +344,17 @@ export const useDialogService = () => {
function showTopUpCreditsDialog(options?: {
isInsufficientCredits?: boolean
}) {
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription, type } = useBillingContext()
if (!isActiveSubscription.value) return
const component =
type.value === 'workspace'
? TopUpCreditsDialogContentWorkspace
: TopUpCreditsDialogContentLegacy
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,
component,
props: options,
dialogComponentProps: {
headless: true,
@@ -647,6 +653,23 @@ export const useDialogService = () => {
})
}
async function showCancelSubscriptionDialog(cancelAt?: string) {
const { default: component } =
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
return dialogStore.showDialog({
key: 'cancel-subscription',
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -672,6 +695,7 @@ export const useDialogService = () => {
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showBillingComingSoonDialog
showBillingComingSoonDialog,
showCancelSubscriptionDialog
}
}

View File

@@ -0,0 +1,504 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
import type { BillingOpStatusResponse } from '@/platform/workspace/api/workspaceApi'
const mockFetchStatus = vi.fn()
const mockFetchBalance = vi.fn()
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
})
}))
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingOpStatus: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSettingsDialog: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from './billingOperationStore'
describe('billingOperationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('startOperation', () => {
it('creates a pending operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.operations.size).toBe(1)
const operation = store.getOperation('op-1')
expect(operation).toBeDefined()
expect(operation?.status).toBe('pending')
expect(operation?.type).toBe('subscription')
expect(store.hasPendingOperations).toBe(true)
})
it('does not create duplicate operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-1', 'topup')
expect(store.operations.size).toBe(1)
expect(store.getOperation('op-1')?.type).toBe('subscription')
})
it('shows immediate processing toast for subscription operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.subscriptionProcessing',
group: 'billing-operation'
})
})
it('shows immediate processing toast for topup operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.topupProcessing',
group: 'billing-operation'
})
})
})
describe('polling success', () => {
it('updates status and shows toast on success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('succeeded')
expect(store.hasPendingOperations).toBe(false)
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockFetchBalance).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.subscriptionSuccess',
life: 5000
})
})
it('shows topup success message for topup operations', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.topupSuccess',
life: 5000
})
})
it('removes the received toast when operation succeeds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
const receivedToast = mockToastAdd.mock.calls[0][0]
await vi.advanceTimersByTimeAsync(0)
expect(mockToastRemove).toHaveBeenCalledWith(receivedToast)
})
})
describe('polling failure', () => {
it('updates status and shows error toast on failure', async () => {
const errorMessage = 'Payment declined'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
error_message: errorMessage,
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('failed')
expect(operation?.errorMessage).toBe(errorMessage)
expect(store.hasPendingOperations).toBe(false)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionFailed',
detail: errorMessage,
life: 5000
})
})
it('uses default message when no error_message in response', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupFailed',
detail: undefined,
life: 5000
})
})
})
describe('polling timeout', () => {
it('times out after 2 minutes and shows error toast', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('timeout')
expect(store.hasPendingOperations).toBe(false)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionTimeout',
life: 5000
})
})
it('shows topup timeout message for topup operations', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout',
life: 5000
})
})
})
describe('exponential backoff', () => {
it('uses exponential backoff for polling intervals', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1500)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2250)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
})
it('caps polling interval at 8 seconds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(60_000)
const callCountBefore = vi.mocked(workspaceApi.getBillingOpStatus).mock
.calls.length
await vi.advanceTimersByTimeAsync(8000)
expect(
vi.mocked(workspaceApi.getBillingOpStatus).mock.calls.length
).toBeGreaterThan(callCountBefore)
})
})
describe('network errors', () => {
it('continues polling on network errors', async () => {
vi.mocked(workspaceApi.getBillingOpStatus)
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
} satisfies BillingOpStatusResponse)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.getOperation('op-1')?.status).toBe('pending')
await vi.advanceTimersByTimeAsync(1500)
expect(store.getOperation('op-1')?.status).toBe('pending')
await vi.advanceTimersByTimeAsync(2250)
expect(store.getOperation('op-1')?.status).toBe('succeeded')
})
})
describe('clearOperation', () => {
it('removes operation from the store', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.operations.size).toBe(1)
store.clearOperation('op-1')
expect(store.operations.size).toBe(0)
expect(store.getOperation('op-1')).toBeUndefined()
})
})
describe('multiple operations', () => {
it('can track multiple operations concurrently', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async (opId: string) => ({
id: opId,
status: 'pending' as const,
started_at: new Date().toISOString()
})
)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-2', 'topup')
expect(store.operations.size).toBe(2)
expect(store.hasPendingOperations).toBe(true)
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async (opId: string) => ({
id: opId,
status:
opId === 'op-1' ? ('succeeded' as const) : ('pending' as const),
started_at: new Date().toISOString()
})
)
await vi.advanceTimersByTimeAsync(1500)
expect(store.getOperation('op-1')?.status).toBe('succeeded')
expect(store.getOperation('op-2')?.status).toBe('pending')
expect(store.hasPendingOperations).toBe(true)
})
})
describe('isSettingUp', () => {
it('returns true when there is a pending subscription operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.isSettingUp).toBe(true)
})
it('returns false when there is no pending subscription operation', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.isSettingUp).toBe(false)
})
it('returns false when only topup operations are pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(store.isSettingUp).toBe(false)
})
})
describe('isAddingCredits', () => {
it('returns true when there is a pending topup operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(store.isAddingCredits).toBe(true)
})
it('returns false when there is no pending topup operation', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(store.isAddingCredits).toBe(false)
})
it('returns false when only subscription operations are pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.isAddingCredits).toBe(false)
})
})
})

View File

@@ -0,0 +1,244 @@
import type { ToastMessageOptions } from 'primevue/toast'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const INITIAL_INTERVAL_MS = 1000
const MAX_INTERVAL_MS = 8000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000 // 2 minutes
type OperationType = 'subscription' | 'topup'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
opId: string
type: OperationType
status: OperationStatus
errorMessage: string | null
startedAt: number
}
export const useBillingOperationStore = defineStore('billingOperation', () => {
const operations = ref<Map<string, BillingOperation>>(new Map())
const timeouts = new Map<string, ReturnType<typeof setTimeout>>()
const intervals = new Map<string, number>()
const receivedToasts = new Map<string, ToastMessageOptions>()
const hasPendingOperations = computed(() =>
[...operations.value.values()].some((op) => op.status === 'pending')
)
const isSettingUp = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'subscription'
)
)
const isAddingCredits = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'topup'
)
)
function getOperation(opId: string) {
return operations.value.get(opId)
}
function startOperation(opId: string, type: OperationType) {
if (operations.value.has(opId)) return
const operation: BillingOperation = {
opId,
type,
status: 'pending',
errorMessage: null,
startedAt: Date.now()
}
operations.value = new Map(operations.value).set(opId, operation)
intervals.set(opId, INITIAL_INTERVAL_MS)
// Show immediate feedback toast (persists until operation completes)
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
summary: t(messageKey),
group: 'billing-operation'
}
receivedToasts.set(opId, toastMessage)
useToastStore().add(toastMessage)
void poll(opId)
}
async function poll(opId: string) {
const operation = operations.value.get(opId)
if (!operation || operation.status !== 'pending') return
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
handleTimeout(opId)
return
}
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
await handleSuccess(opId)
return
}
if (response.status === 'failed') {
handleFailure(opId, response.error_message ?? null)
return
}
scheduleNextPoll(opId)
} catch {
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
handleTimeout(opId)
return
}
scheduleNextPoll(opId)
}
}
function scheduleNextPoll(opId: string) {
const currentInterval = intervals.get(opId) ?? INITIAL_INTERVAL_MS
const nextInterval = Math.min(
currentInterval * BACKOFF_MULTIPLIER,
MAX_INTERVAL_MS
)
intervals.set(opId, nextInterval)
const timeoutId = setTimeout(() => void poll(opId), nextInterval)
timeouts.set(opId, timeoutId)
}
async function handleSuccess(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
updateOperationStatus(opId, 'succeeded', null)
cleanup(opId)
const billingContext = useBillingContext()
await Promise.all([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
// Close any open billing dialogs and show settings
const dialogStore = useDialogStore()
dialogStore.closeDialog({ key: 'subscription-required' })
dialogStore.closeDialog({ key: 'top-up-credits' })
void useDialogService().showSettingsDialog('workspace')
const toastStore = useToastStore()
const messageKey =
operation.type === 'subscription'
? 'billingOperation.subscriptionSuccess'
: 'billingOperation.topupSuccess'
toastStore.add({
severity: 'success',
summary: t(messageKey),
life: 5000
})
}
function handleFailure(opId: string, errorMessage: string | null) {
const operation = operations.value.get(opId)
if (!operation) return
const defaultMessage =
operation.type === 'subscription'
? t('billingOperation.subscriptionFailed')
: t('billingOperation.topupFailed')
updateOperationStatus(opId, 'failed', errorMessage ?? defaultMessage)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined,
life: 5000
})
}
function handleTimeout(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
const message =
operation.type === 'subscription'
? t('billingOperation.subscriptionTimeout')
: t('billingOperation.topupTimeout')
updateOperationStatus(opId, 'timeout', message)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: message,
life: 5000
})
}
function updateOperationStatus(
opId: string,
status: OperationStatus,
errorMessage: string | null
) {
const operation = operations.value.get(opId)
if (!operation) return
const updated = { ...operation, status, errorMessage }
operations.value = new Map(operations.value).set(opId, updated)
}
function cleanup(opId: string) {
const timeoutId = timeouts.get(opId)
if (timeoutId) {
clearTimeout(timeoutId)
timeouts.delete(opId)
}
intervals.delete(opId)
// Remove the "received" toast
const receivedToast = receivedToasts.get(opId)
if (receivedToast) {
useToastStore().remove(receivedToast)
receivedToasts.delete(opId)
}
}
function clearOperation(opId: string) {
cleanup(opId)
const newMap = new Map(operations.value)
newMap.delete(opId)
operations.value = newMap
}
return {
operations,
hasPendingOperations,
isSettingUp,
isAddingCredits,
getOperation,
startOperation,
clearOperation
}
})

View File

@@ -277,7 +277,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}