Валюта VPS берётся из baseCurrency хостера, ставки — из тарифных планов с fallback и parsePlanCost. На списке VPS цены в валюте провайдера. Расширен parseTariffPrice для форматов UserAPI. Добавлены фильтры на странице тарифов. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,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', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user