Compare commits

...

5 Commits

Author SHA1 Message Date
bymyself
fea20fc793 fix(i18n): use translation key for resubscribe failure fallback message
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11398#discussion_r3127855812
2026-05-01 21:12:10 -07:00
bymyself
3a5ff0052f test: clarify formatRefillsDate test uses UTC for timezone stability
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11398#discussion_r3127855817
2026-05-01 21:11:42 -07:00
bymyself
01f48483df fix: use UTC methods in formatRefillsDate for timezone consistency
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11398#discussion_r3127855820
2026-05-01 21:10:46 -07:00
bymyself
cff784d847 refactor: remove monolithic viewmodel, inline logic with pure helpers
Addresses review feedback that the Model/View/Pure logic split was not
appropriate for codebase conventions. Restores inline computed properties
in the component while still delegating deterministic calculations to
extracted pure functions in subscriptionPanelWorkspace.logic.ts.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11398#pullrequestreview-2923655261
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11398#pullrequestreview-2925274137
2026-04-22 18:42:01 -07:00
bymyself
b78efa21a2 refactor: extract SubscriptionPanel logic into composable and pure helpers
Split SubscriptionPanelContentWorkspace into three testable layers:

1. subscriptionPanelWorkspace.logic.ts - pure functions for date
   formatting, tier key resolution, invoice math, and credit
   calculations (tested with zero mocks)

2. useSubscriptionPanelWorkspaceViewModel.ts - composable accepting
   refs as inputs for all computed state and UI action handlers
   (tested with plain refs, no module mocks)

3. SubscriptionPanelContentWorkspace.vue - thin component for
   wiring, side effects, and template rendering (slim smoke tests)

Previous approach required ~300 lines of vi.mock() scaffolding for
42 component-level tests. New approach: 14 pure function tests +
35 composable tests + 5 component smoke tests = 54 total tests
with minimal mocking.
2026-04-20 02:34:25 -07:00
5 changed files with 450 additions and 102 deletions

View File

@@ -2345,6 +2345,7 @@
"resubscribe": "Resubscribe",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"resubscribeFailed": "Failed to resubscribe",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."

View File

@@ -0,0 +1,236 @@
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 SubscriptionPanelContentWorkspace from './SubscriptionPanelContentWorkspace.vue'
const fns = vi.hoisted(() => ({
manageSubscription: vi.fn(),
fetchStatus: vi.fn().mockResolvedValue(undefined),
fetchBalance: vi.fn().mockResolvedValue(undefined),
handleRefresh: vi.fn().mockResolvedValue(undefined),
resubscribe: vi.fn().mockResolvedValue({}),
toastAdd: vi.fn()
}))
const isSettingUp = ref(false)
const isActiveSubscription = ref(false)
const isFreeTier = ref(false)
const subscription = ref<Record<string, unknown> | null>(null)
const isWorkspaceSubscribed = ref(true)
const isInPersonalWorkspace = ref(false)
const members = ref([{ id: '1' }, { id: '2' }])
const perms = ref({ canManageSubscription: true, canTopUp: true })
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: fns.toastAdd })
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
get isSettingUp() {
return isSettingUp.value
}
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
get isActiveSubscription() {
return isActiveSubscription
},
get isFreeTier() {
return isFreeTier
},
get subscription() {
return subscription
},
showSubscriptionDialog: vi.fn(),
manageSubscription: fns.manageSubscription,
fetchStatus: fns.fetchStatus,
fetchBalance: fns.fetchBalance,
getMaxSeats: () => 5
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
() => ({
useSubscriptionCredits: () => ({
totalCredits: ref('100'),
monthlyBonusCredits: ref('50'),
prepaidCredits: ref('50'),
isLoadingBalance: ref(false)
})
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => ({
handleAddApiCredits: vi.fn(),
handleRefresh: fns.handleRefresh
})
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
showPricingTable: vi.fn()
})
})
)
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
get permissions() {
return perms
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isWorkspaceSubscribed() {
return isWorkspaceSubscribed
},
get isInPersonalWorkspace() {
return isInPersonalWorkspace
},
get members() {
return members
}
})
}))
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { resubscribe: fns.resubscribe }
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showCancelSubscriptionDialog: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
const ButtonStub = {
name: 'Button',
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['disabled', 'loading', 'variant', 'size']
}
function renderComponent() {
return render(SubscriptionPanelContentWorkspace, {
global: {
plugins: [i18n],
stubs: {
Button: ButtonStub,
StatusBadge: {
name: 'StatusBadge',
template: '<span data-testid="status-badge"><slot /></span>',
props: ['label', 'severity']
},
Skeleton: {
name: 'Skeleton',
template: '<div data-testid="skeleton" />',
props: ['width', 'height']
},
Menu: true
}
}
})
}
function setSubscribedState(overrides: Record<string, unknown> = {}) {
isActiveSubscription.value = true
isFreeTier.value = false
subscription.value = {
tier: 'STANDARD',
duration: 'MONTHLY',
renewalDate: '2026-06-01T00:00:00Z',
endDate: null,
isCancelled: false,
...overrides
}
}
describe('SubscriptionPanelContentWorkspace (component smoke tests)', () => {
beforeEach(() => {
vi.clearAllMocks()
isSettingUp.value = false
isActiveSubscription.value = false
isFreeTier.value = false
subscription.value = null
isWorkspaceSubscribed.value = true
isInPersonalWorkspace.value = false
members.value = [{ id: '1' }, { id: '2' }]
perms.value = { canManageSubscription: true, canTopUp: true }
})
it('shows loading spinner when setting up', () => {
isSettingUp.value = true
renderComponent()
expect(
screen.getByText('billingOperation.subscriptionProcessing')
).toBeTruthy()
})
it('renders subscribed state with tier name and price', () => {
setSubscribedState()
renderComponent()
const text = document.body.textContent ?? ''
expect(text).toContain('$20')
})
it('calls handleRefresh on refresh button click', async () => {
setSubscribedState()
renderComponent()
const allButtons = screen.getAllByRole('button')
const refreshButton = allButtons.find(
(b) => b.textContent === '' && !b.getAttribute('aria-label')
)
expect(refreshButton).toBeTruthy()
await userEvent.click(refreshButton!)
expect(fns.handleRefresh).toHaveBeenCalled()
})
it('calls workspaceApi.resubscribe on resubscribe click', async () => {
setSubscribedState({ isCancelled: true, endDate: '2026-07-15T00:00:00Z' })
renderComponent()
const btn = screen.getByRole('button', {
name: /subscription.resubscribe/
})
await userEvent.click(btn)
expect(fns.resubscribe).toHaveBeenCalled()
})
it('shows view more details link for owners', () => {
setSubscribedState()
renderComponent()
expect(
screen.getByRole('link', { name: /subscription.viewMoreDetailsPlans/ })
).toHaveAttribute('href', 'https://www.comfy.org/cloud/pricing')
})
})

