fix(currency): глобальные курсы без явного курса хостера и нормализация Valute ЦБ

- parseProviderFxRate: пусто/auto/невалид → конвертация по ссылке из настроек
- normalizeRatesPayload + App: JSON ЦБ с Valute → base+rates как у latest.js
- Подсказка в форме хостера про курсы USD/EUR

Made-with: Cursor
This commit is contained in:
Denozordec
2026-03-23 21:25:01 +07:00
parent a4752d4379
commit c161eaf84a
3 changed files with 66 additions and 6 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ import {
loadDataSet,
updateRecord,
} from './lib/api'
import { normalizeRatesPayload } from './lib/utils'
function App() {
const [isReady, setIsReady] = useState(false)
@@ -63,7 +64,7 @@ function App() {
return response.json()
})
.then((payload) => {
setRatesData(payload)
setRatesData(normalizeRatesPayload(payload) ?? payload)
setRatesError('')
})
.catch((error) => {
+58 -5
View File
@@ -208,6 +208,59 @@ export function formatCurrency(amount, currency = 'USD') {
}).format(safeAmount)
}
/**
* Числовой курс хостера из справочника: только явное положительное число.
* Пусто, «auto», невалид — нет своего курса, используется ссылка на курсы из настроек.
* @param {unknown} raw
* @returns {number}
*/
export function parseProviderFxRate(raw) {
if (raw == null) return NaN
const s = String(raw).trim()
if (!s) return NaN
if (s.toLowerCase() === 'auto') return NaN
const n = Number(s.replace(',', '.'))
if (!Number.isFinite(n) || n <= 0) return NaN
return n
}
/**
* Приводит ответ API курсов к виду { base, rates }.
* Поддержка JSON ЦБ с полем Valute (daily_json.js) и уже нормализованного latest.js.
* @param {object|null|undefined} payload
* @returns {{ base: string, rates: object, date?: string }|null}
*/
export function normalizeRatesPayload(payload) {
if (!payload || typeof payload !== 'object') return null
if (payload.base && payload.rates && typeof payload.rates === 'object') {
return payload
}
if (payload.Valute && typeof payload.Valute === 'object') {
const rates = {}
for (const v of Object.values(payload.Valute)) {
const code = v?.CharCode
const val = Number(v?.Value)
const nom = Number(v?.Nominal) || 1
if (
typeof code === 'string' &&
code.length === 3 &&
Number.isFinite(val) &&
val > 0 &&
nom > 0
) {
rates[code] = nom / val
}
}
if (Object.keys(rates).length === 0) return null
return {
base: 'RUB',
rates,
date: typeof payload.Date === 'string' ? payload.Date : '',
}
}
return null
}
/**
* Конвертирует сумму из одной валюты в другую по ratesData (CBR и т.п.)
* @param {number} amount
@@ -267,7 +320,7 @@ export function formatInBaseCurrency(amount, currency, appSettings, ratesData) {
* Конвертирует сумму в валюту отображения (из настроек).
* provider.baseCurrency — валюта, в которой хостер принимает платежи.
* settings.baseCurrency — валюта отображения на дашбордах.
* Курсы хостера (usdRate, eurRate) — курс 1 USD/EUR в валюту отображения.
* Курсы хостера (usdRate, eurRate) — только если заданы явные числа; иначе — курсы по ссылке из настроек.
*/
export function convertWithProviderRate(amount, currency, provider, appSettings, ratesData) {
const safeAmount = Number(amount)
@@ -282,12 +335,12 @@ export function convertWithProviderRate(amount, currency, provider, appSettings,
return { value: safeAmount, currency: appBase, source: 'native' }
}
const usdRate = Number(provider?.usdRate)
const eurRate = Number(provider?.eurRate)
if (fromCurrency === 'USD' && Number.isFinite(usdRate) && usdRate > 0) {
const usdRate = parseProviderFxRate(provider?.usdRate)
const eurRate = parseProviderFxRate(provider?.eurRate)
if (fromCurrency === 'USD' && Number.isFinite(usdRate)) {
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
}
if (fromCurrency === 'EUR' && Number.isFinite(eurRate) && eurRate > 0) {
if (fromCurrency === 'EUR' && Number.isFinite(eurRate)) {
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
}
+6
View File
@@ -222,6 +222,12 @@ export function ProvidersPage({ db, actions }) {
onChange={(e) => setForm((prev) => ({ ...prev, eurRate: e.target.value }))}
/>
</div>
<div className="col-12">
<div className="text-secondary small">
Курс USD/EUR: рублей (или единиц валюты отображения в настройках) за 1 USD/EUR. Пусто или «auto»
конвертация по ссылке на курсы валют из настроек.
</div>
</div>
<div className="col-12">
<label className="form-label">Заметки</label>
<textarea {...noBrowserSuggestProps}