fix: 修复兑换码额度精度损失 (#6685)

* fix: 修复兑换码额度精度损失(#6680)

* fix(redemption): guard update data integrity
This commit is contained in:
lihu-001
2026-08-07 17:06:32 +08:00
committed by GitHub
parent 1da23d6b33
commit e926e5cace
5 changed files with 721 additions and 146 deletions
@@ -0,0 +1,439 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { Window } from 'happy-dom'
import type { Redemption } from '../../types'
// Use Bun's runner at runtime while reusing the Node test types installed here.
const bunTestModule = 'bun:test'
const { afterAll, afterEach, test } = (await import(bunTestModule)) as {
afterAll: typeof import('node:test').after
afterEach: typeof import('node:test').afterEach
test: typeof import('node:test').test
}
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'HTMLFormElement',
'HTMLLabelElement',
'HTMLFieldSetElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'MouseEvent',
'FocusEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act } = await import('react')
const { createRoot } = await import('react-dom/client')
const i18n = (await import('i18next')).default
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { Toaster, toast } = await import('sonner')
const { api } = await import('@/lib/api')
const { useSystemConfigStore } = await import('@/stores/system-config-store')
const { RedemptionsProvider } = await import('../redemptions-provider')
const { RedemptionsMutateDrawer } = await import('../redemptions-mutate-drawer')
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
'Failed to load': 'Failed to load',
'Loading...': 'Loading...',
'Save changes': 'Save changes',
'Something went wrong!': 'Something went wrong!',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }>
type MockableApi = {
get: ApiMethod
put: ApiMethod
}
type RenderedDrawer = {
host: HTMLDivElement
root: ReturnType<typeof createRoot>
}
type CurrencyFixture = {
quotaDisplayType: 'USD' | 'CNY'
usdExchangeRate: number
}
const apiClient = api as unknown as MockableApi
const originalGet = apiClient.get
const originalPut = apiClient.put
const originalConsoleLog = Reflect.get(console, 'log')
let renderedDrawer: RenderedDrawer | null = null
function redemption(id: number, quota = 500001): Redemption {
return {
id,
user_id: 1,
name: `code-${id}`,
key: `key-${id}`,
status: 1,
quota,
created_time: 1,
redeemed_time: 0,
expired_time: 0,
used_user_id: 0,
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
function drawerTree(currentRow: Redemption) {
return (
<I18nextProvider i18n={i18n}>
<RedemptionsProvider>
<RedemptionsMutateDrawer
open
currentRow={currentRow}
onOpenChange={() => undefined}
/>
</RedemptionsProvider>
<Toaster duration={60_000} />
</I18nextProvider>
)
}
async function renderDrawer(
currentRow: Redemption,
currency: CurrencyFixture = {
quotaDisplayType: 'USD',
usdExchangeRate: 1,
}
): Promise<void> {
useSystemConfigStore.getState().setConfig({
currency: {
displayInCurrency: true,
quotaDisplayType: currency.quotaDisplayType,
quotaPerUnit: 500000,
usdExchangeRate: currency.usdExchangeRate,
customCurrencySymbol: '¤',
customCurrencyExchangeRate: 1,
},
})
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
renderedDrawer = { host, root }
await act(async () => root.render(drawerTree(currentRow)))
}
async function rerenderDrawer(currentRow: Redemption): Promise<void> {
assert.ok(renderedDrawer)
await act(async () => renderedDrawer?.root.render(drawerTree(currentRow)))
}
async function waitForCondition(
condition: () => boolean,
failureMessage: string
): Promise<void> {
if (condition()) return
await new Promise<void>((resolve, reject) => {
const observer = new MutationObserver(() => {
if (!condition()) return
clearTimeout(timeoutId)
observer.disconnect()
resolve()
})
const timeoutId = setTimeout(() => {
observer.disconnect()
reject(new Error(`${failureMessage}: ${document.body.textContent}`))
}, 1500)
observer.observe(document, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
})
}
function getSaveButton(): HTMLButtonElement {
const button = document.querySelector<HTMLButtonElement>(
'button[form="redemption-form"][type="submit"]'
)
assert.ok(button)
return button
}
function getControlByLabel<T extends HTMLElement>(labelText: string): T {
const label = [...document.querySelectorAll<HTMLLabelElement>('label')].find(
(candidate) => candidate.textContent?.trim() === labelText
)
assert.ok(label, `Expected label "${labelText}"`)
assert.ok(label.htmlFor)
const control =
label.control ??
label
.closest('[data-slot="form-item"]')
?.querySelector<HTMLElement>('[data-slot="form-control"], input')
assert.ok(control)
return control as T
}
async function changeInput(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
domWindow.HTMLInputElement.prototype,
'value'
)?.set
assert.ok(valueSetter)
valueSetter.call(input, value)
input.dispatchEvent(
new domWindow.Event('input', { bubbles: true }) as unknown as Event
)
})
}
async function submitForm(): Promise<void> {
const form = document.querySelector<HTMLFormElement>('#redemption-form')
assert.ok(form)
await act(async () =>
form.dispatchEvent(
new domWindow.Event('submit', {
bubbles: true,
cancelable: true,
}) as unknown as Event
)
)
}
async function waitForLoadedForm(): Promise<void> {
await act(async () =>
waitForCondition(() => {
const saveButton = getSaveButton()
return (
saveButton.textContent?.includes('Save changes') === true &&
!saveButton.disabled
)
}, 'redemption drawer did not finish loading')
)
}
afterEach(async () => {
apiClient.get = originalGet
apiClient.put = originalPut
Reflect.set(console, 'log', originalConsoleLog)
toast.dismiss()
domWindow.localStorage.clear()
if (renderedDrawer) {
await act(async () => renderedDrawer?.root.unmount())
renderedDrawer.host.remove()
renderedDrawer = null
}
document.body.replaceChildren()
})
afterAll(() => {
domWindow.close()
})
test('redemption drawer shows the reported CNY quota without floating-point noise', async () => {
const original = redemption(1, 13888889)
apiClient.get = async () => ({ data: { success: true, data: original } })
await renderDrawer(original, {
quotaDisplayType: 'CNY',
usdExchangeRate: 7.2,
})
await waitForLoadedForm()
assert.equal(getControlByLabel<HTMLInputElement>('Quota (CNY)').value, '200')
})
test('redemption drawer blocks updates and reports an error when loading rejects', async () => {
const updates: unknown[] = []
Reflect.set(console, 'log', () => undefined)
apiClient.get = async () => {
throw new Error('network failure')
}
apiClient.put = async (_url, data) => {
updates.push(data)
return { data: { success: true } }
}
await renderDrawer(redemption(1))
await act(async () =>
waitForCondition(
() =>
document.body.textContent?.includes('Something went wrong!') === true,
'load error toast was not shown'
)
)
assert.equal(getSaveButton().disabled, true)
await submitForm()
assert.deepEqual(updates, [])
})
test('redemption drawer blocks updates and uses localized feedback for unsuccessful responses', async () => {
apiClient.get = async () => ({
data: { success: false, message: 'raw server message' },
})
await renderDrawer(redemption(1))
await act(async () =>
waitForCondition(
() => document.body.textContent?.includes('Failed to load') === true,
'unsuccessful-load toast was not shown'
)
)
assert.equal(getSaveButton().disabled, true)
assert.equal(document.body.textContent?.includes('raw server message'), false)
})
test('redemption drawer keeps the original quota when another field changes', async () => {
const original = redemption(1)
const updates: Array<Record<string, unknown>> = []
apiClient.get = async () => ({ data: { success: true, data: original } })
apiClient.put = async (_url, data) => {
assert.ok(data && typeof data === 'object')
updates.push(data as Record<string, unknown>)
return { data: { success: true, data: original } }
}
await renderDrawer(original)
await waitForLoadedForm()
assert.equal(getControlByLabel<HTMLInputElement>('Quota (USD)').value, '1')
await changeInput(getControlByLabel<HTMLInputElement>('Name'), 'renamed')
await submitForm()
await act(async () =>
waitForCondition(() => updates.length === 1, 'update was not submitted')
)
assert.equal(updates[0]?.name, 'renamed')
assert.equal(updates[0]?.quota, 500001)
})
test('redemption drawer recalculates quota when the quota field changes', async () => {
const original = redemption(1)
const updates: Array<Record<string, unknown>> = []
apiClient.get = async () => ({ data: { success: true, data: original } })
apiClient.put = async (_url, data) => {
assert.ok(data && typeof data === 'object')
updates.push(data as Record<string, unknown>)
return { data: { success: true, data: original } }
}
await renderDrawer(original)
await waitForLoadedForm()
await changeInput(getControlByLabel<HTMLInputElement>('Quota (USD)'), '2')
await submitForm()
await act(async () =>
waitForCondition(() => updates.length === 1, 'update was not submitted')
)
assert.equal(updates[0]?.quota, 1000000)
})
test('redemption drawer ignores an older response after switching records', async () => {
const first = redemption(1, 500001)
const second = redemption(2, 1000001)
const firstRequest = deferred<{ data: unknown }>()
const secondRequest = deferred<{ data: unknown }>()
const requestedUrls: string[] = []
const updates: Array<Record<string, unknown>> = []
apiClient.get = (url) => {
requestedUrls.push(url)
if (url === '/api/redemption/1') return firstRequest.promise
if (url === '/api/redemption/2') return secondRequest.promise
throw new Error(`Unexpected GET ${url}`)
}
apiClient.put = async (_url, data) => {
assert.ok(data && typeof data === 'object')
updates.push(data as Record<string, unknown>)
return { data: { success: true, data: second } }
}
await renderDrawer(first)
await rerenderDrawer(second)
await act(async () =>
waitForCondition(
() => requestedUrls.includes('/api/redemption/2'),
'second redemption was not requested'
)
)
await act(async () =>
secondRequest.resolve({ data: { success: true, data: second } })
)
await waitForLoadedForm()
await act(async () =>
firstRequest.resolve({ data: { success: true, data: first } })
)
assert.equal(getControlByLabel<HTMLInputElement>('Name').value, 'code-2')
await changeInput(getControlByLabel<HTMLInputElement>('Name'), 'second')
await submitForm()
await act(async () =>
waitForCondition(() => updates.length === 1, 'update was not submitted')
)
assert.equal(updates[0]?.id, 2)
assert.equal(updates[0]?.quota, 1000001)
})
@@ -51,7 +51,12 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format' import {
formatQuota,
getEditableQuotaStep,
parseQuotaFromDollars,
} from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import { addTimeToDate } from '@/lib/time' import { addTimeToDate } from '@/lib/time'
import { createRedemption, updateRedemption, getRedemption } from '../api' import { createRedemption, updateRedemption, getRedemption } from '../api'
@@ -63,7 +68,7 @@ import {
transformFormDataToPayload, transformFormDataToPayload,
transformRedemptionToFormDefaults, transformRedemptionToFormDefaults,
} from '../lib' } from '../lib'
import { type Redemption } from '../types' import type { Redemption } from '../types'
import { useRedemptions } from './redemptions-provider' import { useRedemptions } from './redemptions-provider'
type RedemptionsMutateDrawerProps = { type RedemptionsMutateDrawerProps = {
@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({
}: RedemptionsMutateDrawerProps) { }: RedemptionsMutateDrawerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isUpdate = !!currentRow const isUpdate = !!currentRow
const redemptionId = currentRow?.id
const { triggerRefresh } = useRedemptions() const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [redemptionLoadState, setRedemptionLoadState] = useState<
'idle' | 'loading' | 'ready' | 'error'
>('idle')
const [loadedRedemption, setLoadedRedemption] = useState<Redemption | null>(
null
)
const form = useForm<RedemptionFormValues>({ const form = useForm<RedemptionFormValues>({
resolver: zodResolver(getRedemptionFormSchema(t)), resolver: zodResolver(getRedemptionFormSchema(t)),
@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({
// Load existing data when updating // Load existing data when updating
useEffect(() => { useEffect(() => {
if (open && isUpdate && currentRow) { if (!open) {
// For update, fetch fresh data setRedemptionLoadState('idle')
getRedemption(currentRow.id).then((result) => { setLoadedRedemption(null)
if (result.success && result.data) { return
form.reset(transformRedemptionToFormDefaults(result.data))
}
})
} else if (open && !isUpdate) {
// For create, reset to defaults
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
} }
}, [open, isUpdate, currentRow, form])
if (!isUpdate || redemptionId === undefined) {
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
setRedemptionLoadState('ready')
setLoadedRedemption(null)
return
}
let ignoreResult = false
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
setRedemptionLoadState('loading')
setLoadedRedemption(null)
void getRedemption(redemptionId)
.then((result) => {
if (ignoreResult) return
if (
!result.success ||
!result.data ||
result.data.id !== redemptionId
) {
setRedemptionLoadState('error')
toast.error(t('Failed to load'))
return
}
form.reset(transformRedemptionToFormDefaults(result.data))
setLoadedRedemption(result.data)
setRedemptionLoadState('ready')
})
.catch((error: unknown) => {
if (ignoreResult) return
setRedemptionLoadState('error')
handleServerError(error)
})
return () => {
ignoreResult = true
}
}, [open, isUpdate, redemptionId, form, t])
const isUpdateReady =
!isUpdate ||
(redemptionLoadState === 'ready' && loadedRedemption?.id === redemptionId)
const isLoadingRedemption = redemptionLoadState === 'loading'
const onSubmit = async (data: RedemptionFormValues) => { const onSubmit = async (data: RedemptionFormValues) => {
if (isUpdate && (!currentRow || !loadedRedemption || !isUpdateReady)) {
return
}
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const basePayload = transformFormDataToPayload(data) const basePayload = transformFormDataToPayload(data)
if (isUpdate && currentRow) { if (isUpdate && currentRow && loadedRedemption) {
const quota = form.getFieldState('quota_dollars').isDirty
? basePayload.quota
: loadedRedemption.quota
const result = await updateRedemption({ const result = await updateRedemption({
...basePayload, ...basePayload,
quota,
id: currentRow.id, id: currentRow.id,
}) })
if (result.success) { if (result.success) {
@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({
const { meta: currencyMeta } = getCurrencyDisplay() const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel() const currencyLabel = getCurrencyLabel()
const tokensOnly = currencyMeta.kind === 'tokens' const tokensOnly = currencyMeta.kind === 'tokens'
const quotaStep = getEditableQuotaStep()
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens') ? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel }) : t('Enter quota in {{currency}}', { currency: currencyLabel })
let submitButtonLabel = t('Save changes')
if (isLoadingRedemption) {
submitButtonLabel = t('Loading...')
} else if (isSubmitting) {
submitButtonLabel = t('Saving...')
}
return ( return (
<Sheet <Sheet
@@ -194,147 +262,163 @@ export function RedemptionsMutateDrawer({
id='redemption-form' id='redemption-form'
onSubmit={handleSubmit} onSubmit={handleSubmit}
className={sideDrawerFormClassName()} className={sideDrawerFormClassName()}
aria-busy={isLoadingRedemption}
> >
<SideDrawerSection> <fieldset
<FormField disabled={!isUpdateReady || isSubmitting}
control={form.control} className='contents'
name='name' >
render={({ field }) => ( <SideDrawerSection>
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormDescription>
{t('Name for this redemption code (1-20 characters)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='flex flex-col gap-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField <FormField
control={form.control} control={form.control}
name='count' name='name'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Quantity')}</FormLabel> <FormLabel>{t('Name')}</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} placeholder={t('Enter a name')} />
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('Create multiple redemption codes at once (1-100)')} {t('Name for this redemption code (1-20 characters)')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)}
</SideDrawerSection> <FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={quotaStep}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='flex flex-col gap-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='count'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(
Number.parseInt(e.target.value, 10) || 1
)
}
/>
</FormControl>
<FormDescription>
{t(
'Create multiple redemption codes at once (1-100)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</SideDrawerSection>
</fieldset>
</form> </form>
</Form> </Form>
<SheetFooter className={sideDrawerFooterClassName()}> <SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}> <SheetClose render={<Button variant='outline' />}>
{t('Close')} {t('Close')}
</SheetClose> </SheetClose>
<Button form='redemption-form' type='submit' disabled={isSubmitting}> <Button
{isSubmitting ? t('Saving...') : t('Save changes')} form='redemption-form'
type='submit'
disabled={isSubmitting || !isUpdateReady}
>
{submitButtonLabel}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import { z } from 'zod' import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format' import {
parseQuotaFromDollars,
quotaUnitsToEditableAmount,
} from '@/lib/format'
import { import {
REDEMPTION_VALIDATION, REDEMPTION_VALIDATION,
getRedemptionFormErrorMessages, getRedemptionFormErrorMessages,
} from '../constants' } from '../constants'
import { type RedemptionFormData, type Redemption } from '../types' import type { RedemptionFormData, Redemption } from '../types'
// ============================================================================ // ============================================================================
// Form Schema (use getRedemptionFormSchema(t) in components for i18n messages) // Form Schema (use getRedemptionFormSchema(t) in components for i18n messages)
@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults(
): RedemptionFormValues { ): RedemptionFormValues {
return { return {
name: redemption.name, name: redemption.name,
quota_dollars: quotaUnitsToDollars(redemption.quota), quota_dollars: quotaUnitsToEditableAmount(redemption.quota),
expired_time: expired_time:
redemption.expired_time > 0 redemption.expired_time > 0
? new Date(redemption.expired_time * 1000) ? new Date(redemption.expired_time * 1000)
+23 -3
View File
@@ -244,6 +244,23 @@ function mergeOptions(
} }
} }
function getFractionDigits(
value: number,
digitsLarge: number,
digitsSmall: number
): number {
return Math.abs(value) >= 1 ? digitsLarge : digitsSmall
}
/** Return the configured fraction digits for a plain currency value. */
export function getCurrencyFractionDigits(
value: number,
options?: CurrencyFormatOptions
): number {
const merged = mergeOptions(options)
return getFractionDigits(value, merged.digitsLarge, merged.digitsSmall)
}
function removeTrailingZeros(str: string): string { function removeTrailingZeros(str: string): string {
if (!str.includes('.')) return str if (!str.includes('.')) return str
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '') return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
@@ -261,7 +278,7 @@ function formatNumberWithSuffix(
return `${removeTrailingZeros(result.toFixed(1))}k` return `${removeTrailingZeros(result.toFixed(1))}k`
} }
const digits = abs >= 1 ? digitsLarge : digitsSmall const digits = getFractionDigits(value, digitsLarge, digitsSmall)
return removeTrailingZeros(value.toFixed(digits)) return removeTrailingZeros(value.toFixed(digits))
} }
@@ -300,8 +317,11 @@ function formatCurrencyValue(
) )
} }
const digits = const digits = getFractionDigits(
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall value,
options.digitsLarge,
options.digitsSmall
)
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero) const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') { if (meta.kind === 'currency') {
+33 -4
View File
@@ -22,6 +22,7 @@ import {
formatCurrencyFromUSD, formatCurrencyFromUSD,
formatQuotaWithCurrency, formatQuotaWithCurrency,
getCurrencyDisplay, getCurrencyDisplay,
getCurrencyFractionDigits,
} from './currency' } from './currency'
// ============================================================================ // ============================================================================
@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number {
*/ */
export function quotaUnitsToDollars(units: number): number { export function quotaUnitsToDollars(units: number): number {
const { config, meta } = getCurrencyDisplay() const { config, meta } = getCurrencyDisplay()
return quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta)
}
function quotaUnitsToDisplayAmount(
units: number,
quotaPerUnit: number,
meta: ReturnType<typeof getCurrencyDisplay>['meta']
): number {
if (meta.kind === 'tokens') { if (meta.kind === 'tokens') {
return units return units
} }
const usdAmount = units / config.quotaPerUnit return (units / quotaPerUnit) * meta.exchangeRate
const exchangeRate = }
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
return usdAmount * exchangeRate /**
* Convert quota units to a plain number suitable for an editable input.
* Uses the same precision as quota list formatting without symbols or suffixes.
*/
export function quotaUnitsToEditableAmount(units: number): number {
const { config, meta } = getCurrencyDisplay()
const amount = quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta)
if (meta.kind === 'tokens') {
return Math.round(amount)
}
return Number(amount.toFixed(getCurrencyFractionDigits(amount)))
}
/** Return the input step matching the configured editable quota precision. */
export function getEditableQuotaStep(): number {
const { meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') {
return 1
}
return 10 ** -getCurrencyFractionDigits(0)
} }
// ============================================================================ // ============================================================================