Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/base/credits/comfyCredits.ts
Original file line number Diff line number Diff line change
@@ -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
})
Comment on lines +54 to +115
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider simplifying function signatures with parameter reordering.

Past review comments suggest that having undefined as a placeholder for locale (e.g., formatCredits(value, undefined, options)) is awkward. The current object-based parameters avoid this, but another approach would be to match Intl.NumberFormat's parameter order: (value, locale?, options?).

Example alternative signature:

export const formatCredits = (
  value: number,
  locale?: string,
  numberOptions?: Intl.NumberFormatOptions
): string => formatNumber({ value, locale, options: numberOptions })

This would allow calls like:

formatCredits(100)                    // default locale
formatCredits(100, 'en-US')           // explicit locale
formatCredits(100, 'en-US', options)  // with options

However, the current object-based approach is also valid and provides better clarity at call sites, especially when only some parameters are needed. Consider team preferences and consistency with other utility functions in the codebase.

Based on past review discussion about parameter ergonomics.

15 changes: 11 additions & 4 deletions src/components/common/UserCredit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div v-else class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
Expand All @@ -21,19 +21,26 @@
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
}>()

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')}`
})
</script>
70 changes: 53 additions & 17 deletions src/components/dialog/content/credit/CreditTopUpOption.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,36 @@
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
<InputNumber
v-if="editable"
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
pt:pc-input-text:root="w-24"
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
/>
<span v-else class="text-xl">{{ amount }}</span>
<div v-if="editable" class="flex items-center gap-2">
<InputNumber
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
prefix="$"
pt:pc-input-text:root="w-28"
@blur="
(e: InputNumberBlurEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
@input="
(e: InputNumberInputEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
/>
<span class="text-xs text-muted">{{ formattedCredits }}</span>
</div>
<div v-else class="flex flex-col leading-tight">
<span class="text-xl font-semibold">{{ formattedCredits }}</span>
<span class="text-xs text-muted">{{ formattedUsd }}</span>
</div>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
Expand All @@ -40,8 +52,10 @@ import type {
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import { formatCreditsFromUsd, formatUsd } from '@/base/credits/comfyCredits'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'

Expand All @@ -61,9 +75,31 @@ const {
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
const { t, locale } = useI18n()

const clampUsd = (value: number) => {
const safe = Number.isNaN(value) ? 0 : value
return Math.min(1000, Math.max(1, safe))
}

const displayUsdAmount = computed(() =>
editable ? clampUsd(Number(customAmount.value)) : clampUsd(amount)
)

const formattedCredits = computed(
() =>
`${formatCreditsFromUsd({
usd: displayUsdAmount.value,
locale: locale.value
})} ${t('credits.credits')}`
)

const formattedUsd = computed(
() => `$${formatUsd({ value: displayUsdAmount.value, locale: locale.value })}`
)

const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
const creditAmount = displayUsdAmount.value
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)

loading.value = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,59 @@
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'

/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const { t, locale } = useI18n()

const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return `${amount} ${t('credits.credits')}`
}
Comment on lines +14 to +22
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for backend API response types related to subscription/credits
rg -n "GetCustomerBalance|cloud_credit_balance|prepaid_balance" --type ts -C 5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 16028


🏁 Script executed:

# Look for type definitions and API responses
fd -e ts -e tsx | xargs rg -l "micros" | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 390


🏁 Script executed:

# Search for any conversion or formatting logic around these fields
rg -n "micros|formatCredits" --type ts --type tsx -A 3 -B 3

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Check for API schema or backend type definitions
fd -e ts -e tsx | xargs rg -l "backend.*return|api.*response" | head -10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 164


🏁 Script executed:

# Find formatCreditsFromCents implementation
rg -n "formatCreditsFromCents" --type ts -B 5 -A 15

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 9756


🏁 Script executed:

# Look for any constants or documentation about the 210 division factor
rg -n "210|micros|cents" --type ts -C 3 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 6997


🏁 Script executed:

# Check the exact API documentation comment about microamount
rg -n "microamount|micros" packages/registry-types/src/comfyRegistryTypes.ts -B 2 -A 2 | head -60

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3006


Backend API contract violation: field names and documentation don't match unit values.

The backend API schema documents amount_micros, cloud_credit_balance_micros, and prepaid_balance_micros as containing "microamount (1/1,000,000 of the currency unit)". However, the code divides these values by 210 (COMFY_CREDIT_RATE_CENTS), treating them as cents (1/100). This is a unit conversion mismatch:

  • Backend claims: microamount (1/1,000,000)
  • Code assumes: cents (1/100)

The comment on line 15 is misleading—it's not a naming issue; the fields ARE correctly named with _micros suffix. The actual problem is a discrepancy between the backend schema documentation and what it actually returns. Either the schema is incorrect (should say "cents" not "microamount"), or the code applies the wrong conversion factor.

This needs clarification from the backend team or verification through actual API testing to ensure the unit conversion is correct.

🤖 Prompt for AI Agents
In src/platform/cloud/subscription/composables/useSubscriptionCredits.ts around
lines 14 to 22, the inline comment and conversion assume the backend returns
cents while the API field names and docs use microamounts; fix this by verifying
the actual unit returned (ask backend or inspect API responses), then update the
conversion to use the correct factor (1,000,000 -> currency units or 100 ->
cents) and remove or replace the misleading comment; if the backend truly
returns cents, change the field docs or add a clear code comment stating that
mismatch, and add a unit-focused test asserting the expected numeric conversion.


const totalCredits = computed(() => {
if (!authStore.balance?.amount_micros) return '0.00'
try {
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
return formatBalance(authStore.balance?.amount_micros)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting total credits:',
error
)
return '0.00'
return formatBalance(0)
}
})

const monthlyBonusCredits = computed(() => {
if (!authStore.balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.cloud_credit_balance_micros,
'usd'
)
return formatBalance(authStore.balance?.cloud_credit_balance_micros)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
return formatBalance(0)
}
})

