mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
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:
125
src/base/credits/comfyCredits.ts
Normal file
125
src/base/credits/comfyCredits.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal file
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
46
tests-ui/tests/base/credits/comfyCredits.test.ts
Normal file
46
tests-ui/tests/base/credits/comfyCredits.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user