mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
subscription page (#6064)
Summary Implements cloud subscription management UI and flow for ComfyUI Cloud users. Core Features: - Subscription Status Tracking: Global reactive state management for subscription status across all components using shared subscriptionStatus ref - Subscribe to Run Button: Replaces the Run button in the actionbar with a "Subscribe to Run" button for users without active subscriptions - Subscription Required Dialog: Modal dialog with subscription benefits, pricing, and checkout flow with video background - Subscription Settings Panel: New settings panel showing subscription status, renewal date, and quick access to billing management - Auto-detection & Polling: Automatically polls subscription status after checkout completion and syncs state across the application https://github.com/user-attachments/assets/f41b8e6a-5845-48a7-8169-3a6fc0d2e5c8 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6064-subscription-page-28d6d73d36508135a2a0fe7c94b40852) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
BIN
public/assets/images/cloud-subscription.webm
Normal file
BIN
public/assets/images/cloud-subscription.webm
Normal file
Binary file not shown.
@@ -34,7 +34,8 @@
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ComfyQueueButton />
|
||||
|
||||
<ComfyRunButton />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -55,7 +56,7 @@ import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
} from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import BatchCountEdit from './BatchCountEdit.vue'
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
|
||||
7
src/components/actionbar/ComfyRunButton/index.ts
Normal file
7
src/components/actionbar/ComfyRunButton/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
export default isCloud
|
||||
? defineAsyncComponent(() => import('./CloudRunButtonWrapper.vue'))
|
||||
: defineAsyncComponent(() => import('./ComfyQueueButton.vue'))
|
||||
@@ -1,12 +1,19 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
|
||||
<div
|
||||
class="flex items-center gap-2 bg-comfy-menu-secondary"
|
||||
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
|
||||
>
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</div>
|
||||
<div class="font-inter text-sm font-extrabold text-slate-100">
|
||||
<div
|
||||
class="font-inter text-sm font-extrabold text-slate-100"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ badge.text }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,7 +21,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
defineProps<{
|
||||
badge: TopbarBadge
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
badge: TopbarBadge
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
labelClass?: string
|
||||
textClass?: string
|
||||
}>(),
|
||||
{
|
||||
reverseOrder: false,
|
||||
noPadding: false,
|
||||
labelClass: '',
|
||||
textClass: ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
v-for="badge in topbarBadgeStore.badges"
|
||||
:key="badge.text"
|
||||
:badge
|
||||
:reverse-order="reverseOrder"
|
||||
:no-padding="noPadding"
|
||||
:label-class="labelClass"
|
||||
:text-class="textClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,5 +17,20 @@ import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
reverseOrder?: boolean
|
||||
noPadding?: boolean
|
||||
labelClass?: string
|
||||
textClass?: string
|
||||
}>(),
|
||||
{
|
||||
reverseOrder: false,
|
||||
noPadding: false,
|
||||
labelClass: '',
|
||||
textClass: ''
|
||||
}
|
||||
)
|
||||
|
||||
const topbarBadgeStore = useTopbarBadgeStore()
|
||||
</script>
|
||||
|
||||
@@ -208,6 +208,7 @@ export const useFirebaseAuthActions = () => {
|
||||
signUpWithEmail,
|
||||
updatePassword,
|
||||
deleteAccount,
|
||||
accessError
|
||||
accessError,
|
||||
reportError
|
||||
}
|
||||
}
|
||||
|
||||
1
src/config/subscriptionPricesConfig.ts
Normal file
1
src/config/subscriptionPricesConfig.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MONTHLY_SUBSCRIPTION_PRICE = 20
|
||||
24
src/extensions/core/cloudSubscription.ts
Normal file
24
src/extensions/core/cloudSubscription.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CloudSubscription',
|
||||
|
||||
setup: async () => {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { requireActiveSubscription } = useSubscription()
|
||||
|
||||
const checkSubscriptionStatus = () => {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
void requireActiveSubscription()
|
||||
}
|
||||
|
||||
watch(() => isLoggedIn.value, checkSubscriptionStatus, {
|
||||
immediate: true
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -26,4 +26,5 @@ import './widgetInputs'
|
||||
|
||||
if (isCloud) {
|
||||
import('./cloudBadge')
|
||||
import('./cloudSubscription')
|
||||
}
|
||||
|
||||
@@ -1337,7 +1337,8 @@
|
||||
"Notification Preferences": "Notification Preferences",
|
||||
"3DViewer": "3DViewer",
|
||||
"Vue Nodes": "Vue Nodes",
|
||||
"Canvas Navigation": "Canvas Navigation"
|
||||
"Canvas Navigation": "Canvas Navigation",
|
||||
"PlanCredits": "Plan & Credits"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -1777,6 +1778,8 @@
|
||||
"failedToInitiateCreditPurchase": "Failed to initiate credit purchase: {error}",
|
||||
"failedToAccessBillingPortal": "Failed to access billing portal: {error}",
|
||||
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
|
||||
"failedToFetchSubscription": "Failed to fetch subscription status: {error}",
|
||||
"failedToInitiateSubscription": "Failed to initiate subscription: {error}",
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
|
||||
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
|
||||
"nothingSelected": "Nothing selected",
|
||||
@@ -1923,6 +1926,35 @@
|
||||
"added": "Added",
|
||||
"accountInitialized": "Account initialized"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subscription",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"apiNodesBalance": "\"API Nodes\" Credit Balance",
|
||||
"apiNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
"viewUsageHistory": "View usage history",
|
||||
"addApiCredits": "Add API credits",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"viewMoreDetails": "View more details",
|
||||
"learnMore": "Learn more",
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
"benefits": {
|
||||
"benefit1": "$10 in monthly credits for API models — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"required": {
|
||||
"title": "Subscribe to",
|
||||
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
|
||||
"subscribe": "Subscribe"
|
||||
},
|
||||
"subscribeToRun": "Subscribe to Run",
|
||||
"subscribeNow": "Subscribe Now"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "User Settings",
|
||||
"name": "Name",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<Button
|
||||
:label="label || $t('subscription.required.subscribe')"
|
||||
:size="size"
|
||||
:class="buttonClass"
|
||||
:loading="isLoading"
|
||||
:disabled="isPolling"
|
||||
severity="primary"
|
||||
@click="handleSubscribe"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
size?: 'small' | 'large'
|
||||
buttonClass?: string
|
||||
}>(),
|
||||
{
|
||||
size: 'large',
|
||||
buttonClass: 'w-full font-bold'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
let pollInterval: number | null = null
|
||||
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await subscribe()
|
||||
|
||||
startPollingSubscriptionStatus()
|
||||
} catch (error) {
|
||||
console.error('[SubscribeButton] Error initiating subscription:', error)
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: $t('subscription.subscribeToRun'),
|
||||
showDelay: 600
|
||||
}"
|
||||
class="subscribe-to-run-button"
|
||||
:label="$t('subscription.subscribeToRun')"
|
||||
icon="pi pi-lock"
|
||||
severity="primary"
|
||||
size="small"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="showSubscriptionDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const { showSubscriptionDialog } = useSubscription()
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-sm" />
|
||||
<span class="text-sm">
|
||||
{{ $t('subscription.benefits.benefit1') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-sm" />
|
||||
<span class="text-sm">
|
||||
{{ $t('subscription.benefits.benefit2') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('subscription.viewMoreDetails')"
|
||||
text
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="left"
|
||||
size="small"
|
||||
class="self-start !p-0 text-sm hover:!bg-transparent [&]:!text-[inherit]"
|
||||
@click="handleViewMoreDetails"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const handleViewMoreDetails = () => {
|
||||
window.open('https://www.comfy.org/cloud', '_blank')
|
||||
}
|
||||
</script>
|
||||
276
src/platform/cloud/subscription/components/SubscriptionPanel.vue
Normal file
276
src/platform/cloud/subscription/components/SubscriptionPanel.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<TabPanel value="PlanCredits" class="subscription-container h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-2xl">
|
||||
{{ $t('subscription.title') }}
|
||||
</h2>
|
||||
<TopbarBadges reverse-order />
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-lg border border-charcoal-400 p-4">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold">{{
|
||||
formattedMonthlyPrice
|
||||
}}</span>
|
||||
<span>{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div v-if="isActiveSubscription" class="text-xs text-muted">
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
:label="$t('subscription.manageSubscription')"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
@click="manageSubscription"
|
||||
/>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="small"
|
||||
button-class="text-xs"
|
||||
@subscribed="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 rounded-lg pt-10 lg:grid-cols-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm">
|
||||
{{ $t('subscription.apiNodesBalance') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="text-xs text-muted">
|
||||
{{ $t('subscription.apiNodesDescription') }}
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-question-circle"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-lg border p-4 dark-theme:border-0 dark-theme:bg-charcoal-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<div class="text-2xl font-bold">${{ totalCredits }}</div>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-sync"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="latestEvents.length > 0"
|
||||
class="flex flex-col gap-2 pt-3 text-xs"
|
||||
>
|
||||
<div
|
||||
v-for="event in latestEvents"
|
||||
:key="event.event_id"
|
||||
class="flex items-center justify-between py-1"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium">
|
||||
{{
|
||||
event.event_type
|
||||
? customerEventService.formatEventType(
|
||||
event.event_type
|
||||
)
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
{{
|
||||
event.createdAt
|
||||
? customerEventService.formatDate(event.createdAt)
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="event.params?.amount !== undefined"
|
||||
class="font-bold"
|
||||
>
|
||||
${{
|
||||
customerEventService.formatAmount(
|
||||
event.params.amount as number
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
:label="$t('subscription.viewUsageHistory')"
|
||||
text
|
||||
severity="secondary"
|
||||
class="p-0 text-xs text-muted"
|
||||
@click="handleViewUsageHistory"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.addApiCredits')"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
@click="handleAddApiCredits"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<SubscriptionBenefits />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-charcoal-400 pt-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:label="$t('subscription.learnMore')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
class="text-xs"
|
||||
@click="handleLearnMore"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.messageSupport')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-comment"
|
||||
class="text-xs"
|
||||
@click="handleMessageSupport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('subscription.invoiceHistory')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="right"
|
||||
class="text-xs"
|
||||
@click="handleInvoiceHistory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
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 type { AuditLog } from '@/services/customerEventsService'
|
||||
import { useCustomerEventsService } from '@/services/customerEventsService'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
const dialogService = useDialogService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
formattedRenewalDate,
|
||||
formattedMonthlyPrice,
|
||||
manageSubscription,
|
||||
handleViewUsageHistory,
|
||||
handleLearnMore,
|
||||
handleInvoiceHistory,
|
||||
fetchStatus
|
||||
} = useSubscription()
|
||||
|
||||
const latestEvents = ref<AuditLog[]>([])
|
||||
|
||||
const totalCredits = computed(() => {
|
||||
if (!authStore.balance) return '0.00'
|
||||
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
|
||||
})
|
||||
|
||||
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const fetchLatestEvents = async () => {
|
||||
try {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
page: 1,
|
||||
limit: 2
|
||||
})
|
||||
if (response?.events) {
|
||||
latestEvents.value = response.events
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SubscriptionPanel] Error fetching latest events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void handleRefresh()
|
||||
})
|
||||
|
||||
const handleAddApiCredits = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([
|
||||
authActions.fetchBalance(),
|
||||
fetchStatus(),
|
||||
fetchLatestEvents()
|
||||
])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="grid h-full grid-cols-5 px-10 pb-10">
|
||||
<div
|
||||
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
|
||||
>
|
||||
<video
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="h-full min-w-[125%] object-cover"
|
||||
style="margin-left: -20%"
|
||||
>
|
||||
<source
|
||||
src="/assets/images/cloud-subscription.webm"
|
||||
type="video/webm"
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex flex-col justify-between pl-8">
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.required.title') }}
|
||||
</div>
|
||||
<TopbarBadges
|
||||
reverse-order
|
||||
no-padding
|
||||
text-class="!text-sm !font-normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-bold">{{ formattedMonthlyPrice }}</span>
|
||||
<span class="text-xl">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubscriptionBenefits class="mt-6 text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<SubscribeButton @subscribed="handleSubscribed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.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'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { formattedMonthlyPrice } = useSubscription()
|
||||
|
||||
const handleSubscribed = () => {
|
||||
emit('close', true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.p-button) {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
208
src/platform/cloud/subscription/composables/useSubscription.ts
Normal file
208
src/platform/cloud/subscription/composables/useSubscription.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
|
||||
interface CloudSubscriptionCheckoutResponse {
|
||||
checkout_url: string
|
||||
}
|
||||
|
||||
interface CloudSubscriptionStatusResponse {
|
||||
is_active: boolean
|
||||
subscription_id: string
|
||||
renewal_date: string
|
||||
}
|
||||
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => {
|
||||
if (!isCloud) return true
|
||||
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
|
||||
let isWatchSetup = false
|
||||
|
||||
export function useSubscription() {
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { reportError } = useFirebaseAuthActions()
|
||||
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
|
||||
const renewalDate = new Date(subscriptionStatus.value.renewal_date)
|
||||
|
||||
return renewalDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const formattedMonthlyPrice = computed(
|
||||
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
|
||||
)
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(async () => {
|
||||
return await fetchSubscriptionStatus()
|
||||
}, reportError)
|
||||
|
||||
const subscribe = wrapWithErrorHandlingAsync(async () => {
|
||||
const response = await initiateSubscriptionCheckout()
|
||||
|
||||
if (!response.checkout_url) {
|
||||
throw new Error(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: 'No checkout URL returned'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = () => {
|
||||
dialogService.showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
const manageSubscription = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const requireActiveSubscription = async (): Promise<void> => {
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewUsageHistory = () => {
|
||||
window.open('https://platform.comfy.org/profile/usage', '_blank')
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
window.open('https://docs.comfy.org', '_blank')
|
||||
}
|
||||
|
||||
const handleInvoiceHistory = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current cloud subscription status for the authenticated user
|
||||
* @returns Subscription status or null if no subscription exists
|
||||
*/
|
||||
const fetchSubscriptionStatus =
|
||||
async (): Promise<CloudSubscriptionStatusResponse | null> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
|
||||
{
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.failedToFetchSubscription', {
|
||||
error: errorData.message
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
return statusData
|
||||
}
|
||||
|
||||
if (!isWatchSetup) {
|
||||
isWatchSetup = true
|
||||
watch(
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn) {
|
||||
await fetchSubscriptionStatus()
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
const initiateSubscriptionCheckout =
|
||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${COMFY_API_BASE_URL}/customers/cloud-subscription-checkout`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: errorData.message
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isActiveSubscription,
|
||||
formattedRenewalDate,
|
||||
formattedMonthlyPrice,
|
||||
|
||||
// Actions
|
||||
subscribe,
|
||||
fetchStatus,
|
||||
showSubscriptionDialog,
|
||||
manageSubscription,
|
||||
requireActiveSubscription,
|
||||
handleViewUsageHistory,
|
||||
handleLearnMore,
|
||||
handleInvoiceHistory
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
@@ -23,6 +24,7 @@ export function useSettingUI(
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
@@ -78,6 +80,22 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
const subscriptionPanel: SettingPanelItem | null = !isCloud
|
||||
? null
|
||||
: {
|
||||
node: {
|
||||
key: 'subscription',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'user',
|
||||
@@ -129,7 +147,8 @@ export function useSettingUI(
|
||||
userPanel,
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : [])
|
||||
...(isElectron() ? [serverConfigPanel] : []),
|
||||
...(isCloud && subscriptionPanel ? [subscriptionPanel] : [])
|
||||
].filter((panel) => panel.component)
|
||||
)
|
||||
|
||||
@@ -155,13 +174,16 @@ export function useSettingUI(
|
||||
})
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Account settings - only show credits when user is authenticated
|
||||
// Account settings - show different panels based on distribution and auth state
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account',
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value ? [creditsPanel.node] : [])
|
||||
...(isLoggedIn.value && isCloud && subscriptionPanel
|
||||
? [subscriptionPanel.node]
|
||||
: []),
|
||||
...(isLoggedIn.value && !isCloud ? [creditsPanel.node] : [])
|
||||
].map(translateCategory)
|
||||
},
|
||||
// Normal settings stored in the settingStore
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
|
||||
@@ -13,6 +14,7 @@ import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordCon
|
||||
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -485,6 +487,37 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function showSubscriptionRequiredDialog() {
|
||||
if (!isCloud) {
|
||||
return
|
||||
}
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: 'subscription-required',
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
'@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue'
|
||||
)
|
||||
),
|
||||
props: {
|
||||
onClose: () => {
|
||||
dialogStore.closeDialog({ key: 'subscription-required' })
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
closable: true,
|
||||
style: 'width: 700px;',
|
||||
pt: {
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: {
|
||||
class: 'overflow-hidden !p-0 !m-0'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
@@ -495,6 +528,7 @@ export const useDialogService = () => {
|
||||
showManagerProgressDialog,
|
||||
showApiNodesSignInDialog,
|
||||
showSignInDialog,
|
||||
showSubscriptionRequiredDialog,
|
||||
showTopUpCreditsDialog,
|
||||
showUpdatePasswordDialog,
|
||||
showExtensionDialog,
|
||||
|
||||
@@ -39,7 +39,7 @@ type AccessBillingPortalResponse =
|
||||
type AccessBillingPortalReqBody =
|
||||
operations['AccessBillingPortal']['requestBody']
|
||||
|
||||
class FirebaseAuthStoreError extends Error {
|
||||
export class FirebaseAuthStoreError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FirebaseAuthStoreError'
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
// Create mocks
|
||||
const mockIsLoggedIn = ref(false)
|
||||
const mockReportError = vi.fn()
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockShowSubscriptionRequiredDialog = vi.fn()
|
||||
const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
isLoggedIn: mockIsLoggedIn
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
reportError: mockReportError,
|
||||
accessBillingPortal: mockAccessBillingPortal
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: vi.fn(() => ({
|
||||
wrapWithErrorHandlingAsync: vi.fn(
|
||||
(fn, errorHandler) =>
|
||||
async (...args: any[]) => {
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (error) {
|
||||
if (errorHandler) {
|
||||
errorHandler(error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: mockGetAuthHeader
|
||||
})),
|
||||
FirebaseAuthStoreError: class extends Error {}
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('useSubscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response)
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should compute isActiveSubscription correctly when subscription is active', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isActiveSubscription.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should format renewal date correctly', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16T12:00:00Z'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { formattedRenewalDate, fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
// The date format may vary based on timezone, so we just check it's a valid date string
|
||||
expect(formattedRenewalDate.value).toMatch(/^[A-Za-z]{3} \d{1,2}, \d{4}$/)
|
||||
expect(formattedRenewalDate.value).toContain('2025')
|
||||
expect(formattedRenewalDate.value).toContain('Nov')
|
||||
})
|
||||
|
||||
it('should return empty string when renewal date is not available', () => {
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
|
||||
expect(formattedRenewalDate.value).toBe('')
|
||||
})
|
||||
|
||||
it('should format monthly price correctly', () => {
|
||||
const { formattedMonthlyPrice } = useSubscription()
|
||||
|
||||
expect(formattedMonthlyPrice.value).toBe('$20')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchStatus', () => {
|
||||
it('should fetch subscription status successfully', async () => {
|
||||
const mockStatus = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
}
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockStatus
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/customers/cloud-subscription-status'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ message: 'Subscription not found' })
|
||||
} as Response)
|
||||
|
||||
const { fetchStatus } = useSubscription()
|
||||
|
||||
await expect(fetchStatus()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('should initiate subscription checkout successfully', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
// Mock window.open
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { subscribe } = useSubscription()
|
||||
|
||||
await subscribe()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/customers/cloud-subscription-checkout'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw error when checkout URL is not returned', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
} as Response)
|
||||
|
||||
const { subscribe } = useSubscription()
|
||||
|
||||
await expect(subscribe()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireActiveSubscription', () => {
|
||||
it('should not show dialog when subscription is active', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
const { requireActiveSubscription } = useSubscription()
|
||||
|
||||
await requireActiveSubscription()
|
||||
|
||||
expect(mockShowSubscriptionRequiredDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show dialog when subscription is inactive', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
const { requireActiveSubscription } = useSubscription()
|
||||
|
||||
await requireActiveSubscription()
|
||||
|
||||
expect(mockShowSubscriptionRequiredDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('action handlers', () => {
|
||||
it('should open usage history URL', () => {
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { handleViewUsageHistory } = useSubscription()
|
||||
handleViewUsageHistory()
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
'https://platform.comfy.org/profile/usage',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should open learn more URL', () => {
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { handleLearnMore } = useSubscription()
|
||||
handleLearnMore()
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should call accessBillingPortal for invoice history', async () => {
|
||||
const { handleInvoiceHistory } = useSubscription()
|
||||
|
||||
await handleInvoiceHistory()
|
||||
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call accessBillingPortal for manage subscription', async () => {
|
||||
const { manageSubscription } = useSubscription()
|
||||
|
||||
await manageSubscription()
|
||||
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user