fix(userapi): период тарифа Macloud и отображение ставки на списке VPS
Docker / build (push) Failing after 1m45s

Месячные планы Macloud определяются через inferPlanPeriod; в UI показывается суточная или месячная ставка по tariffType, а не месячный эквивалент.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 11:21:44 +07:00
co-authored by Cursor
parent 579c00392d
commit 87aa6949b3
8 changed files with 149 additions and 30 deletions
@@ -106,6 +106,37 @@ describe('mapServerToVps', () => {
expect(vps.monthlyRate).toBe(46.5)
})
it('maps macloud monthly plan when period fields are missing', () => {
const planIndex = new Map([
[
'3:7',
{
id: 7,
name: '1 RAM / 1 CPU / 10 NVMe',
cost: 150,
period: '',
'server-group': 3,
},
],
])
const vps = mapServerToVps(
{
...server,
'server-plan': { id: 7, name: '1 RAM / 1 CPU / 10 NVMe' },
'server-group': { id: 3, name: 'VDS' },
data: { cpu: { value: 1 }, ram: { value: 1 }, disk: { value: 10 } },
},
'macloud',
'prov-1',
'acc-1',
planIndex,
'RUB',
)
expect(vps.tariffType).toBe('monthly')
expect(vps.monthlyRate).toBe(150)
expect(vps.dailyRate).toBeNull()
})
it('parses string plan cost from index', () => {
const planIndex = new Map([
[
+15 -5
View File
@@ -12,7 +12,7 @@ import type {
UserApiServerPlan,
UserApiTariffItem,
} from './operations.js'
import { normalizePlanPeriod, effectivePlanCost, findPlanInIndex, parsePlanCost } from './operations.js'
import { inferPlanPeriod, effectivePlanCost, findPlanInIndex, parsePlanCost } from './operations.js'
const STATUS_MAP: Record<string, string> = {
active: 'active',
@@ -101,12 +101,15 @@ function resolveRatesFromTariffItem(
function resolvePlanRates(
server: UserApiServerDetail,
apiType: UserApiType,
billingMode: string | null | undefined,
planIndex?: UserApiPlanCostIndex,
tariffByPlanId?: Map<string, UserApiTariffItem>,
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
const planId = server['server-plan']?.id
const defaultType = inferPlanPeriod(undefined, apiType, billingMode) === 'month' ? 'monthly' : 'daily'
if (planId == null) {
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
return { tariffType: defaultType, dailyRate: null, monthlyRate: null }
}
const planKey = String(planId)
@@ -116,7 +119,7 @@ function resolvePlanRates(
const baseCost = plan ? effectivePlanCost(plan) : null
if (plan && baseCost != null) {
const cost = baseCost + calculateConstructorExtraCost(server, plan)
return ratesFromCost(cost, normalizePlanPeriod(plan.period))
return ratesFromCost(cost, inferPlanPeriod(plan, apiType, billingMode))
}
}
@@ -126,7 +129,7 @@ function resolvePlanRates(
if (fromTariff) return fromTariff
}
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
return { tariffType: defaultType, dailyRate: null, monthlyRate: null }
}
export interface MappedVps {
@@ -183,6 +186,7 @@ export function mapServerToVps(
planIndex?: UserApiPlanCostIndex,
currency = 'RUB',
tariffByPlanId?: Map<string, UserApiTariffItem>,
billingMode?: string | null,
): MappedVps {
const { ip, ipv6 } = extractIp(server)
const data = server.data ?? {}
@@ -199,7 +203,13 @@ export function mapServerToVps(
: traffGb > 0
? Math.round((traffGb / 1024) * 100) / 100
: 0
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(server, planIndex, tariffByPlanId)
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(
server,
apiType,
billingMode,
planIndex,
tariffByPlanId,
)
const resolvedCurrency = (currency || 'RUB').trim().toUpperCase() || 'RUB'
return {
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { inferPlanPeriod, normalizePlanPeriod } from './operations.js'
describe('normalizePlanPeriod', () => {
it('detects day and month from period and period_name', () => {
expect(normalizePlanPeriod('day')).toBe('day')
expect(normalizePlanPeriod('month')).toBe('month')
expect(normalizePlanPeriod(undefined, 'день')).toBe('day')
expect(normalizePlanPeriod(undefined, 'месяц')).toBe('month')
})
it('returns null when period is unknown', () => {
expect(normalizePlanPeriod()).toBeNull()
expect(normalizePlanPeriod('', '')).toBeNull()
})
})
describe('inferPlanPeriod', () => {
it('defaults macloud to month when plan has no period', () => {
expect(inferPlanPeriod({ id: 1, name: 'x', cost: 150 }, 'macloud')).toBe('month')
})
it('defaults vdsina to day when plan has no period', () => {
expect(inferPlanPeriod({ id: 1, name: 'x', cost: 2.1 }, 'vdsina')).toBe('day')
})
it('respects explicit plan period over apiType default', () => {
expect(
inferPlanPeriod({ id: 1, name: 'x', cost: 2.1, period: 'day' }, 'macloud'),
).toBe('day')
})
it('uses account billingMode when plan period is missing', () => {
expect(inferPlanPeriod({ id: 1, name: 'x', cost: 90 }, 'vdsina', 'monthly')).toBe('month')
})
})
+28 -7
View File
@@ -72,6 +72,7 @@ export interface UserApiServerPlan {
cost: number | string
full_cost?: number | string
period?: string
period_name?: string
description?: string
active?: boolean
enable?: boolean
@@ -199,20 +200,39 @@ export function findPlanInIndex(
return undefined
}
export function normalizePlanPeriod(period?: string): 'day' | 'month' {
const p = (period || 'day').toLowerCase()
if (p === 'month' || p === 'monthly') return 'month'
/** Распознаёт период из полей UserAPI (period, period_name). */
export function normalizePlanPeriod(period?: string, periodName?: string): 'day' | 'month' | null {
const sources = [period, periodName].filter((s) => Boolean(String(s ?? '').trim()))
for (const raw of sources) {
const p = String(raw).toLowerCase().trim()
if (['month', 'monthly', 'mo', 'месяц', 'мес'].some((k) => p === k || p.includes(k))) return 'month'
if (['day', 'daily', 'день', 'дн', 'сут', 'сутки'].some((k) => p === k || p.includes(k))) return 'day'
}
return null
}
/** Период тарифа: поля плана → billingMode аккаунта → дефолт по apiType (macloud — месяц). */
export function inferPlanPeriod(
plan: UserApiServerPlan | undefined,
apiType?: string,
billingMode?: string | null,
): 'day' | 'month' {
const fromPlan = plan ? normalizePlanPeriod(plan.period, plan.period_name) : null
if (fromPlan) return fromPlan
if (billingMode === 'monthly') return 'month'
if (billingMode === 'daily') return 'day'
if (apiType === 'macloud') return 'month'
return 'day'
}
/** Формат цены для active_tariffs.price — понятен parseTariffPrice. */
export function formatUserApiTariffPrice(
cost: number,
period: string | undefined,
period: 'day' | 'month',
currency: string,
): string {
const cur = (currency || 'RUB').trim().toUpperCase()
if (normalizePlanPeriod(period) === 'day') {
if (period === 'day') {
return `${cost} ${cur}/day`
}
return `${cost} ${cur}`
@@ -253,17 +273,18 @@ function mapPlanToTariffItem(
country: '',
cpuModel: '',
orderAvailable: Boolean(plan.active && plan.enable),
price: cost != null ? formatUserApiTariffPrice(cost, plan.period, currency) : '',
price: cost != null ? formatUserApiTariffPrice(cost, inferPlanPeriod(plan), currency) : '',
}
}
function normalizePlanInIndex(plan: UserApiServerPlan, groupId: number): UserApiServerPlan {
const cost = effectivePlanCost(plan)
const period = inferPlanPeriod(plan)
return {
...plan,
'server-group': plan['server-group'] ?? groupId,
cost: cost ?? plan.cost,
period: normalizePlanPeriod(plan.period) === 'month' ? 'month' : 'day',
period,
}
}
+1
View File
@@ -108,6 +108,7 @@ export async function syncFromUserApi(
planIndex,
fallbackCurrency,
tariffByPlanId,
account.billingMode,
)
const id = `vps-${idPrefix}-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
+32
View File
@@ -120,6 +120,38 @@ export function tariffTypeLabel(type: string): string {
return TARIFF_TYPE_LABELS[type] ?? type
}
function tariffRateNumber(value: number | string | null | undefined): number | null {
if (value === '' || value == null) return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
/** Сумма тарифа в валюте провайдера: суточная или месячная — по tariffType. */
export function vpsTariffRateAmount(vps: {
tariffType?: string | null
dailyRate?: number | string | null
monthlyRate?: number | string | null
}): number {
const daily = tariffRateNumber(vps.dailyRate)
const monthly = tariffRateNumber(vps.monthlyRate)
if (vps.tariffType === 'daily') {
return daily ?? 0
}
return monthly ?? 0
}
/** Месячный burn-rate для сортировки и отчётов. */
export function vpsTariffMonthlyBurn(vps: {
tariffType?: string | null
dailyRate?: number | string | null
monthlyRate?: number | string | null
}): number {
const daily = tariffRateNumber(vps.dailyRate) ?? 0
const monthly = tariffRateNumber(vps.monthlyRate) ?? 0
if (vps.tariffType === 'daily') return daily * 30
return monthly
}
const ENVIRONMENT_LABELS: Record<string, string> = {
prod: 'Production', dev: 'Development', staging: 'Staging',
}
+2 -5
View File
@@ -27,6 +27,7 @@ import {
tariffTypeLabel,
vpsStatusLabel,
paymentTypeLabel,
vpsTariffRateAmount,
} from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
@@ -215,11 +216,7 @@ function VpsDetailPage() {
<InfoRow
label="Ставка"
value={formatCurrency(
row.monthlyRate != null
? Number(row.monthlyRate)
: row.tariffType === 'daily'
? Number(row.dailyRate || 0) * 30
: Number(row.monthlyRate || 0),
vpsTariffRateAmount(row),
effectiveVpsTariffCurrency(row, provider),
)}
/>
+3 -13
View File
@@ -6,7 +6,7 @@ import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import type { VpsFormValues } from '@/lib/schemas'
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatCurrency, vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatCurrency, vpsStatusLabel, tariffTypeLabel, vpsTariffRateAmount, vpsTariffMonthlyBurn } from '@/lib/format'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
@@ -408,21 +408,11 @@ function VpsPage() {
key: 'tariff',
header: 'Тариф',
icon: CreditCardIcon,
sortValue: (v) =>
v.monthlyRate != null
? Number(v.monthlyRate)
: v.tariffType === 'daily'
? Number(v.dailyRate || 0) * 30
: 0,
sortValue: (v) => vpsTariffMonthlyBurn(v),
cell: (v) => {
const provider = providerById.get(v.providerId)
const currency = effectiveVpsTariffCurrency(v, provider)
const amount =
v.monthlyRate != null
? Number(v.monthlyRate)
: v.tariffType === 'daily'
? Number(v.dailyRate || 0) * 30
: Number(v.monthlyRate || 0)
const amount = vpsTariffRateAmount(v)
return dataGridCellStack(formatCurrency(amount, currency), tariffTypeLabel(v.tariffType))
},
},