Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
142 changes: 142 additions & 0 deletions src/base/credits/comfyCredits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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 CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent

export const usdToCents = (usd: number): number => Math.round(usd * 100)

export const centsToCredits = (cents: number): number =>
Math.round(cents * COMFY_CREDIT_RATE_CENTS)

export const creditsToCents = (credits: number): number =>
Math.round(credits / COMFY_CREDIT_RATE_CENTS)

export const usdToCredits = (usd: number): number =>
Math.round(usd * CREDITS_PER_USD)

export const creditsToUsd = (credits: number): number =>
Math.round((credits / CREDITS_PER_USD) * 100) / 100

export type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}

export type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}

export type FormatFromUsdOptions = {
usd: 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
}: FormatFromCentsOptions): string =>
formatCredits({
value: centsToCredits(cents),
locale,
numberOptions
})

export const formatCreditsFromUsd = ({
usd,
locale,
numberOptions
}: FormatFromUsdOptions): string =>
formatCredits({
value: usdToCredits(usd),
locale,
numberOptions
})

// Special conversion for subscription backend data
// Backend sends values as "micros" but they are really in a special format where 211 units = 1 credit
export const formatCreditsFromSubscriptionMicros = ({
micros,
locale,
numberOptions
}: {
micros: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}): string =>
formatCredits({
value: micros / CREDITS_PER_USD,
locale,
numberOptions
})

export const formatUsd = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({
value,
locale,
options: numberOptions
})

export const formatUsdFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatUsd({
value: cents / 100,
locale,
numberOptions
})

/**
* Clamps a USD value to the allowed range for credit purchases
* @param value - The USD amount to clamp
* @returns The clamped value between $1 and $1000, or 0 if NaN
*/
export const clampUsd = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(1000, Math.max(1, value))
}
13 changes: 10 additions & 3 deletions src/components/common/UserCredit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@
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 { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'

const { textClass } = defineProps<{
textClass?: string
Expand All @@ -38,9 +39,15 @@ const { textClass } = defineProps<{
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
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')}`
})
</script>
41 changes: 41 additions & 0 deletions src/components/dialog/content/TopUpCreditsDialogContent.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import TopUpCreditsDialogContent from './TopUpCreditsDialogContent.vue'

interface TopUpCreditsStoryArgs {
refreshDate?: string
}

const meta: Meta<TopUpCreditsStoryArgs> = {
title: 'Components/Dialog/TopUpCreditsDialogContent',
component: TopUpCreditsDialogContent,
argTypes: {
refreshDate: {
control: 'text',
description: 'Date when credits refresh'
}
},
parameters: {
docs: {
description: {
component:
'Credit top-up dialog content. Design is controlled by the `subscription_tiers_enabled` feature flag (defaults to new design).'
}
}
}
}

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: {
refreshDate: 'Dec 16, 2025'
}
}

export const WithoutRefreshDate: Story = {
args: {
refreshDate: undefined
}
}
Loading
Loading