add shared comfy credit conversion helpers (#7061)

Introduces cents<->usd<->credit converters plus basic formatters and
adds test. Lays groundwork to start converting UI components into
displaying comfy credits.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7061-add-shared-comfy-credit-conversion-helpers-2bb6d73d3650810bb34fdf9bb3fc115b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-12-09 04:11:27 -08:00
committed by GitHub
parent 8209f5a108
commit aef40834f3
14 changed files with 628 additions and 159 deletions

View File

@@ -0,0 +1,125 @@
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
})
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))
}

View File

@@ -26,10 +26,11 @@
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const { textClass } = defineProps<{ const { textClass } = defineProps<{
textClass?: string textClass?: string
@@ -38,9 +39,15 @@ const { textClass } = defineProps<{
const authStore = useFirebaseAuthStore() const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags() const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance) const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()
const formattedBalance = computed(() => { const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00' // Backend returns cents despite the *_micros naming convention.
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd') const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return `${amount} ${t('credits.credits')}`
}) })
</script> </script>

View File

@@ -1,5 +1,65 @@
<template> <template>
<div class="flex w-96 flex-col gap-10 p-2"> <!-- New Credits Design (default) -->
<div
v-if="useNewDesign"
class="flex w-96 flex-col gap-8 p-8 bg-node-component-surface rounded-2xl border border-border-primary"
>
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-foreground-primary m-0">
{{ $t('credits.topUp.addMoreCredits') }}
</h1>
<p class="text-sm text-foreground-secondary m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" />
<span class="text-sm text-foreground-secondary">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="refreshDate" class="text-sm text-foreground-secondary">
{{ $t('credits.refreshes', { date: refreshDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-foreground-secondary">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-foreground-secondary">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
class="w-full"
@click="handleBuy"
/>
</div>
<!-- Legacy Design -->
<div v-else class="flex w-96 flex-col gap-10 p-2">
<div v-if="isInsufficientCredits" class="flex flex-col gap-4"> <div v-if="isInsufficientCredits" class="flex flex-col gap-4">
<h1 class="my-0 text-2xl leading-normal font-medium"> <h1 class="my-0 text-2xl leading-normal font-medium">
{{ $t('credits.topUp.insufficientTitle') }} {{ $t('credits.topUp.insufficientTitle') }}
@@ -34,14 +94,14 @@
>{{ $t('credits.topUp.quickPurchase') }}:</span >{{ $t('credits.topUp.quickPurchase') }}:</span
> >
<div class="grid grid-cols-[2fr_1fr] gap-2"> <div class="grid grid-cols-[2fr_1fr] gap-2">
<CreditTopUpOption <LegacyCreditTopUpOption
v-for="amount in amountOptions" v-for="amount in amountOptions"
:key="amount" :key="amount"
:amount="amount" :amount="amount"
:preselected="amount === preselectedAmountOption" :preselected="amount === preselectedAmountOption"
/> />
<CreditTopUpOption :amount="100" :preselected="false" editable /> <LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
</div> </div>
</div> </div>
</div> </div>
@@ -49,23 +109,107 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button' import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
creditsToUsd,
formatCredits,
formatUsd
} from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue' import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import CreditTopUpOption from './credit/CreditTopUpOption.vue' import CreditTopUpOption from './credit/CreditTopUpOption.vue'
import LegacyCreditTopUpOption from './credit/LegacyCreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const { const {
refreshDate,
isInsufficientCredits = false, isInsufficientCredits = false,
amountOptions = [5, 10, 20, 50], amountOptions = [5, 10, 20, 50],
preselectedAmountOption = 10 preselectedAmountOption = 10
} = defineProps<{ } = defineProps<{
refreshDate?: string
isInsufficientCredits?: boolean isInsufficientCredits?: boolean
amountOptions?: number[] amountOptions?: number[]
preselectedAmountOption?: number preselectedAmountOption?: number
}>() }>()
const { flags } = useFeatureFlags()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
const { t, locale } = useI18n()
const authActions = useFirebaseAuthActions() const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1000,
description: t('credits.topUp.videosEstimate', { count: 100 })
},
{
credits: 5000,
description: t('credits.topUp.videosEstimate', { count: 500 })
},
{
credits: 10000,
description: t('credits.topUp.videosEstimate', { count: 1000 })
},
{
credits: 20000,
description: t('credits.topUp.videosEstimate', { count: 2000 })
}
]
const handleBuy = async () => {
if (!selectedCredits.value) return
loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
detail: t('credits.topUp.purchaseSuccessDetail', {
credits: formatCredits({
value: selectedCredits.value,
locale: locale.value
}),
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
}),
life: 3000
})
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
const handleSeeDetails = async () => { const handleSeeDetails = async () => {
await authActions.accessBillingPortal() await authActions.accessBillingPortal()

View File

@@ -1,81 +1,43 @@
<template> <template>
<div class="flex items-center gap-2"> <div
<Tag class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
severity="secondary" :class="[
icon="pi pi-dollar" selected
rounded ? 'bg-surface-secondary border-2 border-primary'
class="p-1 text-amber-400" : 'bg-surface-tertiary border border-border-primary hover:bg-surface-secondary'
/> ]"
<InputNumber @click="$emit('select')"
v-if="editable" >
v-model="customAmount" <div class="flex flex-col">
:min="1" <span class="text-base font-medium text-foreground-primary">
:max="1000" {{ formattedCredits }}
:step="1" </span>
show-buttons <span class="text-sm text-foreground-secondary">
:allow-empty="false" {{ description }}
:highlight-on-focus="true" </span>
pt:pc-input-text:root="w-24" </div>
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
/>
<span v-else class="text-xl">{{ amount }}</span>
</div> </div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button' import { computed } from 'vue'
import InputNumber from 'primevue/inputnumber' import { useI18n } from 'vue-i18n'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { formatCredits } from '@/base/credits/comfyCredits'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions() const { credits, description, selected } = defineProps<{
const telemetry = useTelemetry() credits: number
description: string
const { selected: boolean
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
}>() }>()
const customAmount = ref(amount) defineEmits<{
const didClickBuyNow = ref(false) select: []
const loading = ref(false) }>()
const handleBuyNow = async () => { const { locale } = useI18n()
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true const formattedCredits = computed(() => {
await authActions.purchaseCredits(creditAmount) return formatCredits({ value: credits, locale: locale.value })
loading.value = false
didClickBuyNow.value = true
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
}) })
</script> </script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
<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
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
clampUsd,
formatCreditsFromUsd,
formatUsd
} from '@/base/credits/comfyCredits'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
const { t, locale } = useI18n()
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 = displayUsdAmount.value
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
try {
await authActions.purchaseCredits(creditAmount)
didClickBuyNow.value = true
} finally {
loading.value = false
}
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
})
</script>

