Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,7 @@
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
"vue/no-import-compiler-macros": "error"
},
"overrides": [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-ui/src/stores/maintenanceTaskStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
type IndexedUpdate = InstallValidation & Record<string, ValidationState>

/** State of a maintenance task, managed by the maintenance task store. */
export class MaintenanceTaskRunner {
class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}

private _state?: MaintenanceTaskState
Expand Down
39 changes: 15 additions & 24 deletions knip.config.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,45 @@
import type { KnipConfig } from 'knip'

const config: KnipConfig = {
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
entry: ['src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
project: ['src/**/*.{js,ts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}'],
entry: ['src/index.ts']
project: ['src/**/*.{js,ts}']
},
'apps/website': {
entry: [
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
}
},
ignoreBinaries: ['python3', 'gh'],
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
Expand All @@ -45,30 +52,14 @@ const config: KnipConfig = {
ignore: [
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
'packages/ingest-types/src/types.gen.ts',
'packages/ingest-types/src/zod.gen.ts',
'packages/ingest-types/openapi-ts.config.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
'.agents/checks/eslint.strict.config.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
css: (text: string) =>
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
.join('\n')
},
vite: {
config: ['vite?(.*).config.mts']
},
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,7 @@
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-primeui": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unplugin-icons": "catalog:",
Expand Down
2 changes: 0 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.49.0
Expand Down
9 changes: 5 additions & 4 deletions src/composables/auth/useFirebaseAuthActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const useFirebaseAuthActions = () => {

const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
void
boolean
>(async (targetTier, openInNewTab = true) => {
const response = await authStore.accessBillingPortal(targetTier)
if (!response.billing_portal_url) {
Expand All @@ -128,10 +128,11 @@ export const useFirebaseAuthActions = () => {
)
}
if (openInNewTab) {
window.open(response.billing_portal_url, '_blank')
} else {
globalThis.location.href = response.billing_portal_url
return window.open(response.billing_portal_url, '_blank') !== null
}

globalThis.location.href = response.billing_portal_url
return true
}, reportError)

const fetchBalance = wrapWithErrorHandlingAsync(async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/extensions/core/clipspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { app } from '../../scripts/app'
import { ComfyApp } from '../../scripts/app'
import { $el, ComfyDialog } from '../../scripts/ui'

export class ClipspaceDialog extends ComfyDialog {
class ClipspaceDialog extends ComfyDialog {
static items: Array<
HTMLButtonElement & {
contextPredicate?: () => boolean
Expand Down
122 changes: 121 additions & 1 deletion src/platform/cloud/subscription/components/PricingTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import { createI18n } from 'vue-i18n'

import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'

function createDeferredPromise<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})

return { promise, resolve }
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
Expand All @@ -20,6 +29,35 @@ const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
const mockLocalStorage = vi.hoisted(() => {
const store = new Map<string, string>()

return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
}),
__reset: () => {
store.clear()
}
}
})

Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})

Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true
})

vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
Expand Down Expand Up @@ -138,7 +176,20 @@ function createWrapper() {
},
stubs: {
SelectButton: {
template: '<div><slot /></div>',
template: `
<div>
<button
v-for="option in options"
:key="option.value"
type="button"
@click="$emit('update:modelValue', option.value)"
>
<slot name="option" :option="option">
{{ option.label }}
</slot>
</button>
</div>
`,
props: ['modelValue', 'options'],
emits: ['update:modelValue']
},
Expand All @@ -155,7 +206,10 @@ describe('PricingTable', () => {
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockTrackBeginCheckout.mockReset()
mockLocalStorage.__reset()
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
Expand Down Expand Up @@ -205,6 +259,72 @@ describe('PricingTable', () => {
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})

it('records the plan snapshot that was actually opened', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'

const portalOpen = createDeferredPromise<boolean>()
mockAccessBillingPortal.mockReturnValueOnce(portalOpen.promise)

const wrapper = createWrapper()
await flushPromises()

const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))

expect(creatorButton).toBeDefined()
await creatorButton?.trigger('click')
await flushPromises()

const monthlyToggle = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Monthly'))

expect(monthlyToggle).toBeDefined()
await monthlyToggle?.trigger('click')
await flushPromises()

portalOpen.resolve(true)
await flushPromises()

expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
expect(
JSON.parse(
window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
) ?? '{}'
)
).toMatchObject({
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
previous_tier: 'standard',
previous_cycle: 'monthly'
})
})

it('does not record a pending upgrade when the billing portal does not open', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
mockAccessBillingPortal.mockResolvedValueOnce(false)

const wrapper = createWrapper()
await flushPromises()

const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))

expect(creatorButton).toBeDefined()
await creatorButton?.trigger('click')
await flushPromises()

expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})

it('should use the latest userId value when it changes after mount', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
Expand Down
42 changes: 29 additions & 13 deletions src/platform/cloud/subscription/components/PricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
Expand Down Expand Up @@ -431,37 +432,52 @@ const handleSubscribe = wrapWithErrorHandlingAsync(

try {
if (hasPaidSubscription.value) {
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(currentTierKey.value
? { previous_tier: currentTierKey.value }
: {})
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
}
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
targetPlan.billingCycle
)
const downgrade =
currentPlanDescriptor.value &&
previousPlan &&
isPlanDowngrade({
current: currentPlanDescriptor.value,
current: previousPlan,
target: targetPlan
})

if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
await accessBillingPortal(checkoutTier)
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}

recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
}
} else {
await performSubscriptionCheckout(
Expand Down
Loading
Loading