diff --git a/apps/api/src/services/billmanager/profiles.test.ts b/apps/api/src/services/billmanager/profiles.test.ts
index 88b9247..d6b2d9a 100644
--- a/apps/api/src/services/billmanager/profiles.test.ts
+++ b/apps/api/src/services/billmanager/profiles.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
-import { elemToObject, extractList } from './parsers.js'
+import { elemToObject, extractList, parseDatacenterName } from './parsers.js'
import { mapVdsToVps } from './mappers.js'
import {
DEFAULT_PROFILE,
@@ -9,6 +9,10 @@ import {
waicoreOverrides,
} from './profiles/index.js'
import { fetchVds, mapVdsWithProfile } from './operations.js'
+import {
+ enrichMappedVpsFromTariffs,
+ parseSpecsFromVdsEdit,
+} from './vds-specs.js'
/** Minimal Waicore-style bjson (no credentials / addon noise). */
const WAICORE_VDS_FIXTURE = {
@@ -33,6 +37,44 @@ const WAICORE_VDS_FIXTURE = {
],
}
+const FIRSTBYTE_VDS_FIXTURE = {
+ func: 'vds',
+ elem: [
+ {
+ id: '4478979',
+ ip: '45.95.202.221',
+ domain: 'auth.shnt.top',
+ expiredate: '2026-10-15',
+ real_expiredate: '2026-10-15',
+ billdaily: 'off',
+ datacentername: '1 Датацентр Россия, Москва',
+ ostempl: 'Debian-13-amd64',
+ pricelist: 'MSK-KVM-SSD-START',
+ pricelist_id: '5228',
+ currency_str: 'RUB',
+ createdate: '2026-07-15',
+ item_status_orig: '2',
+ cost: '75.00 RUB / Месяц',
+ },
+ {
+ id: '4208964',
+ ip: '193.168.227.234',
+ domain: 'vt.shnt.top',
+ expiredate: 'Ежедневное списание',
+ real_expiredate: '2026-07-20',
+ billdaily: 'on',
+ datacentername: '1 Датацентр Россия, Москва',
+ ostempl: 'Debian-13-amd64',
+ pricelist: 'MSK-KVM-SAS-1',
+ pricelist_id: '564',
+ currency_str: 'RUB',
+ createdate: '2026-04-21',
+ item_status_orig: '2',
+ cost: '129.00 RUB / Месяц',
+ },
+ ],
+}
+
describe('billmanager profiles', () => {
afterEach(() => {
vi.unstubAllGlobals()
@@ -53,6 +95,13 @@ describe('billmanager profiles', () => {
)
})
+ it('resolve: firstbyte hostname → firstbyte profile', () => {
+ const p = resolveBillmanagerProfile('https://my.firstbyte.ru/')
+ expect(p.id).toBe('firstbyte')
+ expect(p.funcs.listVds).toBe('vds')
+ expect(p.map.enrichVds).toBeTypeOf('function')
+ })
+
it('resolve: unknown hoster → default', () => {
const p = resolveBillmanagerProfile('https://bill.hoster.ru/')
expect(p.id).toBe('default')
@@ -67,6 +116,13 @@ describe('billmanager profiles', () => {
expect(merged.map.vds).toBe(DEFAULT_PROFILE.map.vds)
})
+ it('parseDatacenterName: FirstByte «1 Датацентр Россия, Москва»', () => {
+ expect(parseDatacenterName('1 Датацентр Россия, Москва')).toEqual({
+ country: 'Россия',
+ location: 'Москва',
+ })
+ })
+
it('Waicore fixture elem → MappedVps fields', () => {
const profile = resolveBillmanagerProfile('https://my.waicore.com/')
const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey)
@@ -81,6 +137,82 @@ describe('billmanager profiles', () => {
expect(vps.status).toBe('active')
})
+ it('FirstByte: country/city + paidUntil for monthly and daily', () => {
+ const profile = resolveBillmanagerProfile('https://bill.firstbyte.ru/')
+ const elems = extractList(FIRSTBYTE_VDS_FIXTURE, profile.extract.listVdsKey)
+ expect(elems).toHaveLength(2)
+
+ const monthly = mapVdsWithProfile(
+ profile,
+ elemToObject(elems[0]!),
+ 'prov-fb',
+ 'acc-fb',
+ )
+ expect(monthly.country).toBe('Россия')
+ expect(monthly.city).toBe('Москва')
+ expect(monthly.datacenter).toBe('1 Датацентр Россия, Москва')
+ expect(monthly.paidUntil).toBe('2026-10-15')
+ expect(monthly.tariffType).toBe('monthly')
+ expect(monthly.diskType).toBe('SSD')
+ expect(monthly.virtualization).toBe('KVM')
+ expect(monthly.vcpu).toBe(0)
+
+ const daily = mapVdsWithProfile(
+ profile,
+ elemToObject(elems[1]!),
+ 'prov-fb',
+ 'acc-fb',
+ )
+ expect(daily.country).toBe('Россия')
+ expect(daily.city).toBe('Москва')
+ expect(daily.paidUntil).toBe('2026-07-20')
+ expect(daily.tariffType).toBe('daily')
+ expect(daily.diskType).toBe('SAS')
+ })
+
+ it('FirstByte: specs from vds.order by pricelist_id', () => {
+ const profile = resolveBillmanagerProfile('https://my.firstbyte.ru/')
+ expect(profile.options?.fetchVdsEditForSpecs).toBe(true)
+
+ const item = elemToObject(FIRSTBYTE_VDS_FIXTURE.elem[0]!)
+ let mapped = mapVdsWithProfile(profile, item, 'p', 'a')
+ mapped = enrichMappedVpsFromTariffs(item, mapped, [
+ {
+ externalId: '5228',
+ name: 'START',
+ desc: 'START
Процессор: 1 ядро; Память: 1 GB; Диск: 15 GB SSD',
+ vcpu: 1,
+ ramGb: 1,
+ diskGb: 15,
+ diskType: 'SSD',
+ virtualization: 'KVM',
+ },
+ ])
+ expect(mapped.vcpu).toBe(1)
+ expect(mapped.ramGb).toBe(1)
+ expect(mapped.diskGb).toBe(15)
+ })
+
+ it('parseSpecsFromVdsEdit reads addon labels', () => {
+ const specs = parseSpecsFromVdsEdit({
+ model: {
+ addon_11: '2',
+ addon_12: '2048',
+ addon_13: '40',
+ pricelist: 'MSK-KVM-SSD-START',
+ },
+ messages: [
+ { name: 'addon_11', msg: 'Количество ядер процессора' },
+ { name: 'addon_12', msg: 'Память (MB)' },
+ { name: 'addon_13', msg: 'Диск SSD (GB)' },
+ ],
+ })
+ expect(specs.vcpu).toBe(2)
+ expect(specs.ramGb).toBe(2)
+ expect(specs.diskGb).toBe(40)
+ expect(specs.diskType).toBe('SSD')
+ })
+
it('fetchVds for waicore URL uses func=vds.vps', async () => {
const calls: string[] = []
vi.stubGlobal(
diff --git a/apps/api/src/services/billmanager/profiles/README.md b/apps/api/src/services/billmanager/profiles/README.md
index 2d574ff..4f95917 100644
--- a/apps/api/src/services/billmanager/profiles/README.md
+++ b/apps/api/src/services/billmanager/profiles/README.md
@@ -44,3 +44,10 @@ export const myHosterOverrides: BillmanagerProfileOverrides = {
`resolveBillmanagerProfile(apiBaseUrl)` → `merge(DEFAULT, override)` или `DEFAULT`.
Sync и operations **не** содержат `if (hoster)` — только профиль.
+
+## Готовые профили
+
+| id | Match | Что переопределяет |
+|----|-------|-------------------|
+| `waicore` | `waicore.com` / keyword | `funcs.listVds = vds.vps` |
+| `firstbyte` | `firstbyte.ru`, `firstbyte.club`, `1byte.ru` | `enrichVds`: страна/город, `paidUntil` ISO, `billdaily`→daily, diskType/KVM из имени; `options.fetchVdsEditForSpecs`; sync: specs из `vds.order` по `pricelist_id`, иначе `vds.edit` |
diff --git a/apps/api/src/services/billmanager/profiles/firstbyte.ts b/apps/api/src/services/billmanager/profiles/firstbyte.ts
new file mode 100644
index 0000000..57d0a5f
--- /dev/null
+++ b/apps/api/src/services/billmanager/profiles/firstbyte.ts
@@ -0,0 +1,77 @@
+import { parseDatacenterName } from '../parsers.js'
+import type { MappedVps } from '../mappers.js'
+import type { BillmanagerProfileOverrides } from './types.js'
+
+/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
+function pickPaidUntilDate(item: Record): string {
+ for (const key of ['real_expiredate', 'expiredate'] as const) {
+ const raw = String(item[key] ?? '').trim()
+ const m = raw.match(/^(\d{4}-\d{2}-\d{2})/)
+ if (m) return m[1]!
+ }
+ return ''
+}
+
+/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
+export function inferFirstbyteHardwareHints(item: Record): {
+ diskType?: string
+ virtualization?: string
+} {
+ const hay = `${item.pricelist || ''} ${item.intname || ''} ${item.name || ''}`
+ let diskType: string | undefined
+ let virtualization: string | undefined
+ if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
+ else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
+ else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
+ else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
+ if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
+ else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
+ else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
+ return { diskType, virtualization }
+}
+
+/**
+ * FirstByte — standard func=vds, but list has no CPU/RAM/disk numbers.
+ *
+ * Sync additionally fills specs via:
+ * 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
+ * 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
+ *
+ * This enrich: geo, paidUntil, daily billing, diskType/KVM from tariff name.
+ */
+export function enrichFirstbyteVds(
+ item: Record,
+ mapped: MappedVps,
+): MappedVps {
+ const dcRaw = (item.datacentername || item.datacenter || mapped.datacenter || '').trim()
+ const { country, location } = parseDatacenterName(dcRaw)
+ const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
+ const tariffType =
+ String(item.billdaily || '').toLowerCase() === 'on' ? 'daily' : mapped.tariffType
+ const hints = inferFirstbyteHardwareHints(item)
+
+ return {
+ ...mapped,
+ country: country || mapped.country,
+ city: location || mapped.city,
+ datacenter: dcRaw || mapped.datacenter,
+ paidUntil,
+ tariffType,
+ diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
+ virtualization: hints.virtualization || mapped.virtualization,
+ }
+}
+
+export const firstbyteOverrides: BillmanagerProfileOverrides = {
+ id: 'firstbyte',
+ match: {
+ hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
+ keywords: ['firstbyte'],
+ },
+ map: {
+ enrichVds: enrichFirstbyteVds,
+ },
+ options: {
+ fetchVdsEditForSpecs: true,
+ },
+}
diff --git a/apps/api/src/services/billmanager/profiles/index.ts b/apps/api/src/services/billmanager/profiles/index.ts
index 18f25b0..6653fd0 100644
--- a/apps/api/src/services/billmanager/profiles/index.ts
+++ b/apps/api/src/services/billmanager/profiles/index.ts
@@ -1,4 +1,5 @@
export { DEFAULT_PROFILE } from './default.js'
+export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js'
export { mergeProfile } from './merge.js'
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
export type {
diff --git a/apps/api/src/services/billmanager/profiles/merge.ts b/apps/api/src/services/billmanager/profiles/merge.ts
index bca37b0..10afb43 100644
--- a/apps/api/src/services/billmanager/profiles/merge.ts
+++ b/apps/api/src/services/billmanager/profiles/merge.ts
@@ -46,5 +46,9 @@ export function mergeProfile(
...overrides.requestParams?.orderPricelist,
},
},
+ options: {
+ ...defaults.options,
+ ...overrides.options,
+ },
}
}
diff --git a/apps/api/src/services/billmanager/profiles/registry.ts b/apps/api/src/services/billmanager/profiles/registry.ts
index d68dd43..72ce240 100644
--- a/apps/api/src/services/billmanager/profiles/registry.ts
+++ b/apps/api/src/services/billmanager/profiles/registry.ts
@@ -1,4 +1,5 @@
import { DEFAULT_PROFILE } from './default.js'
+import { firstbyteOverrides } from './firstbyte.js'
import { mergeProfile } from './merge.js'
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
import { waicoreOverrides } from './waicore.js'
@@ -7,7 +8,10 @@ import { waicoreOverrides } from './waicore.js'
* Hoster override list — first match wins.
* Add new hosters here after creating profiles/.ts.
*/
-export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [waicoreOverrides]
+export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [
+ waicoreOverrides,
+ firstbyteOverrides,
+]
function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean {
const match = override.match
diff --git a/apps/api/src/services/billmanager/profiles/types.ts b/apps/api/src/services/billmanager/profiles/types.ts
index 2419ec7..befb64d 100644
--- a/apps/api/src/services/billmanager/profiles/types.ts
+++ b/apps/api/src/services/billmanager/profiles/types.ts
@@ -48,6 +48,14 @@ export type BillmanagerRequestParams = {
orderPricelist?: Record
}
+export type BillmanagerProfileOptions = {
+ /**
+ * When list `func=vds` has no CPU/RAM/disk — after tariff match,
+ * call `func=vds.edit&elid=` per VPS (FirstByte and similar).
+ */
+ fetchVdsEditForSpecs?: boolean
+}
+
export type BillmanagerProfile = {
id: string
match: BillmanagerMatch
@@ -55,6 +63,7 @@ export type BillmanagerProfile = {
extract: BillmanagerExtract
map: BillmanagerMap
requestParams?: BillmanagerRequestParams
+ options?: BillmanagerProfileOptions
}
/** Deep-partial for hoster override files (only divergences). */
@@ -65,4 +74,5 @@ export type BillmanagerProfileOverrides = {
extract?: Partial
map?: Partial
requestParams?: BillmanagerRequestParams
+ options?: BillmanagerProfileOptions
}
diff --git a/apps/api/src/services/billmanager/sync.ts b/apps/api/src/services/billmanager/sync.ts
index c6bff74..6c0d7f4 100644
--- a/apps/api/src/services/billmanager/sync.ts
+++ b/apps/api/src/services/billmanager/sync.ts
@@ -18,6 +18,12 @@ import {
type TariffItem,
} from './operations.js'
import { resolveBillmanagerProfile } from './profiles/index.js'
+import {
+ applyEditSpecs,
+ enrichMappedVpsFromTariffs,
+ fetchVdsEditSpecs,
+ needsHardwareSpecs,
+} from './vds-specs.js'
export interface SyncFromBillmanagerOptions {
skipTariffs?: boolean
@@ -54,12 +60,33 @@ const SYNC_UPDATE_FIELDS = [
'paidUntil',
] as const
+const SYNC_HARDWARE_FIELDS = ['vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization'] as const
+
function normVal(v: unknown): string {
if (v == null || v === '') return ''
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
return String(v)
}
+async function mapVdsWithSpecs(
+ profile: ReturnType,
+ item: Record,
+ providerId: string,
+ accountId: string,
+ tariffItems: TariffItem[],
+ apiBaseUrl: string,
+ authinfo: string,
+) {
+ let vps = mapVdsWithProfile(profile, item, providerId, accountId)
+ vps = enrichMappedVpsFromTariffs(item, vps, tariffItems)
+ if (needsHardwareSpecs(vps) && profile.options?.fetchVdsEditForSpecs) {
+ const elid = String(item.id || vps.externalId || '').trim()
+ const specs = await fetchVdsEditSpecs(apiBaseUrl, authinfo, elid)
+ if (specs) vps = applyEditSpecs(vps, specs)
+ }
+ return vps
+}
+
export async function syncFromBillmanager(
account: BillmanagerSyncAccount,
opts: SyncFromBillmanagerOptions = {},
@@ -111,7 +138,15 @@ export async function syncFromBillmanager(
if (fetchVpsPayments) {
for (const item of vdsItems) {
- const vps = mapVdsWithProfile(profile, item, providerId, accountId)
+ const vps = await mapVdsWithSpecs(
+ profile,
+ item,
+ providerId,
+ accountId,
+ tariffItems,
+ apiBaseUrl,
+ authinfo,
+ )
const id = `vps-bm-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate
@@ -153,13 +188,41 @@ export async function syncFromBillmanager(
monthlyRate,
paidUntil,
notes,
+ vcpu: vps.vcpu,
+ ramGb: vps.ramGb,
+ diskGb: vps.diskGb,
+ diskType: vps.diskType,
+ virtualization: vps.virtualization,
}
for (const f of SYNC_UPDATE_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
- const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
+ for (const f of SYNC_HARDWARE_FIELDS) {
+ if (userOverrides.includes(f)) {
+ merged[f] = existing[f as keyof typeof existing] as never
+ } else {
+ // Keep previous non-zero hardware if API still returns empty
+ const next = merged[f]
+ const prev = existing[f as keyof typeof existing]
+ if (
+ (next == null || next === 0 || next === '') &&
+ prev != null &&
+ prev !== 0 &&
+ prev !== ''
+ ) {
+ merged[f] = prev as never
+ }
+ }
+ }
+ const compareFields = [
+ 'ip',
+ 'ipv6',
+ 'dns',
+ ...SYNC_UPDATE_FIELDS,
+ ...SYNC_HARDWARE_FIELDS,
+ ] as const
const changedFields = compareFields.filter(
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
)
@@ -184,6 +247,11 @@ export async function syncFromBillmanager(
monthlyRate: merged.monthlyRate,
paidUntil: merged.paidUntil,
notes: merged.notes,
+ vcpu: merged.vcpu,
+ ramGb: merged.ramGb,
+ diskGb: merged.diskGb,
+ diskType: merged.diskType,
+ virtualization: merged.virtualization,
})
.where(eq(schema.vps.id, existing.id))
.run()
diff --git a/apps/api/src/services/billmanager/vds-specs.ts b/apps/api/src/services/billmanager/vds-specs.ts
new file mode 100644
index 0000000..5415564
--- /dev/null
+++ b/apps/api/src/services/billmanager/vds-specs.ts
@@ -0,0 +1,246 @@
+/**
+ * Enrich VPS hardware specs when list `func=vds` has no CPU/RAM/disk
+ * (typical for FirstByte: only pricelist / pricelist_id).
+ */
+
+import { billmanagerRequest } from './client.js'
+import { elemToObject } from './parsers.js'
+import type { MappedVps } from './mappers.js'
+
+/** Minimal tariff shape for matching (from vds.order). */
+export type TariffSpecsSource = {
+ externalId: string
+ name: string
+ desc: string
+ vcpu: number
+ ramGb: number
+ diskGb: number
+ diskType: string
+ virtualization: string
+}
+
+export function needsHardwareSpecs(mapped: MappedVps): boolean {
+ return !mapped.vcpu || !mapped.ramGb || !mapped.diskGb
+}
+
+/** Match VDS list row → tariff from vds.order by pricelist_id / name. */
+export function findTariffForVdsItem(
+ item: Record,
+ tariffs: TariffSpecsSource[],
+): TariffSpecsSource | undefined {
+ if (!tariffs.length) return undefined
+
+ const id = String(item.pricelist_id || '').trim()
+ if (id) {
+ const byId = tariffs.find((t) => String(t.externalId) === id)
+ if (byId && (byId.vcpu || byId.ramGb || byId.diskGb)) return byId
+ }
+
+ const names = [
+ String(item.pricelist || '').trim(),
+ String(item.intname || '').trim(),
+ ].filter(Boolean)
+
+ for (const raw of names) {
+ const needle = raw.toLowerCase()
+ const hit = tariffs.find((t) => {
+ if (t.vcpu || t.ramGb || t.diskGb) {
+ const tName = (t.name || '').toLowerCase()
+ const tDesc = (t.desc || '').toLowerCase()
+ return (
+ tName === needle ||
+ tName.includes(needle) ||
+ needle.includes(tName) ||
+ tDesc.includes(needle)
+ )
+ }
+ return false
+ })
+ if (hit) return hit
+ }
+
+ // Same id even if specs empty (caller may still use name hints)
+ if (id) return tariffs.find((t) => String(t.externalId) === id)
+ return undefined
+}
+
+export function applyTariffSpecs(mapped: MappedVps, tariff: TariffSpecsSource): MappedVps {
+ return {
+ ...mapped,
+ vcpu: mapped.vcpu || tariff.vcpu || 0,
+ ramGb: mapped.ramGb || tariff.ramGb || 0,
+ diskGb: mapped.diskGb || tariff.diskGb || 0,
+ diskType:
+ mapped.diskGb && mapped.diskType
+ ? mapped.diskType
+ : tariff.diskType || mapped.diskType,
+ virtualization: mapped.virtualization || tariff.virtualization || 'KVM',
+ }
+}
+
+export function enrichMappedVpsFromTariffs(
+ item: Record,
+ mapped: MappedVps,
+ tariffs: TariffSpecsSource[],
+): MappedVps {
+ if (!needsHardwareSpecs(mapped)) return mapped
+ const tariff = findTariffForVdsItem(item, tariffs)
+ return tariff ? applyTariffSpecs(mapped, tariff) : mapped
+}
+
+function flattenModel(data: Record): Record {
+ const raw =
+ (data.model as Record | undefined) ??
+ ((data.doc as Record | undefined)?.model as
+ | Record
+ | undefined) ??
+ data
+ return elemToObject(raw)
+}
+
+function parseRamToGb(raw: string): number {
+ const s = String(raw || '').trim()
+ if (!s) return 0
+ const n = parseFloat(s.replace(/[^\d.]/g, ''))
+ if (!Number.isFinite(n) || n <= 0) return 0
+ // BM often stores RAM in MB for addons
+ if (/mb/i.test(s) || (!/gb/i.test(s) && n >= 128)) {
+ return Math.max(0.5, Math.round((n / 1024) * 10) / 10)
+ }
+ return n
+}
+
+function parseDiskGb(raw: string): number {
+ const n = parseFloat(String(raw || '').replace(/[^\d.]/g, ''))
+ return Number.isFinite(n) && n > 0 ? n : 0
+}
+
+function labelForAddon(
+ key: string,
+ data: Record,
+): string {
+ const messages = data.messages ?? (data.doc as Record | undefined)?.messages
+ if (Array.isArray(messages)) {
+ for (const m of messages) {
+ if (!m || typeof m !== 'object') continue
+ const row = m as Record
+ const name = String(row.name ?? row.k ?? '')
+ if (name === key || name.endsWith(key)) {
+ return String(row.msg ?? row.v ?? row.$ ?? '')
+ }
+ }
+ }
+ // metadata on fields sometimes: metadata.addon_N
+ const meta = data.metadata as Record | undefined
+ if (meta && typeof meta[key] === 'object' && meta[key]) {
+ const m = meta[key] as Record
+ return String(m.label ?? m.name ?? m.msg ?? '')
+ }
+ return ''
+}
+
+/** Parse CPU/RAM/disk from `func=vds.edit&elid=` form response. */
+export function parseSpecsFromVdsEdit(
+ data: Record,
+): Pick {
+ const model = flattenModel(data)
+ let vcpu = 0
+ let ramGb = 0
+ let diskGb = 0
+ let diskType = ''
+ let virtualization = ''
+
+ for (const key of ['ncpu', 'cpu', 'vcpu', 'cpus', 'cpu_count'] as const) {
+ const n = parseInt(String(model[key] || ''), 10)
+ if (n > 0) {
+ vcpu = n
+ break
+ }
+ }
+ for (const key of ['ram', 'memory', 'mem', 'ram_mb', 'ram_gb'] as const) {
+ if (model[key]) {
+ ramGb = parseRamToGb(model[key]!)
+ if (ramGb) break
+ }
+ }
+ for (const key of ['disc', 'disk', 'hdd', 'disksize', 'disk_gb', 'hdd_gb'] as const) {
+ if (model[key]) {
+ diskGb = parseDiskGb(model[key]!)
+ if (diskGb) break
+ }
+ }
+
+ for (const [key, val] of Object.entries(model)) {
+ if (!key.startsWith('addon_') || !val) continue
+ const label = labelForAddon(key, data).toLowerCase()
+ const n = parseFloat(String(val).replace(/[^\d.]/g, ''))
+ if (!Number.isFinite(n) || n <= 0) continue
+
+ if (/ядер|процессор|cpu|v?cpu|core/i.test(label) && !vcpu) {
+ vcpu = Math.round(n)
+ } else if (/память|memory|ram|озу/i.test(label) && !ramGb) {
+ ramGb = parseRamToGb(String(val))
+ } else if (/диск|disk|hdd|ssd|nvme|sas|хранилище/i.test(label) && !diskGb) {
+ diskGb = parseDiskGb(String(val))
+ if (/nvme/i.test(label)) diskType = 'NVMe'
+ else if (/sas/i.test(label)) diskType = 'SAS'
+ else if (/hdd/i.test(label)) diskType = 'HDD'
+ else if (/ssd/i.test(label)) diskType = 'SSD'
+ }
+ }
+
+ const hay = `${model.pricelist || ''} ${model.intname || ''} ${model.name || ''}`
+ if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
+ else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
+ else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
+ if (!diskType) {
+ if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
+ else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
+ else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
+ else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
+ }
+
+ return {
+ vcpu,
+ ramGb,
+ diskGb,
+ diskType: diskType || 'SSD',
+ virtualization: virtualization || 'KVM',
+ }
+}
+
+export function applyEditSpecs(
+ mapped: MappedVps,
+ specs: ReturnType,
+): MappedVps {
+ return {
+ ...mapped,
+ vcpu: mapped.vcpu || specs.vcpu || 0,
+ ramGb: mapped.ramGb || specs.ramGb || 0,
+ diskGb: mapped.diskGb || specs.diskGb || 0,
+ diskType: mapped.diskGb ? mapped.diskType : specs.diskType || mapped.diskType,
+ virtualization: mapped.virtualization || specs.virtualization || 'KVM',
+ }
+}
+
+export async function fetchVdsEditSpecs(
+ baseUrl: string,
+ authinfo: string,
+ elid: string,
+): Promise | null> {
+ const id = String(elid || '').trim()
+ if (!id) return null
+ try {
+ const data = await billmanagerRequest(baseUrl, authinfo, 'vds.edit', { elid: id })
+ const specs = parseSpecsFromVdsEdit(data)
+ if (!specs.vcpu && !specs.ramGb && !specs.diskGb) return null
+ return specs
+ } catch (err) {
+ console.warn(
+ 'fetchVdsEditSpecs failed:',
+ id,
+ err instanceof Error ? err.message : err,
+ )
+ return null
+ }
+}