View File

@@ -63,7 +63,7 @@ export function useFeatureFlags() {
remoteConfig.value.subscription_tiers_enabled ?? remoteConfig.value.subscription_tiers_enabled ??
api.getServerFeature( api.getServerFeature(
ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED, ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED,
false true // Default to true (new design)
) )
) )
} }

View File

@@ -1845,8 +1845,21 @@
"maxAmount": "(Max. $1,000 USD)", "maxAmount": "(Max. $1,000 USD)",
"buyNow": "Buy now", "buyNow": "Buy now",
"seeDetails": "See details", "seeDetails": "See details",
"topUp": "Top Up" "topUp": "Top Up",
"addMoreCredits": "Add more credits",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseSuccess": "Purchase Successful",
"purchaseSuccessDetail": "Successfully purchased {credits} credits for {amount}",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred"
}, },
"creditsAvailable": "Credits available",
"refreshes": "Refreshes {date}",
"eventType": "Event Type", "eventType": "Event Type",
"details": "Details", "details": "Details",
"time": "Time", "time": "Time",

View File

@@ -121,7 +121,7 @@
height="2rem" height="2rem"
/> />
<div v-else class="text-2xl font-bold"> <div v-else class="text-2xl font-bold">
${{ totalCredits }} {{ totalCredits }}
</div> </div>
</div> </div>
@@ -134,7 +134,7 @@
height="1rem" height="1rem"
/> />
<div v-else class="text-sm text-text-secondary font-bold"> <div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }} {{ monthlyBonusCredits }}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-sm text-text-secondary"> <div class="text-sm text-text-secondary">
@@ -162,7 +162,7 @@
height="1rem" height="1rem"
/> />
<div v-else class="text-sm text-text-secondary font-bold"> <div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }} {{ prepaidCredits }}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-sm text-text-secondary"> <div class="text-sm text-text-secondary">

