mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-07 14:34:41 +00:00
Compare commits
3 Commits
codex/fe-5
...
test/cov-S
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2182348357 | ||
|
|
d08e2d3fe9 | ||
|
|
e1b42f46b9 |
@@ -0,0 +1,198 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
|
||||
|
||||
const mockHandleSubscribeClick = vi.fn()
|
||||
const mockHandleBackToPricing = vi.fn()
|
||||
const mockHandleAddCreditCard = vi.fn()
|
||||
const mockHandleConfirmTransition = vi.fn()
|
||||
const mockHandleResubscribe = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: () => ({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', close: 'Close' },
|
||||
subscription: {
|
||||
plansForWorkspace: 'Plans for {workspace}',
|
||||
teamWorkspace: 'Team'
|
||||
},
|
||||
credits: {
|
||||
topUp: {
|
||||
insufficientTitle: 'Insufficient Credits',
|
||||
insufficientMessage: 'You have run out of credits.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const PricingTableStub = {
|
||||
name: 'PricingTableWorkspace',
|
||||
template: `<div data-testid="pricing-table">
|
||||
<button data-testid="subscribe-btn" @click="$emit('subscribe', { tierKey: 'standard', billingCycle: 'yearly' })">Subscribe</button>
|
||||
<button data-testid="resubscribe-btn" @click="$emit('resubscribe')">Resubscribe</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const AddPaymentPreviewStub = {
|
||||
name: 'SubscriptionAddPaymentPreviewWorkspace',
|
||||
template: `<div data-testid="add-payment-preview">
|
||||
<button data-testid="add-card-btn" @click="$emit('addCreditCard')">Add Card</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const TransitionPreviewStub = {
|
||||
name: 'SubscriptionTransitionPreviewWorkspace',
|
||||
template: `<div data-testid="transition-preview">
|
||||
<button data-testid="confirm-btn" @click="$emit('confirm')">Confirm</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {}
|
||||
) {
|
||||
return render(SubscriptionRequiredDialogContentWorkspace, {
|
||||
props: {
|
||||
onClose: props.onClose ?? vi.fn(),
|
||||
...(props.reason ? { reason: props.reason } : {})
|
||||
},
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
PricingTableWorkspace: PricingTableStub,
|
||||
SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub,
|
||||
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckoutStep.value = 'pricing'
|
||||
mockPreviewData.value = null
|
||||
})
|
||||
|
||||
it('shows pricing table on pricing step', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('pricing-table')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows close button and hides back button on pricing step', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByLabelText('Close')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Back')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderComponent({ onClose })
|
||||
|
||||
await user.click(screen.getByLabelText('Close'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows back button on preview step', () => {
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||
renderComponent()
|
||||
expect(screen.getByLabelText('Back')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows insufficient credits message when reason is out_of_credits', () => {
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
expect(screen.getByText('Insufficient Credits')).toBeInTheDocument()
|
||||
expect(screen.getByText('You have run out of credits.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show insufficient credits message without reason', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Insufficient Credits')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows new subscription preview when transition_type is new_subscription', () => {
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('add-payment-preview')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows transition preview when transition_type is upgrade', () => {
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'upgrade' }
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('transition-preview')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires subscribe event to handleSubscribeClick', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-btn'))
|
||||
|
||||
expect(mockHandleSubscribeClick).toHaveBeenCalledWith({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
})
|
||||
|
||||
it('wires resubscribe event to handleResubscribe', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('resubscribe-btn'))
|
||||
|
||||
expect(mockHandleResubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wires back button to handleBackToPricing', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByLabelText('Back'))
|
||||
|
||||
expect(mockHandleBackToPricing).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
variant="muted-textonly"
|
||||
class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times text-xl" />
|
||||
</Button>
|
||||
@@ -94,28 +94,14 @@
|
||||
</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 { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
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 '@/platform/workspace/stores/billingOperationStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
const { onClose, reason } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
@@ -125,227 +111,22 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
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'
|
||||
})
|
||||
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'
|
||||
})
|
||||
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
|
||||
})
|
||||
} 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,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
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
|
||||
})
|
||||
} 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,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
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
|
||||
})
|
||||
} 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
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onClose()
|
||||
}
|
||||
const {
|
||||
checkoutStep,
|
||||
isLoadingPreview,
|
||||
loadingTier,
|
||||
isSubscribing,
|
||||
isResubscribing,
|
||||
previewData,
|
||||
selectedTierKey,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
handleSubscribeClick,
|
||||
handleBackToPricing,
|
||||
handleAddCreditCard,
|
||||
handleConfirmTransition,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { findPlanSlug } from './useSubscriptionCheckout'
|
||||
|
||||
function makeStandardYearly(): Plan {
|
||||
return {
|
||||
slug: 'standard-yearly',
|
||||
tier: 'STANDARD',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 1600,
|
||||
credits_cents: 4200,
|
||||
max_seats: 1,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 1600,
|
||||
total_credits_cents: 4200
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCreatorMonthly(): Plan {
|
||||
return {
|
||||
slug: 'creator-monthly',
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 3500,
|
||||
credits_cents: 7400,
|
||||
max_seats: 5,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 3500,
|
||||
total_credits_cents: 7400
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function allPlans(): Plan[] {
|
||||
return [makeStandardYearly(), makeCreatorMonthly()]
|
||||
}
|
||||
|
||||
describe('findPlanSlug', () => {
|
||||
it('finds an annual plan by tier key and yearly billing cycle', () => {
|
||||
expect(findPlanSlug(allPlans(), 'standard', 'yearly')).toBe(
|
||||
'standard-yearly'
|
||||
)
|
||||
})
|
||||
|
||||
it('finds a monthly plan by tier key and monthly billing cycle', () => {
|
||||
expect(findPlanSlug(allPlans(), 'creator', 'monthly')).toBe(
|
||||
'creator-monthly'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when no plan matches', () => {
|
||||
expect(findPlanSlug(allPlans(), 'standard', 'monthly')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty plans', () => {
|
||||
expect(findPlanSlug([], 'standard', 'yearly')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const {
|
||||
mockSubscribe,
|
||||
mockPreviewSubscribe,
|
||||
mockFetchStatus,
|
||||
mockFetchBalance,
|
||||
mockPlans,
|
||||
mockResubscribe,
|
||||
mockToastAdd
|
||||
} = vi.hoisted(() => ({
|
||||
mockSubscribe: vi.fn(),
|
||||
mockPreviewSubscribe: vi.fn(),
|
||||
mockFetchStatus: vi.fn(),
|
||||
mockFetchBalance: vi.fn(),
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockResubscribe: vi.fn(),
|
||||
mockToastAdd: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
subscribe: mockSubscribe,
|
||||
previewSubscribe: mockPreviewSubscribe,
|
||||
plans: computed(() => mockPlans.value),
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { resubscribe: mockResubscribe }
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.comfy.org'
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('useSubscriptionCheckout', () => {
|
||||
type CloseEmit = (e: 'close', subscribed: boolean) => void
|
||||
|
||||
let emit: ReturnType<typeof vi.fn<CloseEmit>>
|
||||
|
||||
async function setup() {
|
||||
const { useSubscriptionCheckout } =
|
||||
await import('./useSubscriptionCheckout')
|
||||
return useSubscriptionCheckout(emit)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockPlans.value = allPlans()
|
||||
emit = vi.fn<CloseEmit>()
|
||||
})
|
||||
|
||||
describe('handleSubscribeClick', () => {
|
||||
it('transitions to preview on successful preview', async () => {
|
||||
const checkout = await setup()
|
||||
const preview = {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription' as const,
|
||||
effective_at: '2025-01-01',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 1600,
|
||||
cost_next_period_cents: 1600,
|
||||
credits_today_cents: 4200,
|
||||
credits_next_period_cents: 4200,
|
||||
new_plan: makeStandardYearly().seat_summary
|
||||
}
|
||||
mockPreviewSubscribe.mockResolvedValueOnce(preview)
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('preview')
|
||||
expect(checkout.previewData.value).toStrictEqual(preview)
|
||||
})
|
||||
|
||||
it('shows error toast when preview is disallowed', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
reason: 'Not allowed'
|
||||
})
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Not allowed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when plan slug is not found', async () => {
|
||||
const checkout = await setup()
|
||||
mockPlans.value = []
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'This plan is not available'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast on network failure', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Network error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves monthly billing cycle to correct plan slug', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription'
|
||||
})
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'creator',
|
||||
billingCycle: 'monthly'
|
||||
})
|
||||
|
||||
expect(mockPreviewSubscribe).toHaveBeenCalledWith('creator-monthly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBackToPricing', () => {
|
||||
it('resets to pricing step and clears preview data', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.checkoutStep.value = 'preview'
|
||||
checkout.previewData.value = {} as never
|
||||
|
||||
checkout.handleBackToPricing()
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||
expect(checkout.previewData.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddCreditCard', () => {
|
||||
it('emits close on subscribed status', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'standard-yearly',
|
||||
'https://platform.comfy.org/payment/success',
|
||||
'https://platform.comfy.org/payment/failed'
|
||||
)
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
})
|
||||
|
||||
it('opens payment URL when needs_payment_method', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'needs_payment_method',
|
||||
billing_op_id: 'op-2',
|
||||
payment_method_url: 'https://stripe.com/pay'
|
||||
})
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://stripe.com/pay', '_blank')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows error toast on subscribe failure', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('Payment failed'))
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Payment failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleConfirmTransition', () => {
|
||||
it('emits close on subscribed status', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-3'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleConfirmTransition()
|
||||
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('Transition error'))
|
||||
|
||||
await checkout.handleConfirmTransition()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Transition error'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleResubscribe', () => {
|
||||
it('emits close on success', async () => {
|
||||
const checkout = await setup()
|
||||
mockResubscribe.mockResolvedValueOnce({
|
||||
billing_op_id: 'op-4',
|
||||
status: 'active'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleResubscribe()
|
||||
|
||||
expect(mockResubscribe).toHaveBeenCalled()
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
const checkout = await setup()
|
||||
mockResubscribe.mockRejectedValueOnce(new Error('Resubscribe failed'))
|
||||
|
||||
await checkout.handleResubscribe()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Resubscribe failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
210
src/platform/workspace/composables/useSubscriptionCheckout.ts
Normal file
210
src/platform/workspace/composables/useSubscriptionCheckout.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
export function findPlanSlug(
|
||||
plans: Plan[],
|
||||
tierKey: CheckoutTierKey,
|
||||
billingCycle: BillingCycle
|
||||
): string | null {
|
||||
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase()
|
||||
const plan = plans.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
export function useSubscriptionCheckout(emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
|
||||
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')
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
function getApiPlanSlug(
|
||||
tierKey: CheckoutTierKey,
|
||||
billingCycle: BillingCycle
|
||||
): string | null {
|
||||
return findPlanSlug(plans.value, tierKey, billingCycle)
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
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'
|
||||
})
|
||||
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
|
||||
})
|
||||
} finally {
|
||||
isLoadingPreview.value = false
|
||||
loadingTier.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
}
|
||||
|
||||
async function handleSubscription() {
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
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
|
||||
})
|
||||
} 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
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkoutStep,
|
||||
isLoadingPreview,
|
||||
loadingTier,
|
||||
isSubscribing,
|
||||
isResubscribing,
|
||||
previewData,
|
||||
selectedTierKey,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
handleSubscribeClick,
|
||||
handleBackToPricing,
|
||||
handleAddCreditCard: handleSubscription,
|
||||
handleConfirmTransition: handleSubscription,
|
||||
handleResubscribe
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user