refactor(currency): валюта тарифа = валюта хостера, не VPS

- effectiveVpsTariffCurrency всегда берёт provider.baseCurrency
- Убран селектор валюты из формы VPS (подпись из хостера)
- DashboardPage, ResourcesPage, ReportsPage: вместо item.currency — baseCurrency провайдера
- Конвертация по глобальным курсам теперь корректно находит EUR/USD

Made-with: Cursor
This commit is contained in:
Denozordec
2026-03-23 21:32:46 +07:00
parent c161eaf84a
commit a97ec8c121
5 changed files with 47 additions and 55 deletions
+6 -13
View File
@@ -174,22 +174,15 @@ function toIsoCurrency(currency) {
} }
/** /**
* Валюта суммы тарифа VPS: поле vps.currency, иначе (только у хостеров без API) — валюта приёма из справочника. * Валюта тарифа VPS = валюта хостера (provider.baseCurrency).
* Для BILLmanager нельзя подставлять baseCurrency хостера при пустом поле: тариф в валюте из API (часто RUB). * vps.currency — только как запасной вариант, если хостер не найден.
* У ручных хостеров «USD» в VPS часто дефолт формы — тогда берём валюту хостера.
*/ */
export function effectiveVpsTariffCurrency(vps, provider) { export function effectiveVpsTariffCurrency(vps, provider) {
const ownRaw = (vps?.currency || '').trim()
const ownIso = ownRaw ? toIsoCurrency(ownRaw) : null
const provRaw = (provider?.baseCurrency || '').trim() const provRaw = (provider?.baseCurrency || '').trim()
const provIso = provRaw ? toIsoCurrency(provRaw) : null if (provRaw) return toIsoCurrency(provRaw)
const noApi = !(provider?.apiType || '').trim() const ownRaw = (vps?.currency || '').trim()
if (noApi && ownIso === 'USD' && provIso && provIso !== 'USD') { if (ownRaw) return toIsoCurrency(ownRaw)
return provIso return 'RUB'
}
if (ownIso) return ownIso
if (provIso && noApi) return provIso
return noApi ? 'USD' : 'RUB'
} }
/** /**
+15 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { convertCurrency, formatCurrency, monthKey } from '../lib/utils' import { convertCurrency, effectiveVpsTariffCurrency, formatCurrency, monthKey } from '../lib/utils'
import { getPaidUntilDate as computePaidUntil } from '../lib/paid-until' import { getPaidUntilDate as computePaidUntil } from '../lib/paid-until'
import { computeInventoryHealth, formatSyncSummaryLine } from '../lib/inventory-health' import { computeInventoryHealth, formatSyncSummaryLine } from '../lib/inventory-health'
import { fetchSyncStatus } from '../lib/api' import { fetchSyncStatus } from '../lib/api'
@@ -37,6 +37,11 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
const activeVpsCount = vps.filter((item) => item.status === 'active').length const activeVpsCount = vps.filter((item) => item.status === 'active').length
const providerById = useMemo(
() => new Map(providers.map((p) => [p.id, p])),
[providers],
)
const monthForecast = useMemo(() => { const monthForecast = useMemo(() => {
return vps return vps
.filter((item) => item.status === 'active') .filter((item) => item.status === 'active')
@@ -46,9 +51,10 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
tariffType === 'daily' tariffType === 'daily'
? Number(item.dailyRate || 0) * 30 ? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0) : Number(item.monthlyRate || 0)
return acc + convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData) const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
return acc + convertCurrency(amount, cur, baseCurrency, ratesData)
}, 0) }, 0)
}, [vps, baseCurrency, ratesData]) }, [vps, baseCurrency, ratesData, providerById])
const monthExpenses = useMemo(() => { const monthExpenses = useMemo(() => {
return [...payments, ...balanceLedger] return [...payments, ...balanceLedger]
@@ -144,14 +150,15 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
tariffType === 'daily' tariffType === 'daily'
? Number(item.dailyRate || 0) * 30 ? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0) : Number(item.monthlyRate || 0)
map[key].forecast += convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData) const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
map[key].forecast += convertCurrency(amount, cur, baseCurrency, ratesData)
} }
return Object.values(map).sort((a, b) => { return Object.values(map).sort((a, b) => {
if (a.key === '__none__') return 1 if (a.key === '__none__') return 1
if (b.key === '__none__') return -1 if (b.key === '__none__') return -1
return a.label.localeCompare(b.label, 'ru') return a.label.localeCompare(b.label, 'ru')
}) })
}, [vps, baseCurrency, ratesData]) }, [vps, baseCurrency, ratesData, providerById])
const forecastByAccount = useMemo(() => { const forecastByAccount = useMemo(() => {
return providerAccounts return providerAccounts
@@ -164,13 +171,14 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
tariffType === 'daily' tariffType === 'daily'
? Number(item.dailyRate || 0) * 30 ? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0) : Number(item.monthlyRate || 0)
forecast += convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData) const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
forecast += convertCurrency(amount, cur, baseCurrency, ratesData)
} }
return { account: acc, count: items.length, forecast } return { account: acc, count: items.length, forecast }
}) })
.filter((row) => row.count > 0) .filter((row) => row.count > 0)
.sort((a, b) => b.forecast - a.forecast) .sort((a, b) => b.forecast - a.forecast)
}, [vps, providerAccounts, baseCurrency, ratesData]) }, [vps, providerAccounts, baseCurrency, ratesData, providerById])
const accountBalances = providerAccounts.map((account) => { const accountBalances = providerAccounts.map((account) => {
if (account.balance_api != null && Number.isFinite(Number(account.balance_api))) { if (account.balance_api != null && Number.isFinite(Number(account.balance_api))) {
+2 -1
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
import { import {
convertCurrency, convertCurrency,
downloadTextFile, downloadTextFile,
effectiveVpsTariffCurrency,
formatCurrency, formatCurrency,
toCsv, toCsv,
vpsStatusLabel, vpsStatusLabel,
@@ -134,7 +135,7 @@ export function ReportsPage({ db, settings, ratesData }) {
city: vps.city || '', city: vps.city || '',
status: vps.status, status: vps.status,
expense: Number(total.toFixed(2)), expense: Number(total.toFixed(2)),
currency: vps.currency || 'USD', currency: effectiveVpsTariffCurrency(vps, provider),
} }
}) })
}, [db.payments, db.balanceLedger, db.providers, db.providerAccounts, db.vps, filters]) }, [db.payments, db.balanceLedger, db.providers, db.providerAccounts, db.vps, filters])
+11 -4
View File
@@ -1,20 +1,22 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { import {
convertCurrency, convertCurrency,
effectiveVpsTariffCurrency,
formatCurrency, formatCurrency,
} from '../lib/utils' } from '../lib/utils'
import { EmptyState } from '../components/EmptyState' import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader' import { PageHeader } from '../components/PageHeader'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps' import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
function vpsMonthlyEstimateInBase(item, baseCurrency, ratesData) { function vpsMonthlyEstimateInBase(item, baseCurrency, ratesData, providerById) {
if (item.status !== 'active') return 0 if (item.status !== 'active') return 0
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly') const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const amount = const amount =
tariffType === 'daily' tariffType === 'daily'
? Number(item.dailyRate || 0) * 30 ? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0) : Number(item.monthlyRate || 0)
return convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData) const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
return convertCurrency(amount, cur, baseCurrency, ratesData)
} }
export function ResourcesPage({ db, settings, ratesData }) { export function ResourcesPage({ db, settings, ratesData }) {
@@ -74,6 +76,11 @@ export function ResourcesPage({ db, settings, ratesData }) {
}) })
}, [db.vps, filters, customFields]) }, [db.vps, filters, customFields])
const providerById = useMemo(
() => new Map((db.providers || []).map((p) => [p.id, p])),
[db.providers],
)
const groups = useMemo(() => { const groups = useMemo(() => {
const map = new Map() const map = new Map()
const accounts = db.providerAccounts || [] const accounts = db.providerAccounts || []
@@ -123,7 +130,7 @@ export function ResourcesPage({ db, settings, ratesData }) {
g.vcpu += Number(item.vcpu || 0) g.vcpu += Number(item.vcpu || 0)
g.ramGb += Number(item.ramGb || 0) g.ramGb += Number(item.ramGb || 0)
g.diskGb += Number(item.diskGb || 0) g.diskGb += Number(item.diskGb || 0)
g.forecast += vpsMonthlyEstimateInBase(item, baseCurrency, ratesData) g.forecast += vpsMonthlyEstimateInBase(item, baseCurrency, ratesData, providerById)
} }
const list = [...map.values()] const list = [...map.values()]
list.sort((a, b) => { list.sort((a, b) => {
@@ -134,7 +141,7 @@ export function ResourcesPage({ db, settings, ratesData }) {
return b.forecast - a.forecast || b.vcpu - a.vcpu return b.forecast - a.forecast || b.vcpu - a.vcpu
}) })
return list return list
}, [vpsFiltered, groupBy, db.providerAccounts, baseCurrency, ratesData]) }, [vpsFiltered, groupBy, db.providerAccounts, baseCurrency, ratesData, providerById])
const maxForecast = useMemo( const maxForecast = useMemo(
() => groups.reduce((m, g) => Math.max(m, g.forecast), 0), () => groups.reduce((m, g) => Math.max(m, g.forecast), 0),
+13 -30
View File
@@ -374,14 +374,10 @@ export function VpsPage({ db, actions, settings, ratesData }) {
customFields.forEach((f) => { customFields.forEach((f) => {
customFieldValues[f.key] = form[f.key] ?? '' customFieldValues[f.key] = form[f.key] ?? ''
}) })
const prov = providerById.get(form.providerId)
const resolvedCurrency =
(form.currency || '').trim() || (prov?.baseCurrency || '').trim() || 'USD'
const payload = { const payload = {
...restForm, ...restForm,
...customFieldValues, ...customFieldValues,
additionalIps, additionalIps,
currency: resolvedCurrency,
dailyRate: form.tariffType === 'daily' ? form.dailyRate : '', dailyRate: form.tariffType === 'daily' ? form.dailyRate : '',
monthlyRate: form.tariffType === 'monthly' ? form.monthlyRate : '', monthlyRate: form.tariffType === 'monthly' ? form.monthlyRate : '',
} }
@@ -426,7 +422,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
status: vps.status || 'active', status: vps.status || 'active',
tariffType: tariffType:
vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly'), vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly'),
currency: effectiveVpsTariffCurrency(vps, providerById.get(vps.providerId)), currency: vps.currency || '',
dailyRate: vps.dailyRate || '', dailyRate: vps.dailyRate || '',
monthlyRate: vps.monthlyRate || '', monthlyRate: vps.monthlyRate || '',
createdAt: vps.createdAt || '', createdAt: vps.createdAt || '',
@@ -1440,18 +1436,13 @@ export function VpsPage({ db, actions, settings, ratesData }) {
<select autoComplete="off" <select autoComplete="off"
className="form-select" className="form-select"
value={form.providerId} value={form.providerId}
onChange={(e) => { onChange={(e) =>
const newProviderId = e.target.value
const prov = providerById.get(newProviderId)
const nextCur =
(prov?.baseCurrency || '').trim().toUpperCase() || 'USD'
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
providerId: newProviderId, providerId: e.target.value,
providerAccountId: '', providerAccountId: '',
currency: nextCur,
})) }))
}} }
required required
> >
<option value=""> Выберите </option> <option value=""> Выберите </option>
@@ -1681,7 +1672,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
<section className="vps-form-section"> <section className="vps-form-section">
<h6 className="vps-form-section-title">Тариф и оплата</h6> <h6 className="vps-form-section-title">Тариф и оплата</h6>
<div className="row g-3"> <div className="row g-3">
<div className="col-12 col-sm-6 col-md-3"> <div className="col-12 col-sm-6 col-md-4">
<label className="form-label">Статус</label> <label className="form-label">Статус</label>
<select autoComplete="off" <select autoComplete="off"
className="form-select" className="form-select"
@@ -1693,7 +1684,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
<option value="archived">{vpsStatusLabel('archived')}</option> <option value="archived">{vpsStatusLabel('archived')}</option>
</select> </select>
</div> </div>
<div className="col-12 col-sm-6 col-md-3"> <div className="col-12 col-sm-6 col-md-4">
<label className="form-label">Тип тарифа</label> <label className="form-label">Тип тарифа</label>
<select autoComplete="off" <select autoComplete="off"
className="form-select" className="form-select"
@@ -1711,7 +1702,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
<option value="monthly">{tariffTypeLabel('monthly')}</option> <option value="monthly">{tariffTypeLabel('monthly')}</option>
</select> </select>
</div> </div>
<div className="col-12 col-sm-6 col-md-3"> <div className="col-12 col-sm-6 col-md-4">
<label className="form-label"> <label className="form-label">
{form.tariffType === 'daily' ? 'Суточный тариф' : 'Месячный тариф'} {form.tariffType === 'daily' ? 'Суточный тариф' : 'Месячный тариф'}
</label> </label>
@@ -1731,21 +1722,13 @@ export function VpsPage({ db, actions, settings, ratesData }) {
placeholder="0.00" placeholder="0.00"
/> />
</div> </div>
<div className="col-12 col-sm-6 col-md-3"> {form.providerId ? (
<label className="form-label">Валюта тарифа</label> <div className="col-12">
<select autoComplete="off" <span className="text-secondary small">
className="form-select" Валюта тарифа: {(providerById.get(form.providerId)?.baseCurrency || '—').toUpperCase()} (из справочника хостера)
value={form.currency || (providerById.get(form.providerId)?.baseCurrency || '').trim() || 'USD'} </span>
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
>
<option value="RUB">RUB</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
<div className="text-secondary small mt-1">
В списке VPS и конвертации используется эта валюта, а не только валюта хостера в справочнике.
</div> </div>
</div> ) : null}
<div className="col-12 col-sm-6"> <div className="col-12 col-sm-6">
<label className="form-label">Дата создания</label> <label className="form-label">Дата создания</label>
<input {...noBrowserSuggestProps} <input {...noBrowserSuggestProps}