View File

@@ -1,58 +1,37 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
/** /**
* Composable for handling subscription credit calculations and formatting * Composable for handling subscription credit calculations and formatting
*/ */
export function useSubscriptionCredits() { export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore() const authStore = useFirebaseAuthStore()
const { t, locale } = useI18n()
const totalCredits = computed(() => { const formatBalance = (maybeCents?: number) => {
if (!authStore.balance?.amount_micros) return '0.00' // Backend returns cents despite the *_micros naming convention.
try { const cents = maybeCents ?? 0
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd') const amount = formatCreditsFromCents({
} catch (error) { cents,
console.error( locale: locale.value
'[useSubscriptionCredits] Error formatting total credits:', })
error return `${amount} ${t('credits.credits')}`
) }
return '0.00'
}
})
const monthlyBonusCredits = computed(() => { const totalCredits = computed(() =>
if (!authStore.balance?.cloud_credit_balance_micros) return '0.00' formatBalance(authStore.balance?.amount_micros)
try { )
return formatMetronomeCurrency(
authStore.balance.cloud_credit_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const prepaidCredits = computed(() => { const monthlyBonusCredits = computed(() =>
if (!authStore.balance?.prepaid_balance_micros) return '0.00' formatBalance(authStore.balance?.cloud_credit_balance_micros)
try { )
return formatMetronomeCurrency(
authStore.balance.prepaid_balance_micros, const prepaidCredits = computed(() =>
'usd' formatBalance(authStore.balance?.prepaid_balance_micros)
) )
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance) const isLoadingBalance = computed(() => authStore.isFetchingBalance)

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest'
import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
creditsToCents,
creditsToUsd,
formatCredits,
formatCreditsFromCents,
formatCreditsFromUsd,
formatUsd,
formatUsdFromCents,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
describe('comfyCredits helpers', () => {
test('exposes the fixed conversion rate', () => {
expect(CREDITS_PER_USD).toBe(211)
expect(COMFY_CREDIT_RATE_CENTS).toBeCloseTo(2.11) // credits per cent
})
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(100)).toBe(211) // 100 cents = 211 credits
expect(creditsToCents(211)).toBe(100) // 211 credits = 100 cents
})
test('converts USD to credits and back', () => {
expect(usdToCredits(1)).toBe(211) // 1 USD = 211 credits
expect(creditsToUsd(211)).toBe(1) // 211 credits = 1 USD
})
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: 100, locale })).toBe('211.00')
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
})

View File

@@ -0,0 +1,52 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
selected: false,
...props
},
global: {
plugins: [i18n]
}
})
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies selected styling when selected', () => {
const wrapper = mountOption({ selected: true })
expect(wrapper.find('div').classes()).toContain('bg-surface-secondary')
expect(wrapper.find('div').classes()).toContain('border-primary')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain('bg-surface-tertiary')
expect(wrapper.find('div').classes()).toContain('border-border-primary')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
})
})

View File

