diff --git a/src/base/credits/comfyCredits.ts b/src/base/credits/comfyCredits.ts new file mode 100644 index 0000000000..b2407210ce --- /dev/null +++ b/src/base/credits/comfyCredits.ts @@ -0,0 +1,115 @@ +const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = { + minimumFractionDigits: 2, + maximumFractionDigits: 2 +} + +const formatNumber = ({ + value, + locale, + options +}: { + value: number + locale?: string + options?: Intl.NumberFormatOptions +}): string => { + const merged: Intl.NumberFormatOptions = { + ...DEFAULT_NUMBER_FORMAT, + ...options + } + + if ( + typeof merged.maximumFractionDigits === 'number' && + typeof merged.minimumFractionDigits === 'number' && + merged.maximumFractionDigits < merged.minimumFractionDigits + ) { + merged.minimumFractionDigits = merged.maximumFractionDigits + } + + return new Intl.NumberFormat(locale, merged).format(value) +} + +export const COMFY_CREDIT_RATE_CENTS = 210 +export const COMFY_CREDIT_RATE_USD = COMFY_CREDIT_RATE_CENTS / 100 + +export const usdToCents = (usd: number): number => Math.round(usd * 100) + +export const centsToCredits = (cents: number): number => + cents / COMFY_CREDIT_RATE_CENTS + +export const creditsToCents = (credits: number): number => + Math.round(credits * COMFY_CREDIT_RATE_CENTS) + +export const usdToCredits = (usd: number): number => + centsToCredits(usdToCents(usd)) + +export const creditsToUsd = (credits: number): number => + creditsToCents(credits) / 100 + +export type FormatOptions = { + value: number + locale?: string + numberOptions?: Intl.NumberFormatOptions +} + +export const formatCredits = ({ + value, + locale, + numberOptions +}: FormatOptions): string => + formatNumber({ value, locale, options: numberOptions }) + +export const formatCreditsFromCents = ({ + cents, + locale, + numberOptions +}: { + cents: number + locale?: string + numberOptions?: Intl.NumberFormatOptions +}): string => + formatCredits({ + value: centsToCredits(cents), + locale, + numberOptions + }) + +export const formatCreditsFromUsd = ({ + usd, + locale, + numberOptions +}: { + usd: number + locale?: string + numberOptions?: Intl.NumberFormatOptions +}): string => + formatCredits({ + value: usdToCredits(usd), + locale, + numberOptions + }) + +export const formatUsd = ({ + value, + locale, + numberOptions +}: FormatOptions): string => + formatNumber({ + value, + locale, + options: numberOptions + }) + +export const formatUsdFromCents = ({ + cents, + locale, + numberOptions +}: { + cents: number + locale?: string + numberOptions?: Intl.NumberFormatOptions +}): string => + formatUsd({ + value: cents / 100, + locale, + numberOptions + }) diff --git a/src/components/common/UserCredit.vue b/src/components/common/UserCredit.vue index aac405b90c..825467dbe5 100644 --- a/src/components/common/UserCredit.vue +++ b/src/components/common/UserCredit.vue @@ -9,7 +9,7 @@
@@ -21,9 +21,10 @@ import Skeleton from 'primevue/skeleton' import Tag from 'primevue/tag' import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { formatCreditsFromCents } from '@/base/credits/comfyCredits' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' -import { formatMetronomeCurrency } from '@/utils/formatUtil' const { textClass } = defineProps<{ textClass?: string @@ -31,9 +32,15 @@ const { textClass } = defineProps<{ const authStore = useFirebaseAuthStore() const balanceLoading = computed(() => authStore.isFetchingBalance) +const { t, locale } = useI18n() const formattedBalance = computed(() => { - if (!authStore.balance) return '0.00' - return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd') + // Backend returns cents despite the *_micros naming convention. + const cents = authStore.balance?.amount_micros ?? 0 + const amount = formatCreditsFromCents({ + cents, + locale: locale.value + }) + return `${amount} ${t('credits.credits')}` }) diff --git a/src/components/dialog/content/credit/CreditTopUpOption.vue b/src/components/dialog/content/credit/CreditTopUpOption.vue index f134aba5e2..bdca252fec 100644 --- a/src/components/dialog/content/credit/CreditTopUpOption.vue +++ b/src/components/dialog/content/credit/CreditTopUpOption.vue @@ -2,24 +2,36 @@
- - {{ amount }} +
+ + {{ formattedCredits }} +
+
+ {{ formattedCredits }} + {{ formattedUsd }} +
' }, + InputNumber: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '' + }, + ProgressSpinner: { template: '
' } + } + } + }) + +describe('CreditTopUpOption', () => { + it('renders converted credit price for preset amounts', () => { + const wrapper = mountOption({ amount: 2.1 }) + expect(wrapper.text()).toContain('1.00 Credits') + expect(wrapper.text()).toContain('$2.10') + }) + + it('updates credit label when editable amount changes', async () => { + const wrapper = mountOption({ editable: true }) + const vm = wrapper.vm as unknown as { customAmount: number } + vm.customAmount = 4.2 + await wrapper.vm.$nextTick() + expect(wrapper.text()).toContain('2.00 Credits') + }) +}) diff --git a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts index b7bff37a50..5be9f7d80c 100644 --- a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts @@ -17,9 +17,9 @@ const mockSubscriptionData = { } const mockCreditsData = { - totalCredits: '10.00', - monthlyBonusCredits: '5.00', - prepaidCredits: '5.00', + totalCredits: '10.00 Credits', + monthlyBonusCredits: '5.00 Credits', + prepaidCredits: '5.00 Credits', isLoadingBalance: false } @@ -154,8 +154,8 @@ describe('SubscriptionPanel', () => { describe('credit display functionality', () => { it('displays dynamic credit values correctly', () => { const wrapper = createWrapper() - expect(wrapper.text()).toContain('$10.00') // totalCredits - expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid + expect(wrapper.text()).toContain('10.00 Credits') + expect(wrapper.text()).toContain('5.00 Credits') }) it('shows loading skeleton when fetching balance', () => { diff --git a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts b/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts index 1d64129a40..5cb17af670 100644 --- a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts @@ -1,9 +1,21 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as comfyCredits from '@/base/credits/comfyCredits' import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: () => 'Credits', + locale: { value: 'en-US' } + }) + } +}) + // Mock Firebase Auth and related modules vi.mock('vuefire', () => ({ useFirebaseAuth: vi.fn(() => ({ @@ -55,14 +67,6 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({ }) })) -// Mock formatMetronomeCurrency -vi.mock('@/utils/formatUtil', () => ({ - formatMetronomeCurrency: vi.fn((micros: number) => { - // Simple mock that converts micros to dollars - return (micros / 1000000).toFixed(2) - }) -})) - describe('useSubscriptionCredits', () => { let authStore: ReturnType @@ -73,63 +77,62 @@ describe('useSubscriptionCredits', () => { }) describe('totalCredits', () => { - it('should return "0.00" when balance is null', () => { + it('should return "0.00 Credits" when balance is null', () => { authStore.balance = null const { totalCredits } = useSubscriptionCredits() - expect(totalCredits.value).toBe('0.00') + expect(totalCredits.value).toBe('0.00 Credits') }) - it('should return "0.00" when amount_micros is missing', () => { + it('should return "0.00 Credits" when amount_micros is missing', () => { authStore.balance = {} as any const { totalCredits } = useSubscriptionCredits() - expect(totalCredits.value).toBe('0.00') + expect(totalCredits.value).toBe('0.00 Credits') }) it('should format amount_micros correctly', () => { - authStore.balance = { amount_micros: 5000000 } as any + authStore.balance = { amount_micros: 210 } as any const { totalCredits } = useSubscriptionCredits() - expect(totalCredits.value).toBe('5.00') + expect(totalCredits.value).toBe('1.00 Credits') }) it('should handle formatting errors gracefully', async () => { - const mockFormatMetronomeCurrency = vi.mocked( - await import('@/utils/formatUtil') - ).formatMetronomeCurrency - mockFormatMetronomeCurrency.mockImplementationOnce(() => { + const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents') + formatSpy.mockImplementationOnce(() => { throw new Error('Formatting error') }) - authStore.balance = { amount_micros: 5000000 } as any + authStore.balance = { amount_micros: 210 } as any const { totalCredits } = useSubscriptionCredits() - expect(totalCredits.value).toBe('0.00') + expect(totalCredits.value).toBe('0.00 Credits') + formatSpy.mockRestore() }) }) describe('monthlyBonusCredits', () => { - it('should return "0.00" when cloud_credit_balance_micros is missing', () => { + it('should return "0.00 Credits" when cloud_credit_balance_micros is missing', () => { authStore.balance = {} as any const { monthlyBonusCredits } = useSubscriptionCredits() - expect(monthlyBonusCredits.value).toBe('0.00') + expect(monthlyBonusCredits.value).toBe('0.00 Credits') }) it('should format cloud_credit_balance_micros correctly', () => { - authStore.balance = { cloud_credit_balance_micros: 2500000 } as any + authStore.balance = { cloud_credit_balance_micros: 420 } as any const { monthlyBonusCredits } = useSubscriptionCredits() - expect(monthlyBonusCredits.value).toBe('2.50') + expect(monthlyBonusCredits.value).toBe('2.00 Credits') }) }) describe('prepaidCredits', () => { - it('should return "0.00" when prepaid_balance_micros is missing', () => { + it('should return "0.00 Credits" when prepaid_balance_micros is missing', () => { authStore.balance = {} as any const { prepaidCredits } = useSubscriptionCredits() - expect(prepaidCredits.value).toBe('0.00') + expect(prepaidCredits.value).toBe('0.00 Credits') }) it('should format prepaid_balance_micros correctly', () => { - authStore.balance = { prepaid_balance_micros: 7500000 } as any + authStore.balance = { prepaid_balance_micros: 630 } as any const { prepaidCredits } = useSubscriptionCredits() - expect(prepaidCredits.value).toBe('7.50') + expect(prepaidCredits.value).toBe('3.00 Credits') }) })