Compare commits

...

3 Commits

Author SHA1 Message Date
Benjamin Lu
2182348357 test: tighten subscription checkout emit typing (#11832)
## Summary

Tightens the `useSubscriptionCheckout` test emit mock so it matches the
composable contract without using `as never`.

## Changes

- **What**: Adds a `CloseEmit` function signature and types
`vi.fn<CloseEmit>()` directly.
- **Dependencies**: None.

## Review Focus

Confirm the mock typing remains readable and preserves the existing test
behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11832-test-tighten-subscription-checkout-emit-typing-3546d73d3650818eaacec567ff8e519d)
by [Unito](https://www.unito.io)
2026-05-03 19:54:18 -07:00
bymyself
d08e2d3fe9 refactor: extract useSubscriptionCheckout composable, rewrite tests
- Extract checkout logic from SubscriptionRequiredDialogContentWorkspace
  into useSubscriptionCheckout composable
- Deduplicate handleAddCreditCard/handleConfirmTransition into single
  handleSubscription function
- Export pure findPlanSlug function for zero-mock unit testing
- Rewrite component tests to mock only the composable (11 slim tests)
- Add composable-level tests with minimal mocking (17 tests)
- Pure function tests for findPlanSlug with zero mocks (4 tests)

Addresses review feedback: heavy mocking signaled tight coupling.
Component is now a thin presentation layer; logic lives in the
composable where it can be tested with focused, maintainable tests.
2026-05-01 21:14:14 -07:00
bymyself
e1b42f46b9 test: add component tests for SubscriptionRequiredDialogContentWorkspace 2026-05-01 21:14:14 -07:00
4 changed files with 797 additions and 237 deletions

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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'
})
)
})
})
})

View 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
}
}