@@ -17,6 +17,14 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
}) })
})) }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
subscriptionTiersEnabled: false // Test legacy badge behavior
}
})
}))
const { updateSubgraphCredits } = usePriceBadge() const { updateSubgraphCredits } = usePriceBadge()
const mockNode = new LGraphNode('mock node') const mockNode = new LGraphNode('mock node')

View File

@@ -17,9 +17,9 @@ const mockSubscriptionData = {
} }
const mockCreditsData = { const mockCreditsData = {
totalCredits: '10.00', totalCredits: '10.00 Credits',
monthlyBonusCredits: '5.00', monthlyBonusCredits: '5.00 Credits',
prepaidCredits: '5.00', prepaidCredits: '5.00 Credits',
isLoadingBalance: false isLoadingBalance: false
} }
@@ -154,8 +154,8 @@ describe('SubscriptionPanel', () => {
describe('credit display functionality', () => { describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => { it('displays dynamic credit values correctly', () => {
const wrapper = createWrapper() const wrapper = createWrapper()
expect(wrapper.text()).toContain('$10.00') // totalCredits expect(wrapper.text()).toContain('10.00 Credits')
expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid expect(wrapper.text()).toContain('5.00 Credits')
}) })
it('shows loading skeleton when fetching balance', () => { it('shows loading skeleton when fetching balance', () => {

View File

@@ -1,8 +1,27 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as comfyCredits from '@/base/credits/comfyCredits'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits' import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
vi.mock(
'vue-i18n',
async (importOriginal: () => Promise<typeof import('vue-i18n')>) => {
const actual = await importOriginal()
return {
...actual,
useI18n: () => ({
t: () => 'Credits',
locale: { value: 'en-US' }
})
}
}
)
// Mock Firebase Auth and related modules // Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({ vi.mock('vuefire', () => ({
@@ -55,14 +74,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', () => { describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore> let authStore: ReturnType<typeof useFirebaseAuthStore>
@@ -73,63 +84,66 @@ describe('useSubscriptionCredits', () => {
}) })
describe('totalCredits', () => { describe('totalCredits', () => {
it('should return "0.00" when balance is null', () => { it('should return "0.00 Credits" when balance is null', () => {
authStore.balance = null authStore.balance = null
const { totalCredits } = useSubscriptionCredits() 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 authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits() const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00') expect(totalCredits.value).toBe('0.00 Credits')
}) })
it('should format amount_micros correctly', () => { it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 5000000 } as any authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits() const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('5.00') expect(totalCredits.value).toBe('211.00 Credits')
}) })
it('should handle formatting errors gracefully', async () => { it('should handle formatting errors by throwing', async () => {
const mockFormatMetronomeCurrency = vi.mocked( const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents')
await import('@/utils/formatUtil') formatSpy.mockImplementationOnce(() => {
).formatMetronomeCurrency
mockFormatMetronomeCurrency.mockImplementationOnce(() => {
throw new Error('Formatting error') throw new Error('Formatting error')
}) })
authStore.balance = { amount_micros: 5000000 } as any authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits() const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00') expect(() => totalCredits.value).toThrow('Formatting error')
formatSpy.mockRestore()
}) })
}) })
describe('monthlyBonusCredits', () => { 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 authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits() const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00') expect(monthlyBonusCredits.value).toBe('0.00 Credits')
}) })
it('should format cloud_credit_balance_micros correctly', () => { it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = { cloud_credit_balance_micros: 2500000 } as any authStore.balance = {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits() const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('2.50') expect(monthlyBonusCredits.value).toBe('422.00 Credits')
}) })
}) })
describe('prepaidCredits', () => { 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 authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits() const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00') expect(prepaidCredits.value).toBe('0.00 Credits')
}) })
it('should format prepaid_balance_micros correctly', () => { it('should format prepaid_balance_micros correctly', () => {
authStore.balance = { prepaid_balance_micros: 7500000 } as any authStore.balance = {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits() const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('7.50') expect(prepaidCredits.value).toBe('633.00 Credits')
}) })
}) })