Skip to content

Commit aef4083

Browse files
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>
1 parent 8209f5a commit aef4083

File tree

14 files changed

+629
-160
lines changed

14 files changed

+629
-160
lines changed

src/base/credits/comfyCredits.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
2+
minimumFractionDigits: 2,
3+
maximumFractionDigits: 2
4+
}
5+
6+
const formatNumber = ({
7+
value,
8+
locale,
9+
options
10+
}: {
11+
value: number
12+
locale?: string
13+
options?: Intl.NumberFormatOptions
14+
}): string => {
15+
const merged: Intl.NumberFormatOptions = {
16+
...DEFAULT_NUMBER_FORMAT,
17+
...options
18+
}
19+
20+
if (
21+
typeof merged.maximumFractionDigits === 'number' &&
22+
typeof merged.minimumFractionDigits === 'number' &&
23+
merged.maximumFractionDigits < merged.minimumFractionDigits
24+
) {
25+
merged.minimumFractionDigits = merged.maximumFractionDigits
26+
}
27+
28+
return new Intl.NumberFormat(locale, merged).format(value)
29+
}
30+
31+
export const CREDITS_PER_USD = 211
32+
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
33+
34+
export const usdToCents = (usd: number): number => Math.round(usd * 100)
35+
36+
export const centsToCredits = (cents: number): number =>
37+
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
38+
39+
export const creditsToCents = (credits: number): number =>
40+
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
41+
42+
export const usdToCredits = (usd: number): number =>
43+
Math.round(usd * CREDITS_PER_USD)
44+
45+
export const creditsToUsd = (credits: number): number =>
46+
Math.round((credits / CREDITS_PER_USD) * 100) / 100
47+
48+
export type FormatOptions = {
49+
value: number
50+
locale?: string
51+
numberOptions?: Intl.NumberFormatOptions
52+
}
53+
54+
export type FormatFromCentsOptions = {
55+
cents: number
56+
locale?: string
57+
numberOptions?: Intl.NumberFormatOptions
58+
}
59+
60+
export type FormatFromUsdOptions = {
61+
usd: number
62+
locale?: string
63+
numberOptions?: Intl.NumberFormatOptions
64+
}
65+
66+
export const formatCredits = ({
67+
value,
68+
locale,
69+
numberOptions
70+
}: FormatOptions): string =>
71+
formatNumber({ value, locale, options: numberOptions })
72+
73+
export const formatCreditsFromCents = ({
74+
cents,
75+
locale,
76+
numberOptions
77+
}: FormatFromCentsOptions): string =>
78+
formatCredits({
79+
value: centsToCredits(cents),
80+
locale,
81+
numberOptions
82+
})
83+
84+
export const formatCreditsFromUsd = ({
85+
usd,
86+
locale,
87+
numberOptions
88+
}: FormatFromUsdOptions): string =>
89+
formatCredits({
90+
value: usdToCredits(usd),
91+
locale,
92+
numberOptions
93+
})
94+
95+
export const formatUsd = ({
96+
value,
97+
locale,
98+
numberOptions
99+
}: FormatOptions): string =>
100+
formatNumber({
101+
value,
102+
locale,
103+
options: numberOptions
104+
})
105+
106+
export const formatUsdFromCents = ({
107+
cents,
108+
locale,
109+
numberOptions
110+
}: FormatFromCentsOptions): string =>
111+
formatUsd({
112+
value: cents / 100,
113+
locale,
114+
numberOptions
115+
})
116+
117+
/**
118+
* Clamps a USD value to the allowed range for credit purchases
119+
* @param value - The USD amount to clamp
120+
* @returns The clamped value between $1 and $1000, or 0 if NaN
121+
*/
122+
export const clampUsd = (value: number): number => {
123+
if (Number.isNaN(value)) return 0
124+
return Math.min(1000, Math.max(1, value))
125+
}