View File

@@ -368,24 +368,27 @@ import { useToast } from 'primevue/usetoast'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { getTierPrice } from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import {
formatRefillsDate,
formatSubscriptionDate,
getNextMonthInvoice,
getPlanTotalCreditsValue,
getSubscriptionTierKey
} from './subscriptionPanelWorkspace.logic'
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
storeToRefs(workspaceStore)
@@ -410,40 +413,16 @@ const {
const { showCancelSubscriptionDialog } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
const isResubscribing = ref(false)
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to resubscribe'
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message
})
} finally {
isResubscribing.value = false
}
}
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
// Only show cancelled state for team workspaces (workspace billing)
// Personal workspaces use legacy billing which has different cancellation semantics
const isCancelled = computed(
() =>
!isInPersonalWorkspace.value && (subscription.value?.isCancelled ?? false)
)
// Show subscribe prompt to owners without active subscription
// Don't show if subscription is cancelled (still active until end date)
const showSubscribePrompt = computed(() => {
if (!permissions.value.canManageSubscription) return false
if (isCancelled.value) return false
@@ -451,7 +430,6 @@ const showSubscribePrompt = computed(() => {
return !isWorkspaceSubscribed.value
})
// MEMBER view without subscription - members can't manage subscription
const isMemberView = computed(
() =>
!permissions.value.canManageSubscription &&
@@ -459,12 +437,10 @@ const isMemberView = computed(
!isWorkspaceSubscribed.value
)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => showSubscribePrompt.value || isMemberView.value
)
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
function handleSubscribeWorkspace() {
showSubscriptionDialog()
}
@@ -477,36 +453,31 @@ function handleUpgrade() {
function handleUpgradeToAddCredits() {
showPricingTable()
}
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const formattedRenewalDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const renewalDate = new Date(subscription.value.renewalDate)
return renewalDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const tierKey = computed(() => getSubscriptionTierKey(subscriptionTier.value))
const formattedEndDate = computed(() => {
if (!subscription.value?.endDate) return ''
const endDate = new Date(subscription.value.endDate)
return endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const formattedRenewalDate = computed(() =>
formatSubscriptionDate(subscription.value?.renewalDate)
)
const formattedEndDate = computed(() =>
formatSubscriptionDate(subscription.value?.endDate)
)
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
if (!tier) return ''
const key = TIER_TO_KEY[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
const baseName = t(`subscription.tiers.${tierKey.value}.name`)
return isYearlySubscription.value
? t('subscription.tierNameYearly', { name: baseName })
: baseName
@@ -524,54 +495,36 @@ const planMenuItems = computed(() => [
}
])
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() =>
getNextMonthInvoice(memberCount.value, tierPrice.value)
)
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
const refillsDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const refillsDate = computed(() =>
formatRefillsDate(subscription.value?.renewalDate)
)
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
{ date: refillsDate.value },
{ escapeParameter: false }
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
{ date: refillsDate.value },
{ escapeParameter: false }
)
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
const total = getPlanTotalCreditsValue(
tierKey.value,
isYearlySubscription.value
)
return total === null ? '—' : n(total)
})
const includedCreditsDisplay = computed(
@@ -579,30 +532,52 @@ const includedCreditsDisplay = computed(
)
const tierBenefits = computed((): TierBenefit[] => {
const key = tierKey.value
const benefits: TierBenefit[] = []
if (!isInPersonalWorkspace.value) {
benefits.push({
key: 'members',
type: 'icon',
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
label: t('subscription.membersLabel', {
count: getMaxSeats(tierKey.value)
}),
icon: 'pi pi-user'
})
}
benefits.push(...getCommonTierBenefits(key, t, n))
benefits.push(...getCommonTierBenefits(tierKey.value, t, n))
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const isResubscribing = ref(false)
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
} catch (error) {
const message =
error instanceof Error
? error.message
: t('subscription.resubscribeFailed')
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message
})
} finally {
isResubscribing.value = false
}
}
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
const TOPUP_EXPIRY_MS = 5 * 60 * 1000
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
@@ -610,13 +585,11 @@ function handleWindowFocus() {
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import {
formatRefillsDate,
formatSubscriptionDate,
getNextMonthInvoice,
getPlanTotalCreditsValue,
getSubscriptionTierKey
} from './subscriptionPanelWorkspace.logic'
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: { free_tier_credits: 100 } }
}))
describe('getSubscriptionTierKey', () => {
it('returns default key for null tier', () => {
expect(getSubscriptionTierKey(null)).toBe('standard')
})
it('returns default key for undefined tier', () => {
expect(getSubscriptionTierKey(undefined)).toBe('standard')
})
it('maps known tiers correctly', () => {
expect(getSubscriptionTierKey('STANDARD')).toBe('standard')
expect(getSubscriptionTierKey('CREATOR')).toBe('creator')
expect(getSubscriptionTierKey('PRO')).toBe('pro')
expect(getSubscriptionTierKey('FREE')).toBe('free')
expect(getSubscriptionTierKey('FOUNDERS_EDITION')).toBe('founder')
})
})
describe('formatSubscriptionDate', () => {
it('returns empty string for null', () => {
expect(formatSubscriptionDate(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(formatSubscriptionDate(undefined)).toBe('')
})
it('formats a date string', () => {
const result = formatSubscriptionDate('2026-06-15T12:00:00Z')
expect(result).toContain('Jun')
expect(result).toContain('2026')
})
})
describe('formatRefillsDate', () => {
it('returns empty string for null', () => {
expect(formatRefillsDate(null)).toBe('')
})
it('formats as MM/DD/YY using UTC (timezone-agnostic)', () => {
// Input has explicit `Z` (UTC); formatRefillsDate uses UTC methods,
// so the result is stable across local timezones.
const result = formatRefillsDate('2026-06-15T12:00:00Z')
expect(result).toBe('06/15/26')
})
})
describe('getNextMonthInvoice', () => {
it('multiplies member count by tier price', () => {
expect(getNextMonthInvoice(3, 20)).toBe(60)
})
it('returns 0 for zero members', () => {
expect(getNextMonthInvoice(0, 20)).toBe(0)
})
})
describe('getPlanTotalCreditsValue', () => {
it('returns monthly credits for standard tier', () => {
expect(getPlanTotalCreditsValue('standard', false)).toBe(4200)
})
it('returns yearly credits (12x) for standard tier', () => {
expect(getPlanTotalCreditsValue('standard', true)).toBe(50400)
})
it('returns creator tier credits', () => {
expect(getPlanTotalCreditsValue('creator', false)).toBe(7400)
})
it('returns pro tier credits', () => {
expect(getPlanTotalCreditsValue('pro', false)).toBe(21100)
})
})

View File

@@ -0,0 +1,50 @@
import type {
SubscriptionTier,
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
export function getSubscriptionTierKey(
tier: SubscriptionTier | null | undefined
): TierKey {
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
}
export function formatSubscriptionDate(date?: string | null): string {
if (!date) return ''
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
export function formatRefillsDate(date?: string | null): string {
if (!date) return ''
const d = new Date(date)
const day = String(d.getUTCDate()).padStart(2, '0')
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
const year = String(d.getUTCFullYear()).slice(-2)
return `${month}/${day}/${year}`
}
export function getNextMonthInvoice(
memberCount: number,
tierPrice: number
): number {
return memberCount * tierPrice
}
export function getPlanTotalCreditsValue(
tierKey: TierKey,
isYearly: boolean
): number | null {
const credits = getTierCredits(tierKey)
if (credits === null) return null
return isYearly ? credits * 12 : credits
}