const prepaidCredits = computed(() => {
if (!authStore.balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.prepaid_balance_micros,
'usd'
)
return formatBalance(authStore.balance?.prepaid_balance_micros)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
return formatBalance(0)
}
})

Expand Down
46 changes: 46 additions & 0 deletions tests-ui/tests/base/credits/comfyCredits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest'

import {
COMFY_CREDIT_RATE_CENTS,
COMFY_CREDIT_RATE_USD,
centsToCredits,
creditsToCents,
creditsToUsd,
formatCredits,
formatCreditsFromCents,
formatCreditsFromUsd,
formatUsd,
formatUsdFromCents,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'

describe('comfyCredits helpers', () => {
test('exposes the fixed conversion rate', () => {
expect(COMFY_CREDIT_RATE_CENTS).toBe(210)
expect(COMFY_CREDIT_RATE_USD).toBeCloseTo(2.1)
})

test('converts between USD and cents', () => {
expect(usdToCents(1.23)).toBe(123)
expect(formatUsdFromCents({ cents: 123, locale: 'en-US' })).toBe('1.23')
})

test('converts cents to credits and back', () => {
expect(centsToCredits(210)).toBeCloseTo(1)
expect(creditsToCents(5)).toBe(1050)
})

test('converts USD to credits and back', () => {
expect(usdToCredits(2.1)).toBeCloseTo(1)
expect(creditsToUsd(3.5)).toBeCloseTo(7.35)
})

test('formats credits and USD values using en-US locale', () => {
const locale = 'en-US'
expect(formatCredits({ value: 1234.567, locale })).toBe('1,234.57')
expect(formatCreditsFromCents({ cents: 210, locale })).toBe('1.00')
expect(formatCreditsFromUsd({ usd: 4.2, locale })).toBe('2.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
})
Loading
Loading