src/components/common/UserCredit.vue

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
import Skeleton from 'primevue/skeleton'
2727
import Tag from 'primevue/tag'
2828
import { computed } from 'vue'
29+
import { useI18n } from 'vue-i18n'
2930
31+
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
3032
import { useFeatureFlags } from '@/composables/useFeatureFlags'
3133
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
32-
import { formatMetronomeCurrency } from '@/utils/formatUtil'
3334
3435
const { textClass } = defineProps<{
3536
textClass?: string
@@ -38,9 +39,15 @@ const { textClass } = defineProps<{
3839
const authStore = useFirebaseAuthStore()
3940
const { flags } = useFeatureFlags()
4041
const balanceLoading = computed(() => authStore.isFetchingBalance)
42+
const { t, locale } = useI18n()
4143
4244
const formattedBalance = computed(() => {
43-
if (!authStore.balance) return '0.00'
44-
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
45+
// Backend returns cents despite the *_micros naming convention.
46+
const cents = authStore.balance?.amount_micros ?? 0
47+
const amount = formatCreditsFromCents({
48+
cents,
49+
locale: locale.value
50+
})
51+
return `${amount} ${t('credits.credits')}`
4552
})
4653
</script>

src/components/dialog/content/TopUpCreditsDialogContent.vue

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,65 @@
11
<template>
2-
<div class="flex w-96 flex-col gap-10 p-2">
2+
<!-- New Credits Design (default) -->
3+
<div
4+
v-if="useNewDesign"
5+
class="flex w-96 flex-col gap-8 p-8 bg-node-component-surface rounded-2xl border border-border-primary"
6+
>
7+
<!-- Header -->
8+
<div class="flex flex-col gap-4">
9+
<h1 class="text-2xl font-semibold text-foreground-primary m-0">
10+
{{ $t('credits.topUp.addMoreCredits') }}
11+
</h1>
12+
<p class="text-sm text-foreground-secondary m-0">
13+
{{ $t('credits.topUp.creditsDescription') }}
14+
</p>
15+
</div>
16+
17+
<!-- Current Balance Section -->
18+
<div class="flex flex-col gap-4">
19+
<div class="flex items-baseline gap-2">
20+
<UserCredit text-class="text-3xl font-bold" />
21+
<span class="text-sm text-foreground-secondary">{{
22+
$t('credits.creditsAvailable')
23+
}}</span>
24+
</div>
25+
<div v-if="refreshDate" class="text-sm text-foreground-secondary">
26+
{{ $t('credits.refreshes', { date: refreshDate }) }}
27+
</div>
28+
</div>
29+
30+
<!-- Credit Options Section -->
31+
<div class="flex flex-col gap-4">
32+
<span class="text-sm text-foreground-secondary">
33+
{{ $t('credits.topUp.howManyCredits') }}
34+
</span>
35+
<div class="flex flex-col gap-2">
36+
<CreditTopUpOption
37+
v-for="option in creditOptions"
38+
:key="option.credits"
39+
:credits="option.credits"
40+
:description="option.description"
41+
:selected="selectedCredits === option.credits"
42+
@select="selectedCredits = option.credits"
43+
/>
44+
</div>
45+
<div class="text-xs text-foreground-secondary">
46+
{{ $t('credits.topUp.templateNote') }}
47+
</div>
48+
</div>
49+
50+
<!-- Buy Button -->
51+
<Button
52+
:disabled="!selectedCredits || loading"
53+
:loading="loading"
54+
severity="primary"
55+
:label="$t('credits.topUp.buy')"
56+
class="w-full"
57+
@click="handleBuy"
58+
/>
59+
</div>
60+
61+
<!-- Legacy Design -->
62+
<div v-else class="flex w-96 flex-col gap-10 p-2">
363
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
464
<h1 class="my-0 text-2xl leading-normal font-medium">
565
{{ $t('credits.topUp.insufficientTitle') }}
@@ -34,38 +94,122 @@
3494
>{{ $t('credits.topUp.quickPurchase') }}:</span
3595
>
3696
<div class="grid grid-cols-[2fr_1fr] gap-2">
37-
<CreditTopUpOption
97+
<LegacyCreditTopUpOption
3898
v-for="amount in amountOptions"
3999
:key="amount"
40100
:amount="amount"
41101
:preselected="amount === preselectedAmountOption"
42102
/>
43103

44-
<CreditTopUpOption :amount="100" :preselected="false" editable />
104+
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
45105
</div>
46106
</div>
47107
</div>
48108
</template>
49109

50110
<script setup lang="ts">
51111
import Button from 'primevue/button'
112+
import { useToast } from 'primevue/usetoast'
113+
import { computed, ref } from 'vue'
114+
import { useI18n } from 'vue-i18n'
52115
116+
import {
117+
creditsToUsd,
118+
formatCredits,
119+
formatUsd
120+
} from '@/base/credits/comfyCredits'
53121
import UserCredit from '@/components/common/UserCredit.vue'
54122
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
123+
import { useFeatureFlags } from '@/composables/useFeatureFlags'
124+
import { useTelemetry } from '@/platform/telemetry'
55125
56126
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
127+
import LegacyCreditTopUpOption from './credit/LegacyCreditTopUpOption.vue'
128+
129+
interface CreditOption {
130+
credits: number
131+
description: string
132+
}
57133
58134
const {
135+
refreshDate,
59136
isInsufficientCredits = false,
60137
amountOptions = [5, 10, 20, 50],
61138
preselectedAmountOption = 10
62139
} = defineProps<{
140+
refreshDate?: string
63141
isInsufficientCredits?: boolean
64142
amountOptions?: number[]
65143
preselectedAmountOption?: number
66144
}>()
67145
146+
const { flags } = useFeatureFlags()
147+
// Use feature flag to determine design - defaults to true (new design)
148+
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
149+
150+
const { t, locale } = useI18n()
68151
const authActions = useFirebaseAuthActions()
152+
const telemetry = useTelemetry()
153+
const toast = useToast()
154+
155+
const selectedCredits = ref<number | null>(null)
156+
const loading = ref(false)
157+
158+
const creditOptions: CreditOption[] = [
159+
{
160+
credits: 1000,
161+
description: t('credits.topUp.videosEstimate', { count: 100 })
162+
},
163+
{
164+
credits: 5000,
165+
description: t('credits.topUp.videosEstimate', { count: 500 })
166+
},
167+
{
168+
credits: 10000,
169+
description: t('credits.topUp.videosEstimate', { count: 1000 })
170+
},
171+
{
172+
credits: 20000,
173+
description: t('credits.topUp.videosEstimate', { count: 2000 })
174+
}
175+
]
176+
177+
const handleBuy = async () => {
178+
if (!selectedCredits.value) return
179+
180+
loading.value = true
181+
try {
182+
const usdAmount = creditsToUsd(selectedCredits.value)
183+
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
184+
await authActions.purchaseCredits(usdAmount)
185+
186+
toast.add({
187+
severity: 'success',
188+
summary: t('credits.topUp.purchaseSuccess'),
189+
detail: t('credits.topUp.purchaseSuccessDetail', {
190+
credits: formatCredits({
191+
value: selectedCredits.value,
192+
locale: locale.value
193+
}),
194+
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
195+
}),
196+
life: 3000
197+
})
198+
} catch (error) {
199+
console.error('Purchase failed:', error)
200+
201+
const errorMessage =
202+
error instanceof Error ? error.message : t('credits.topUp.unknownError')
203+
toast.add({
204+
severity: 'error',
205+
summary: t('credits.topUp.purchaseError'),
206+
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
207+
life: 5000
208+
})
209+
} finally {
210+
loading.value = false
211+
}
212+
}
69213
70214
const handleSeeDetails = async () => {
71215
await authActions.accessBillingPortal()

0 commit comments

Comments
 (0)