mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
5 Commits
perf/fix-d
...
test/cov-S
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea20fc793 | ||
|
|
3a5ff0052f | ||
|
|
01f48483df | ||
|
|
cff784d847 | ||
|
|
b78efa21a2 |
@@ -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}."
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user