mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 01:20:03 +00:00
Feat/workspaces 6 billing (#8508)
## Summary Implements billing infrastructure for team workspaces, separate from legacy personal billing. ## Changes - **Billing abstraction**: New `useBillingContext` composable that switches between legacy (personal) and workspace billing based on context - **Workspace subscription flows**: Pricing tables, plan transitions, cancellation dialogs, and payment preview components for workspace billing - **Top-up credits**: Workspace-specific top-up dialog with polling for payment confirmation - **Workspace API**: Extended with billing endpoints (subscriptions, invoices, payment methods, credits top-up) - **Workspace switcher**: Now displays tier badges for each workspace - **Subscribe polling**: Added polling mechanisms (`useSubscribePolling`, `useTopupPolling`) for async payment flows ## Review Focus - Billing flow correctness for workspace vs legacy contexts - Polling timeout and error handling in payment flows ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8508-Feat-workspaces-6-billing-2f96d73d365081f69f65c1ddf369010d) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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', () => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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="'→'"></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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { 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',
|
||||
@@ -25,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(createTestingPinia({ stubActions: false }))
|
||||
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()
|
||||
@@ -118,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user