Skip to content
2 changes: 2 additions & 0 deletions packages/registry-types/src/comfyRegistryTypes.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions src/composables/auth/useFirebaseAuthActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore';
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore';
import { usdToMicros } from '@/utils/formatUtil'

/**
Expand Down Expand Up @@ -102,8 +103,11 @@ export const useFirebaseAuthActions = () => {
window.open(response.checkout_url, '_blank')
}, reportError)

const accessBillingPortal = wrapWithErrorHandlingAsync(async () => {
const response = await authStore.accessBillingPortal()
const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier],
void
>(async (targetTier) => {
const response = await authStore.accessBillingPortal(targetTier)
if (!response.billing_portal_url) {
throw new Error(
t('toastMessages.failedToAccessBillingPortal', {
Expand Down
4 changes: 3 additions & 1 deletion src/platform/cloud/subscription/components/PricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,9 @@ const handleSubscribe = wrapWithErrorHandlingAsync(

try {
if (isActiveSubscription.value) {
await accessBillingPortal()
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
await accessBillingPortal(checkoutTier)
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
Expand Down
9 changes: 8 additions & 1 deletion src/stores/firebaseAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ type AccessBillingPortalResponse =
operations['AccessBillingPortal']['responses']['200']['content']['application/json']
type AccessBillingPortalReqBody =
operations['AccessBillingPortal']['requestBody']
export type BillingPortalTargetTier = NonNullable<
NonNullable<
NonNullable<AccessBillingPortalReqBody>['content']
>['application/json']
>['target_tier']

export class FirebaseAuthStoreError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -409,13 +414,15 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
executeAuthAction((_) => addCredits(requestBodyContent))

const accessBillingPortal = async (
requestBody?: AccessBillingPortalReqBody
targetTier?: BillingPortalTargetTier
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}

const requestBody = targetTier ? { target_tier: targetTier } : undefined

const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'

import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'

const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)

vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
subscriptionTier: computed(() => mockSubscriptionTier.value)
})
}))

vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal,
reportError: mockReportError
})
}))

vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: vi.fn(
(fn, errorHandler) =>
async (...args: unknown[]) => {
try {
return await fn(...args)
} catch (error) {
if (errorHandler) {
errorHandler(error)
}
throw error
}
}
)
})
}))

vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getAuthHeader: mockGetAuthHeader
}),
FirebaseAuthStoreError: class extends Error {}
}))

vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))

global.fetch = vi.fn()

const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
yearly: 'Yearly',
monthly: 'Monthly',
mostPopular: 'Most Popular',
usdPerMonth: '/ month',
billedYearly: 'Billed yearly ({total})',
billedMonthly: 'Billed monthly',
currentPlan: 'Current Plan',
subscribeTo: 'Subscribe to {plan}',
changeTo: 'Change to {plan}',
maxDuration: {
standard: '30 min',
creator: '30 min',
pro: '1 hr'
},
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
pro: { name: 'Pro' }
},
benefits: {
monthlyCredits: '{credits} monthly credits',
maxDuration: '{duration} max duration',
gpu: 'RTX 6000 Pro GPU',
addCredits: 'Add more credits anytime',
customLoRAs: 'Import custom LoRAs'
}
}
}
}
})

function createWrapper() {
return mount(PricingTable, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
SelectButton: {
template: '<div><slot /></div>',
props: ['modelValue', 'options'],
emits: ['update:modelValue']
},
Popover: { template: '<div><slot /></div>' },
Button: {
template:
'<button @click="$emit(\'click\')" :disabled="disabled" :data-tier="dataTier">{{ label }}</button>',
props: ['loading', 'label', 'severity', 'disabled', 'dataTier', 'pt'],
emits: ['click']
}
}
}
})
}

describe('PricingTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
} as Response)
})

describe('billing portal deep linking', () => {
it('should call accessBillingPortal with yearly tier suffix when billing cycle is yearly (default)', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'

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(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
})

it('should call accessBillingPortal with different tiers correctly', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'

const wrapper = createWrapper()
await flushPromises()

const proButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Pro'))

await proButton?.trigger('click')
await flushPromises()

expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})
Comment on lines +137 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add test coverage for monthly billing cycle deep-linking.

The PR description states that billing cycle information (yearly vs monthly) is passed to the billing portal. However, all tests only verify the yearly suffix (-yearly). There are no tests verifying that monthly subscriptions pass the correct tier suffix (e.g., creator-monthly, pro-monthly).

Add tests that set mockIsYearlySubscription.value = true and verify the billing portal is called with monthly tier suffixes.

🔎 Suggested test for monthly billing cycle
+    it('should call accessBillingPortal with monthly tier suffix when billing cycle is monthly', async () => {
+      mockIsActiveSubscription.value = true
+      mockSubscriptionTier.value = 'STANDARD'
+      mockIsYearlySubscription.value = true
+
+      const wrapper = createWrapper()
+      await flushPromises()
+
+      const creatorButton = wrapper
+        .findAll('button')
+        .find((btn) => btn.text().includes('Creator'))
+
+      await creatorButton?.trigger('click')
+      await flushPromises()
+
+      expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-monthly')
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('billing portal deep linking', () => {
it('should call accessBillingPortal with yearly tier suffix when billing cycle is yearly (default)', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
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(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
})
it('should call accessBillingPortal with different tiers correctly', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const wrapper = createWrapper()
await flushPromises()
const proButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Pro'))
await proButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})
it('should call accessBillingPortal with monthly tier suffix when billing cycle is monthly', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
mockIsYearlySubscription.value = false
const wrapper = createWrapper()
await flushPromises()
const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))
await creatorButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-monthly')
})
🤖 Prompt for AI Agents
In tests-ui/tests/platform/cloud/subscription/components/PricingTable.test.ts
around lines 137 to 171, add test coverage for the monthly billing deep-linking:
create two tests (one for Creator and one for Pro) that set
mockIsActiveSubscription.value = true, set mockSubscriptionTier.value
appropriately, set mockIsYearlySubscription.value = true (to simulate monthly
billing per the PR note), create the wrapper and await flushPromises(), find the
appropriate button (by text includes 'Creator' or 'Pro'), trigger click and
await flushPromises(), and assert mockAccessBillingPortal was called with
'creator-monthly' and 'pro-monthly' respectively; also ensure mocks are
reset/cleared as needed before each test to avoid cross-test interference.


it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'

const wrapper = createWrapper()
await flushPromises()

const currentPlanButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Current Plan'))

await currentPlanButton?.trigger('click')
await flushPromises()

expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})

it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false

const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)

const wrapper = createWrapper()
await flushPromises()

const subscribeButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Subscribe'))

await subscribeButton?.trigger('click')
await flushPromises()

expect(mockAccessBillingPortal).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout/'),
expect.any(Object)
)
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://checkout.stripe.com/test',
'_blank'
)

windowOpenSpy.mockRestore()
})

it('should pass correct tier for each subscription level', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'PRO'

const wrapper = createWrapper()
await flushPromises()

const standardButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Standard'))

await standardButton?.trigger('click')
await flushPromises()

expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly')
})
})
})
Comment on lines +125 to +237

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding error handling tests.

While the current test coverage is solid for happy paths, consider adding tests for error scenarios such as:

  • accessBillingPortal throwing an error
  • fetch request failing for new subscriber checkout
  • Network errors during checkout initiation

These tests would verify that the reportError mock (line 15) is properly invoked during failure conditions.

Example error handling test
it('should handle billing portal errors gracefully', async () => {
  mockIsActiveSubscription.value = true
  mockSubscriptionTier.value = 'STANDARD'
  mockAccessBillingPortal.mockRejectedValueOnce(new Error('Portal error'))

  const wrapper = createWrapper()
  await flushPromises()

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

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

  expect(mockReportError).toHaveBeenCalled()
})

Loading