Compare commits

..

8 Commits

Author SHA1 Message Date
Alexander Brown
cbe06f147a Merge branch 'main' into test/cov-SubscriptionPanel 2026-05-19 17:43:44 -07:00
Deep Mehta
2ab1abb898 Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12344)
Reverts Comfy-Org/ComfyUI_frontend#12301

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12344-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-session-3656d73d365081119ebad749a2e0d403)
by [Unito](https://www.unito.io)
2026-05-20 07:58:12 +09:00
Alexander Brown
69e119e3a3 Merge branch 'main' into test/cov-SubscriptionPanel 2026-05-19 14:01:58 -07:00
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
10 changed files with 456 additions and 433 deletions

View File

@@ -1,63 +0,0 @@
{
"last_node_id": 3,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [400, 50],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": [], "slot_index": 0 }
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
},
{
"id": 2,
"type": "Note",
"pos": [50, 50],
"size": [300, 150],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["This is a reference note"],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 3,
"type": "MarkdownNote",
"pos": [50, 250],
"size": [300, 150],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["# Markdown heading"],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] }
},
"version": 0.4
}

View File

@@ -1,82 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Note Node API Export', { tag: '@node' }, () => {
test('excludes Note and MarkdownNote from API format export', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const classTypes = Object.values(apiWorkflow).map((n) => n.class_type)
expect(classTypes, 'API output should not contain Note').not.toContain(
'Note'
)
expect(
classTypes,
'API output should not contain MarkdownNote'
).not.toContain('MarkdownNote')
expect(
Object.keys(apiWorkflow),
'All-virtual workflow should produce empty API output'
).toHaveLength(0)
})
test('preserves real nodes while filtering virtual ones', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const entries = Object.values(apiWorkflow)
expect(entries, 'Exactly one real node in API output').toHaveLength(1)
expect(entries[0].class_type).toBe('KSampler')
})
test('standard workflow export still includes Note nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
const workflow = await comfyPage.workflow.getExportedWorkflow()
const noteNodes = workflow.nodes.filter(
(n) => n.type === 'Note' || n.type === 'MarkdownNote'
)
expect(
noteNodes,
'Standard export must preserve both Note and MarkdownNote'
).toHaveLength(2)
})
test('no virtual node types leak through graphToPrompt', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
const virtualNodeCheck = await comfyPage.page.evaluate(async () => {
const { output } = await window.app!.graphToPrompt()
const virtualTypes = ['Note', 'MarkdownNote', 'Reroute', 'PrimitiveNode']
const leaked: string[] = []
for (const node of Object.values(output)) {
if (virtualTypes.includes(node.class_type)) {
leaked.push(node.class_type)
}
}
return { leaked, totalNodes: Object.keys(output).length }
})
expect(
virtualNodeCheck.leaked,
'No virtual node types should leak into API output'
).toHaveLength(0)
expect(virtualNodeCheck.totalNodes).toBeGreaterThan(0)
})
})

View File

@@ -2369,6 +2369,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

@@ -1,91 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { getSurveyCompletedStatus } from './auth'
const fetchApi = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => fetchApi(...args)
}
}))
vi.mock('@sentry/vue', () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn()
}))
function mockResponse({
ok,
status,
body
}: {
ok: boolean
status: number
body?: unknown
}): Response {
return fromPartial<Response>({
ok,
status,
statusText: '',
json: async () => body
})
}
describe('getSurveyCompletedStatus', () => {
beforeEach(() => {
fetchApi.mockReset()
})
test('200 with non-empty value → true', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: { q1: 'a' } } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('200 with empty value → false (the only "not completed" signal)', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: {} } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('200 with null value → false', async () => {
fetchApi.mockResolvedValueOnce(
mockResponse({ ok: true, status: 200, body: { value: null } })
)
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
})
test('404 → true (do not bounce on missing key)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 404 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('500 → true (do not bounce on transient backend error)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
// 401/403 fall under the same "ambiguous => treat as completed" policy.
// The dedicated auth layer handles re-authentication on the next API
// call; this function deliberately does not try to disambiguate auth
// failures from other non-OK responses. Locking with tests so the
// policy can't drift back to a "throw on auth error" branch.
test('401 → true (auth layer handles re-auth on next call)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 401 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('403 → true (auth layer handles re-auth on next call)', async () => {
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 403 }))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
test('network rejection → true (do not bounce on network error)', async () => {
fetchApi.mockRejectedValueOnce(new TypeError('Network request failed'))
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
})
})

View File

@@ -96,24 +96,23 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
}
})
if (!response.ok) {
// Ambiguous response (404/5xx/etc). Treat as completed to avoid
// bouncing working customers to /cloud/survey on transient hiccups.
// Real "not completed" only comes from a 200 with empty value.
// Not an error case - survey not completed is a valid state
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'warning',
level: 'info',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return true
return false
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network/parse failure — same policy as ambiguous HTTP responses.
// Network error - still capture it as it's not thrown from above
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
@@ -125,7 +124,7 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
},
level: 'warning'
})
return true
return false
}
}

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 '@comfyorg/tailwind-utils'
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
}

View File

@@ -1,88 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { graphToPrompt } from './executionUtil'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('graphToPrompt', () => {
it('excludes nodes with isVirtualNode from API output', async () => {
const graph = new LGraph()
const realNode = new LGraphNode('RealNode')
realNode.comfyClass = 'KSampler'
graph.add(realNode)
const virtualNode = new LGraphNode('VirtualNode')
virtualNode.isVirtualNode = true
virtualNode.comfyClass = 'Note'
graph.add(virtualNode)
const { output } = await graphToPrompt(graph)
expect(output[String(virtualNode.id)]).toBeUndefined()
expect(output[String(realNode.id)]).toBeDefined()
expect(output[String(realNode.id)].class_type).toBe('KSampler')
})
it('produces empty output when all nodes are virtual', async () => {
const graph = new LGraph()
const note = new LGraphNode('Note')
note.isVirtualNode = true
note.comfyClass = 'Note'
graph.add(note)
const mdNote = new LGraphNode('MarkdownNote')
mdNote.isVirtualNode = true
mdNote.comfyClass = 'MarkdownNote'
graph.add(mdNote)
const { output } = await graphToPrompt(graph)
expect(Object.keys(output)).toHaveLength(0)
})
it('includes virtual nodes in workflow JSON for save fidelity', async () => {
const graph = new LGraph()
const note = new LGraphNode('Note')
note.isVirtualNode = true
note.comfyClass = 'Note'
graph.add(note)
const realNode = new LGraphNode('RealNode')
realNode.comfyClass = 'KSampler'
graph.add(realNode)
const { workflow, output } = await graphToPrompt(graph)
expect(
workflow.nodes.some((n) => n.id === note.id),
'Workflow JSON should preserve virtual nodes by ID'
).toBe(true)
expect(output[String(note.id)]).toBeUndefined()
})
it('preserves multiple non-virtual nodes', async () => {
const graph = new LGraph()
const node1 = new LGraphNode('Node1')
node1.comfyClass = 'KSampler'
graph.add(node1)
const node2 = new LGraphNode('Node2')
node2.comfyClass = 'SaveImage'
graph.add(node2)
const { output } = await graphToPrompt(graph)
expect(Object.keys(output)).toHaveLength(2)
expect(output[String(node1.id)].class_type).toBe('KSampler')
expect(output[String(node2.id)].class_type).toBe('SaveImage')
})
})