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, loadDataSet,
updateRecord, updateRecord,
} from './lib/api' } from './lib/api'
import { normalizeRatesPayload } from './lib/utils'
function App() { function App() {
const [isReady, setIsReady] = useState(false) const [isReady, setIsReady] = useState(false)
@@ -63,7 +64,7 @@ function App() {
return response.json() return response.json()
}) })
.then((payload) => { .then((payload) => {
setRatesData(payload) setRatesData(normalizeRatesPayload(payload) ?? payload)
setRatesError('') setRatesError('')
}) })
.catch((error) => { .catch((error) => {
+58 -5
View File
@@ -208,6 +208,59 @@ export function formatCurrency(amount, currency = 'USD') {
}).format(safeAmount) }).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 и т.п.) * Конвертирует сумму из одной валюты в другую по ratesData (CBR и т.п.)
* @param {number} amount * @param {number} amount
@@ -267,7 +320,7 @@ export function formatInBaseCurrency(amount, currency, appSettings, ratesData) {
* Конвертирует сумму в валюту отображения (из настроек). * Конвертирует сумму в валюту отображения (из настроек).
* provider.baseCurrency — валюта, в которой хостер принимает платежи. * provider.baseCurrency — валюта, в которой хостер принимает платежи.
* settings.baseCurrency — валюта отображения на дашбордах. * settings.baseCurrency — валюта отображения на дашбордах.
* Курсы хостера (usdRate, eurRate) — курс 1 USD/EUR в валюту отображения. * Курсы хостера (usdRate, eurRate) — только если заданы явные числа; иначе — курсы по ссылке из настроек.
*/ */
export function convertWithProviderRate(amount, currency, provider, appSettings, ratesData) { export function convertWithProviderRate(amount, currency, provider, appSettings, ratesData) {
const safeAmount = Number(amount) const safeAmount = Number(amount)
@@ -282,12 +335,12 @@ export function convertWithProviderRate(amount, currency, provider, appSettings,
return { value: safeAmount, currency: appBase, source: 'native' } return { value: safeAmount, currency: appBase, source: 'native' }
} }
const usdRate = Number(provider?.usdRate) const usdRate = parseProviderFxRate(provider?.usdRate)
const eurRate = Number(provider?.eurRate) const eurRate = parseProviderFxRate(provider?.eurRate)
if (fromCurrency === 'USD' && Number.isFinite(usdRate) && usdRate > 0) { if (fromCurrency === 'USD' && Number.isFinite(usdRate)) {
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' } 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' } 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 }))} onChange={(e) => setForm((prev) => ({ ...prev, eurRate: e.target.value }))}
/> />
</div> </div>
<div className="col-12">
<div className="text-secondary small">
Курс USD/EUR: рублей (или единиц валюты отображения в настройках) за 1 USD/EUR. Пусто или «auto»
конвертация по ссылке на курсы валют из настроек.
</div>
</div>
<div className="col-12"> <div className="col-12">
<label className="form-label">Заметки</label> <label className="form-label">Заметки</label>
<textarea {...noBrowserSuggestProps} <textarea {...noBrowserSuggestProps}