diff --git a/apps/api/src/services/userapi/mappers.test.ts b/apps/api/src/services/userapi/mappers.test.ts index 3af9ba6..734b6cd 100644 --- a/apps/api/src/services/userapi/mappers.test.ts +++ b/apps/api/src/services/userapi/mappers.test.ts @@ -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([ [ diff --git a/apps/api/src/services/userapi/mappers.ts b/apps/api/src/services/userapi/mappers.ts index 24ae78f..98f6639 100644 --- a/apps/api/src/services/userapi/mappers.ts +++ b/apps/api/src/services/userapi/mappers.ts @@ -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 = { active: 'active', @@ -101,12 +101,15 @@ function resolveRatesFromTariffItem( function resolvePlanRates( server: UserApiServerDetail, + apiType: UserApiType, + billingMode: string | null | undefined, planIndex?: UserApiPlanCostIndex, tariffByPlanId?: Map, ): { 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, + 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 { diff --git a/apps/api/src/services/userapi/operations.test.ts b/apps/api/src/services/userapi/operations.test.ts new file mode 100644 index 0000000..10e56d3 --- /dev/null +++ b/apps/api/src/services/userapi/operations.test.ts @@ -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') + }) +}) diff --git a/apps/api/src/services/userapi/operations.ts b/apps/api/src/services/userapi/operations.ts index f20dec7..a7a3b63 100644 --- a/apps/api/src/services/userapi/operations.ts +++ b/apps/api/src/services/userapi/operations.ts @@ -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, } } diff --git a/apps/api/src/services/userapi/sync.ts b/apps/api/src/services/userapi/sync.ts index 2743487..e3b567f 100644 --- a/apps/api/src/services/userapi/sync.ts +++ b/apps/api/src/services/userapi/sync.ts @@ -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 || []) diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 10abb58..54675bf 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -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 = { prod: 'Production', dev: 'Development', staging: 'Staging', } diff --git a/apps/web/src/routes/_auth/vps.$vpsId.tsx b/apps/web/src/routes/_auth/vps.$vpsId.tsx index 89937cd..f31a223 100644 --- a/apps/web/src/routes/_auth/vps.$vpsId.tsx +++ b/apps/web/src/routes/_auth/vps.$vpsId.tsx @@ -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() { diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 5f935b3..ed37767 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -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)) }, },