fix(userapi): валюта и ставки Macloud/VDSina при синке
Docker / build (push) Has been cancelled

Валюта VPS берётся из baseCurrency хостера, ставки — из тарифных планов с fallback и parsePlanCost. На списке VPS цены в валюте провайдера. Расширен parseTariffPrice для форматов UserAPI. Добавлены фильтры на странице тарифов.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 02:48:04 +07:00
co-authored by Cursor
parent 6c5eb04fae
commit 031fc3aaa0
14 changed files with 1508 additions and 144 deletions
+34 -4
View File
@@ -2,12 +2,42 @@ import { describe, expect, it } from 'vitest'
import { parseTariffPrice } from '@cfdm/db/repositories/tariffs'
describe('parseTariffPrice', () => {
it('parses amount and currency', () => {
expect(parseTariffPrice('100.50 RUB')).toEqual({ monthlyRate: 100.5, currency: 'RUB' })
expect(parseTariffPrice('12 USD')).toEqual({ monthlyRate: 12, currency: 'USD' })
it('parses BILLmanager monthly amount and currency', () => {
expect(parseTariffPrice('100.50 RUB')).toEqual({
amount: 100.5,
monthlyRate: 100.5,
currency: 'RUB',
period: 'month',
})
expect(parseTariffPrice('12 USD')).toEqual({
amount: 12,
monthlyRate: 12,
currency: 'USD',
period: 'month',
})
})
it('parses UserAPI daily formats', () => {
expect(parseTariffPrice('1.55 USD/day')).toEqual({
amount: 1.55,
monthlyRate: 46.5,
currency: 'USD',
period: 'day',
})
expect(parseTariffPrice('1.55 ₽/день')).toEqual({
amount: 1.55,
monthlyRate: 46.5,
currency: 'RUB',
period: 'day',
})
})
it('handles empty', () => {
expect(parseTariffPrice('')).toEqual({ monthlyRate: null, currency: null })
expect(parseTariffPrice('')).toEqual({
amount: null,
monthlyRate: null,
currency: null,
period: null,
})
})
})
+49 -1
View File
@@ -49,7 +49,55 @@ describe('mapServerToVps', () => {
expect(vps.notes).toContain('macloud-12345')
expect(vps.tariffType).toBe('daily')
expect(vps.dailyRate).toBe(1.55)
expect(vps.monthlyRate).toBeNull()
expect(vps.monthlyRate).toBe(46.5)
expect(vps.currency).toBe('RUB')
})
it('parses string plan cost from index', () => {
const planIndex = new Map([
[
'1',
{
id: 1,
name: 'Plan',
cost: '2.50',
period: 'day',
},
],
])
const vps = mapServerToVps(server, 'vdsina', 'prov-1', 'acc-1', planIndex, 'USD')
expect(vps.dailyRate).toBe(2.5)
expect(vps.currency).toBe('USD')
})
it('falls back to tariff item price when plan index misses', () => {
const tariffByPlanId = new Map([
[
'1',
{
externalId: '1',
datacenterKey: '11',
datacenterName: 'Cloud',
name: 'Plan',
desc: '',
vcpu: 1,
ramGb: 1,
diskGb: 10,
diskType: 'NVMe',
virtualization: 'KVM',
channel: '',
location: 'Cloud',
country: '',
cpuModel: '',
orderAvailable: true,
price: '3 USD/day',
},
],
])
const vps = mapServerToVps(server, 'vdsina', 'prov-1', 'acc-1', undefined, 'USD', tariffByPlanId)
expect(vps.dailyRate).toBe(3)
expect(vps.monthlyRate).toBe(90)
expect(vps.currency).toBe('USD')
})
it('maps monthly plan cost from plan index', () => {
+65 -14
View File
@@ -2,9 +2,17 @@
* UserAPI response → vps-tracker model mappers
*/
import { parseTariffPrice } from '@cfdm/db/repositories/tariffs'
import type { UserApiType } from '@cfdm/shared/contracts/provider'
import type { UserApiOperation, UserApiServerDetail, UserApiServerPlan, UserApiPlanCostIndex } from './operations.js'
import type {
UserApiOperation,
UserApiPlanCostIndex,
UserApiServerDetail,
UserApiServerPlan,
UserApiTariffItem,
} from './operations.js'
import { normalizePlanPeriod, parsePlanCost } from './operations.js'
const STATUS_MAP: Record<string, string> = {
active: 'active',
@@ -47,38 +55,78 @@ function calculateConstructorExtraCost(
for (const key of ['cpu', 'ram', 'disk'] as const) {
const param = plan.params[key]
if (!param?.cost) continue
const paramCost = parsePlanCost(param.cost) ?? 0
const serverRes = serverData[key]
const planBase = planData[key]?.value ?? 0
const serverTotal = serverRes?.total ?? serverRes?.value ?? planBase
const units = Math.max(0, serverTotal - planBase)
if (units > 0) extra += units * param.cost
if (units > 0) extra += units * paramCost
}
return extra
}
function roundRate(n: number): number {
return Math.round(n * 100) / 100
}
function ratesFromCost(
cost: number,
period: 'day' | 'month',
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
if (period === 'month') {
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(cost) }
}
const dailyRate = roundRate(cost)
return { tariffType: 'daily', dailyRate, monthlyRate: roundRate(dailyRate * 30) }
}
function resolveRatesFromTariffItem(
price: string,
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } | null {
const parsed = parseTariffPrice(price)
if (parsed.amount == null || !Number.isFinite(parsed.amount)) return null
if (parsed.period === 'day') {
const dailyRate = roundRate(parsed.amount)
return { tariffType: 'daily', dailyRate, monthlyRate: roundRate(dailyRate * 30) }
}
return {
tariffType: 'monthly',
dailyRate: null,
monthlyRate: roundRate(parsed.amount),
}
}
function resolvePlanRates(
server: UserApiServerDetail,
planIndex?: UserApiPlanCostIndex,
tariffByPlanId?: Map<string, UserApiTariffItem>,
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
const planId = server['server-plan']?.id
if (planId == null || !planIndex) {
if (planId == null) {
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
}
const plan = planIndex.get(String(planId))
if (!plan || !Number.isFinite(plan.cost)) {
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
const planKey = String(planId)
if (planIndex) {
const plan = planIndex.get(planKey)
const baseCost = plan ? parsePlanCost(plan.cost ?? plan.full_cost) : null
if (plan && baseCost != null) {
const cost = baseCost + calculateConstructorExtraCost(server, plan)
return ratesFromCost(cost, normalizePlanPeriod(plan.period))
}
}
const cost = plan.cost + calculateConstructorExtraCost(server, plan)
const period = (plan.period || 'day').toLowerCase()
if (period === 'month') {
return { tariffType: 'monthly', dailyRate: null, monthlyRate: cost }
const tariff = tariffByPlanId?.get(planKey)
if (tariff?.price) {
const fromTariff = resolveRatesFromTariffItem(tariff.price)
if (fromTariff) return fromTariff
}
return { tariffType: 'daily', dailyRate: cost, monthlyRate: null }
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
}
export interface MappedVps {
@@ -133,6 +181,8 @@ export function mapServerToVps(
providerId: string,
providerAccountId: string,
planIndex?: UserApiPlanCostIndex,
currency = 'RUB',
tariffByPlanId?: Map<string, UserApiTariffItem>,
): MappedVps {
const { ip, ipv6 } = extractIp(server)
const data = server.data ?? {}
@@ -149,7 +199,8 @@ export function mapServerToVps(
: traffGb > 0
? Math.round((traffGb / 1024) * 100) / 100
: 0
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(server, planIndex)
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(server, planIndex, tariffByPlanId)
const resolvedCurrency = (currency || 'RUB').trim().toUpperCase() || 'RUB'
return {
externalId: String(server.id),
@@ -178,7 +229,7 @@ export function mapServerToVps(
backupEnabled: false,
status,
tariffType,
currency: 'RUB',
currency: resolvedCurrency,
dailyRate,
monthlyRate,
createdAt: dateToIso(server.created),
+56 -10
View File
@@ -24,7 +24,7 @@ export interface UserApiTariffSpec {
}
export interface UserApiPlanParamCost {
cost?: number
cost?: number | string
min?: number
max?: number
}
@@ -68,8 +68,8 @@ export interface UserApiServerGroup {
export interface UserApiServerPlan {
id: number
name: string
cost: number
full_cost?: number
cost: number | string
full_cost?: number | string
period?: string
description?: string
active?: boolean
@@ -143,6 +143,33 @@ function parseBalanceAmount(raw: string | number | undefined): number {
return Number.isFinite(n) ? n : 0
}
/** Парсит cost/full_cost тарифного плана UserAPI (число или строка). */
export function parsePlanCost(raw: string | number | undefined | null): number | null {
if (raw == null || raw === '') return null
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
const n = Number.parseFloat(String(raw).replace(/[^\d.-]/g, ''))
return Number.isFinite(n) ? n : null
}
export function normalizePlanPeriod(period?: string): 'day' | 'month' {
const p = (period || 'day').toLowerCase()
if (p === 'month' || p === 'monthly') return 'month'
return 'day'
}
/** Формат цены для active_tariffs.price — понятен parseTariffPrice. */
export function formatUserApiTariffPrice(
cost: number,
period: string | undefined,
currency: string,
): string {
const cur = (currency || 'RUB').trim().toUpperCase()
if (normalizePlanPeriod(period) === 'day') {
return `${cost} ${cur}/day`
}
return `${cost} ${cur}`
}
function inferDiskType(name: string): string {
const upper = name.toUpperCase()
if (upper.includes('NVME')) return 'NVMe'
@@ -155,10 +182,11 @@ function mapPlanToTariffItem(
plan: UserApiServerPlan,
groupId: string,
groupName: string,
currency: string,
): UserApiTariffItem {
const data = plan.data ?? {}
const diskGb = data.disk?.value ?? 0
const priceSuffix = plan.period === 'day' ? ' ₽/день' : ' ₽'
const cost = parsePlanCost(plan.cost ?? plan.full_cost)
const descParts = [plan.description || '']
if (plan.has_params) descParts.push('конструктор')
return {
@@ -177,7 +205,16 @@ function mapPlanToTariffItem(
country: '',
cpuModel: '',
orderAvailable: Boolean(plan.active && plan.enable),
price: plan.cost != null ? `${plan.cost}${priceSuffix}` : '',
price: cost != null ? formatUserApiTariffPrice(cost, plan.period, currency) : '',
}
}
function normalizePlanInIndex(plan: UserApiServerPlan): UserApiServerPlan {
const cost = parsePlanCost(plan.cost ?? plan.full_cost)
return {
...plan,
cost: cost ?? 0,
period: normalizePlanPeriod(plan.period) === 'month' ? 'month' : 'day',
}
}
@@ -262,13 +299,21 @@ export async function fetchDatacenters(
return map
}
export async function fetchServerGroups(
async function fetchAllServerGroups(
baseUrl: string,
credentials: string,
): Promise<UserApiServerGroup[]> {
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
const data = await userApiRequest<UserApiServerGroup[]>(url, token, '/server-group')
return Array.isArray(data) ? data.filter((g) => g.active !== false) : []
return Array.isArray(data) ? data : []
}
export async function fetchServerGroups(
baseUrl: string,
credentials: string,
): Promise<UserApiServerGroup[]> {
const groups = await fetchAllServerGroups(baseUrl, credentials)
return groups.filter((g) => g.active !== false)
}
export async function fetchServerPlans(
@@ -285,13 +330,13 @@ export async function fetchPlanCostIndex(
baseUrl: string,
credentials: string,
): Promise<UserApiPlanCostIndex> {
const groups = await fetchServerGroups(baseUrl, credentials)
const groups = await fetchAllServerGroups(baseUrl, credentials)
const index: UserApiPlanCostIndex = new Map()
for (const group of groups) {
const plans = await fetchServerPlans(baseUrl, credentials, group.id)
for (const plan of plans) {
if (plan?.id != null) index.set(String(plan.id), plan)
if (plan?.id != null) index.set(String(plan.id), normalizePlanInIndex(plan))
}
}
@@ -301,6 +346,7 @@ export async function fetchPlanCostIndex(
export async function fetchTariffList(
baseUrl: string,
credentials: string,
currency = 'RUB',
): Promise<UserApiTariffItem[]> {
const groups = await fetchServerGroups(baseUrl, credentials)
const items: UserApiTariffItem[] = []
@@ -310,7 +356,7 @@ export async function fetchTariffList(
const groupKey = String(group.id)
for (const plan of plans) {
if (plan.active === false || plan.enable === false) continue
items.push(mapPlanToTariffItem(plan, groupKey, group.name || ''))
items.push(mapPlanToTariffItem(plan, groupKey, group.name || '', currency))
}
}
+29 -11
View File
@@ -6,13 +6,17 @@ import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accou
import { syncFromUserApi } from './sync.js'
import type { UserApiSyncAccount } from './context.js'
vi.mock('./operations.js', () => ({
fetchServersWithDetails: vi.fn(),
fetchBalance: vi.fn(),
fetchTariffList: vi.fn(),
fetchPlanCostIndex: vi.fn(),
fetchOperations: vi.fn(),
}))
vi.mock('./operations.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./operations.js')>()
return {
...actual,
fetchServersWithDetails: vi.fn(),
fetchBalance: vi.fn(),
fetchTariffList: vi.fn(),
fetchPlanCostIndex: vi.fn(),
fetchOperations: vi.fn(),
}
})
import {
fetchBalance,
@@ -43,6 +47,7 @@ function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
balanceUpdatedAt: null,
enoughmoneyto: '',
balanceAlertBelow: null,
providerBaseCurrency: null,
}
}
@@ -84,7 +89,7 @@ describe('syncFromUserApi', () => {
country: '',
cpuModel: '',
orderAvailable: true,
price: '1.55 ₽/день',
price: '1.55 RUB/day',
},
])
vi.mocked(fetchPlanCostIndex).mockResolvedValue(
@@ -151,7 +156,7 @@ describe('syncFromUserApi', () => {
it('syncs vdsina account with vdsina id prefix', async () => {
getSqlite()
.prepare(
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-vdsina', 'VDSina', 'vdsina', 'https://userapi.vdsina.com/v1')`,
`INSERT INTO providers (id, name, apiType, apiBaseUrl, baseCurrency) VALUES ('prov-vdsina', 'VDSina', 'vdsina', 'https://userapi.vdsina.com/v1', 'USD')`,
)
.run()
providerAccountsRepository.create({
@@ -161,10 +166,23 @@ describe('syncFromUserApi', () => {
apiCredentials: 'secret-token',
})
const result = await syncFromUserApi(makeAccount('vdsina'))
const result = await syncFromUserApi({
...makeAccount('vdsina'),
providerBaseCurrency: 'USD',
})
expect(result.vpsCount).toBe(1)
const vps = getSqlite().prepare('SELECT id FROM vps WHERE id = ?').get('vps-vdsina-acc-vdsina-100')
const vps = getSqlite()
.prepare('SELECT id, currency, dailyRate, monthlyRate FROM vps WHERE id = ?')
.get('vps-vdsina-acc-vdsina-100') as {
id: string
currency: string
dailyRate: number
monthlyRate: number
}
expect(vps).toBeTruthy()
expect(vps.currency).toBe('USD')
expect(vps.dailyRate).toBe(1.55)
expect(vps.monthlyRate).toBe(46.5)
})
})
+14 -2
View File
@@ -82,19 +82,31 @@ export async function syncFromUserApi(
fetchVpsData
? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null)
: null,
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials).catch(() => []) : [],
fetchTariffs
? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => [])
: [],
fetchVpsData ? fetchOperations(apiBaseUrl, credentials).catch(() => []) : [],
fetchVpsData
? fetchPlanCostIndex(apiBaseUrl, credentials).catch(() => new Map() as UserApiPlanCostIndex)
: (new Map() as UserApiPlanCostIndex),
])
const tariffByPlanId = new Map(tariffItems.map((t) => [t.externalId, t]))
let vpsCount = 0
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
if (fetchVpsData) {
for (const server of servers) {
const vps = mapServerToVps(server, apiType, providerId, accountId, planIndex)
const vps = mapServerToVps(
server,
apiType,
providerId,
accountId,
planIndex,
fallbackCurrency,
tariffByPlanId,
)
const id = `vps-${idPrefix}-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate