Для daily FirstByte считать dailyRate из cost/30 и общий paidUntil по балансу аккаунта; для 4VPS парсить страну/город из dc_name+flag. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -40,7 +40,7 @@ export interface MappedVps {
|
||||
status: string
|
||||
tariffType: string
|
||||
currency: string
|
||||
dailyRate: null
|
||||
dailyRate: number | null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil: string
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
enrichMappedVpsFromTariffs,
|
||||
parseSpecsFromVdsEdit,
|
||||
} from './vds-specs.js'
|
||||
import {
|
||||
applyFirstbyteSharedDailyPaidUntil,
|
||||
monthlyToDailyRate,
|
||||
} from './profiles/firstbyte.js'
|
||||
|
||||
/** Minimal Waicore-style bjson (no credentials / addon noise). */
|
||||
const WAICORE_VDS_FIXTURE = {
|
||||
@@ -156,6 +160,7 @@ describe('billmanager profiles', () => {
|
||||
expect(monthly.diskType).toBe('SSD')
|
||||
expect(monthly.virtualization).toBe('KVM')
|
||||
expect(monthly.vcpu).toBe(0)
|
||||
expect(monthly.dailyRate).toBeNull()
|
||||
|
||||
const daily = mapVdsWithProfile(
|
||||
profile,
|
||||
@@ -165,9 +170,61 @@ describe('billmanager profiles', () => {
|
||||
)
|
||||
expect(daily.country).toBe('Россия')
|
||||
expect(daily.city).toBe('Москва')
|
||||
expect(daily.paidUntil).toBe('2026-07-20')
|
||||
expect(daily.paidUntil).toBe('')
|
||||
expect(daily.tariffType).toBe('daily')
|
||||
expect(daily.diskType).toBe('SAS')
|
||||
expect(daily.monthlyRate).toBe(129)
|
||||
expect(daily.dailyRate).toBe(monthlyToDailyRate(129))
|
||||
})
|
||||
|
||||
it('FirstByte: shared balance → same paidUntil for all daily VPS', () => {
|
||||
const profile = resolveBillmanagerProfile('https://my.firstbyte.ru/')
|
||||
expect(profile.map.enrichVdsBatch).toBeTypeOf('function')
|
||||
|
||||
const a = mapVdsWithProfile(
|
||||
profile,
|
||||
elemToObject({
|
||||
id: '1',
|
||||
billdaily: 'on',
|
||||
expiredate: 'Ежедневное списание',
|
||||
cost: '129.00 RUB / Месяц',
|
||||
currency_str: 'RUB',
|
||||
item_status_orig: '2',
|
||||
ip: '1.1.1.1',
|
||||
datacentername: '1 Датацентр Россия, Москва',
|
||||
}),
|
||||
'p',
|
||||
'a',
|
||||
)
|
||||
const b = mapVdsWithProfile(
|
||||
profile,
|
||||
elemToObject({
|
||||
id: '2',
|
||||
billdaily: 'on',
|
||||
expiredate: 'Ежедневное списание',
|
||||
cost: '129.00 RUB / Месяц',
|
||||
currency_str: 'RUB',
|
||||
item_status_orig: '2',
|
||||
ip: '2.2.2.2',
|
||||
datacentername: '1 Датацентр Россия, Москва',
|
||||
}),
|
||||
'p',
|
||||
'a',
|
||||
)
|
||||
const monthly = mapVdsWithProfile(
|
||||
profile,
|
||||
elemToObject(FIRSTBYTE_VDS_FIXTURE.elem[0]!),
|
||||
'p',
|
||||
'a',
|
||||
)
|
||||
|
||||
// 2 × (129/30) = 8.6/day; balance 86 → 10 days
|
||||
const asOf = new Date(Date.UTC(2026, 6, 19))
|
||||
const out = applyFirstbyteSharedDailyPaidUntil([a, b, monthly], 86, asOf)
|
||||
expect(out[0]!.paidUntil).toBe('2026-07-29')
|
||||
expect(out[1]!.paidUntil).toBe('2026-07-29')
|
||||
expect(out[2]!.paidUntil).toBe(monthly.paidUntil)
|
||||
expect(out[0]!.dailyRate).toBe(monthlyToDailyRate(129))
|
||||
})
|
||||
|
||||
it('FirstByte: specs from vds.order by pricelist_id', () => {
|
||||
|
||||
@@ -50,4 +50,4 @@ 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` |
|
||||
| `firstbyte` | `firstbyte.ru`, `firstbyte.club`, `1byte.ru` | `enrichVds`: страна/город, daily из `billdaily`/`Ежедневное списание`, `dailyRate=cost/30`; `enrichVdsBatch`: общий баланс ÷ сумма daily → один `paidUntil`; specs из `vds.order` / `vds.edit` |
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { parseDatacenterName } from '../parsers.js'
|
||||
import type { MappedVps } from '../mappers.js'
|
||||
import type { BillmanagerProfileOverrides } from './types.js'
|
||||
import type {
|
||||
BillmanagerMapOverrides,
|
||||
BillmanagerProfileOverrides,
|
||||
} from './types.js'
|
||||
|
||||
/** BILLmanager daily billing usually divides monthly price by 30. */
|
||||
export const FIRSTBYTE_DAYS_PER_MONTH = 30
|
||||
|
||||
/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
|
||||
function pickPaidUntilDate(item: Record<string, string>): string {
|
||||
export function pickPaidUntilDate(item: Record<string, string>): 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})/)
|
||||
@@ -12,6 +18,66 @@ function pickPaidUntilDate(item: Record<string, string>): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isFirstbyteDailyBilling(item: Record<string, string>): boolean {
|
||||
if (String(item.billdaily || '').toLowerCase() === 'on') return true
|
||||
const expire = String(item.expiredate || '').trim()
|
||||
return /ежедневн/i.test(expire)
|
||||
}
|
||||
|
||||
/** Parse «129.00 RUB / Месяц» or item_cost → monthly amount. */
|
||||
export function parseFirstbyteMonthlyCost(item: Record<string, string>): number {
|
||||
const fromCost = parseFloat(
|
||||
String(item.cost || '')
|
||||
.replace(/[^\d.,-]/g, '')
|
||||
.replace(',', '.'),
|
||||
)
|
||||
if (Number.isFinite(fromCost) && fromCost > 0) return fromCost
|
||||
const fromItem = parseFloat(String(item.item_cost || '').replace(',', '.'))
|
||||
return Number.isFinite(fromItem) && fromItem > 0 ? fromItem : 0
|
||||
}
|
||||
|
||||
export function monthlyToDailyRate(monthly: number): number {
|
||||
if (!monthly || monthly <= 0) return 0
|
||||
return Math.round((monthly / FIRSTBYTE_DAYS_PER_MONTH) * 10000) / 10000
|
||||
}
|
||||
|
||||
function addDaysIso(from: Date, days: number): string {
|
||||
const d = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()))
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared account balance ÷ sum(dailyRate of all daily VPS) → same paidUntil for each.
|
||||
* Monthly VPS are left unchanged.
|
||||
*/
|
||||
export function applyFirstbyteSharedDailyPaidUntil(
|
||||
list: MappedVps[],
|
||||
balance: number | null | undefined,
|
||||
asOf: Date = new Date(),
|
||||
): MappedVps[] {
|
||||
const dailyOnes = list.filter((v) => v.tariffType === 'daily')
|
||||
if (dailyOnes.length === 0) return list
|
||||
|
||||
const totalDaily = dailyOnes.reduce((sum, v) => sum + (Number(v.dailyRate) || 0), 0)
|
||||
const bal = typeof balance === 'number' && Number.isFinite(balance) ? balance : 0
|
||||
|
||||
let paidUntil = ''
|
||||
if (totalDaily > 0 && bal > 0) {
|
||||
const daysLeft = Math.floor(bal / totalDaily)
|
||||
paidUntil = addDaysIso(asOf, daysLeft)
|
||||
} else if (totalDaily > 0) {
|
||||
// Нет средств — считаем оплаченным до сегодня (0 полных дней)
|
||||
paidUntil = addDaysIso(asOf, 0)
|
||||
}
|
||||
|
||||
if (!paidUntil) return list
|
||||
|
||||
return list.map((v) =>
|
||||
v.tariffType === 'daily' ? { ...v, paidUntil } : v,
|
||||
)
|
||||
}
|
||||
|
||||
/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
|
||||
export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
||||
diskType?: string
|
||||
@@ -36,8 +102,10 @@ export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
||||
* Sync additionally fills specs via:
|
||||
* 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
|
||||
* 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
|
||||
* 3) enrichVdsBatch: shared balance → paidUntil for daily VPS
|
||||
*
|
||||
* This enrich: geo, paidUntil, daily billing, diskType/KVM from tariff name.
|
||||
* This enrich: geo, rates, daily flag, diskType/KVM from tariff name.
|
||||
* For daily: paidUntil left empty until enrichVdsBatch (shared balance).
|
||||
*/
|
||||
export function enrichFirstbyteVds(
|
||||
item: Record<string, string>,
|
||||
@@ -45,32 +113,61 @@ export function enrichFirstbyteVds(
|
||||
): 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 daily = isFirstbyteDailyBilling(item)
|
||||
const monthly = parseFirstbyteMonthlyCost(item) || mapped.monthlyRate || 0
|
||||
const hints = inferFirstbyteHardwareHints(item)
|
||||
|
||||
if (daily) {
|
||||
const dailyRate = monthlyToDailyRate(monthly) || mapped.dailyRate
|
||||
return {
|
||||
...mapped,
|
||||
country: country || mapped.country,
|
||||
city: location || mapped.city,
|
||||
datacenter: dcRaw || mapped.datacenter,
|
||||
tariffType: 'daily',
|
||||
monthlyRate: monthly || mapped.monthlyRate,
|
||||
dailyRate,
|
||||
// Не брать real_expiredate — дата из общего баланса в enrichVdsBatch
|
||||
paidUntil: '',
|
||||
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
||||
virtualization: hints.virtualization || mapped.virtualization,
|
||||
}
|
||||
}
|
||||
|
||||
const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
|
||||
return {
|
||||
...mapped,
|
||||
country: country || mapped.country,
|
||||
city: location || mapped.city,
|
||||
datacenter: dcRaw || mapped.datacenter,
|
||||
paidUntil,
|
||||
tariffType,
|
||||
tariffType: mapped.tariffType || 'monthly',
|
||||
monthlyRate: monthly || mapped.monthlyRate,
|
||||
dailyRate: null,
|
||||
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
||||
virtualization: hints.virtualization || mapped.virtualization,
|
||||
}
|
||||
}
|
||||
|
||||
export function enrichFirstbyteVdsBatch(
|
||||
list: MappedVps[],
|
||||
ctx: { balance: number | null },
|
||||
): MappedVps[] {
|
||||
return applyFirstbyteSharedDailyPaidUntil(list, ctx.balance)
|
||||
}
|
||||
|
||||
const firstbyteMap: BillmanagerMapOverrides = {
|
||||
enrichVds: enrichFirstbyteVds,
|
||||
enrichVdsBatch: enrichFirstbyteVdsBatch,
|
||||
}
|
||||
|
||||
export const firstbyteOverrides: BillmanagerProfileOverrides = {
|
||||
id: 'firstbyte',
|
||||
match: {
|
||||
hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
|
||||
keywords: ['firstbyte'],
|
||||
},
|
||||
map: {
|
||||
enrichVds: enrichFirstbyteVds,
|
||||
},
|
||||
map: firstbyteMap,
|
||||
options: {
|
||||
fetchVdsEditForSpecs: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { DEFAULT_PROFILE } from './default.js'
|
||||
export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js'
|
||||
export { enrichFirstbyteVds, enrichFirstbyteVdsBatch, firstbyteOverrides } from './firstbyte.js'
|
||||
export { mergeProfile } from './merge.js'
|
||||
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
|
||||
export type {
|
||||
|
||||
@@ -24,6 +24,16 @@ export type BillmanagerExtract = {
|
||||
paymentsKey: string
|
||||
}
|
||||
|
||||
export type EnrichVdsFn = (
|
||||
item: Record<string, string>,
|
||||
mapped: MappedVps,
|
||||
) => MappedVps
|
||||
|
||||
export type EnrichVdsBatchFn = (
|
||||
list: MappedVps[],
|
||||
ctx: { balance: number | null },
|
||||
) => MappedVps[]
|
||||
|
||||
export type BillmanagerMap = {
|
||||
vds: (
|
||||
item: Record<string, string>,
|
||||
@@ -35,10 +45,20 @@ export type BillmanagerMap = {
|
||||
accountId: string,
|
||||
) => MappedPayment | null
|
||||
/** Optional post-process after map.vds (specs / geo / etc.) */
|
||||
enrichVds?: (
|
||||
item: Record<string, string>,
|
||||
mapped: MappedVps,
|
||||
) => MappedVps
|
||||
enrichVds?: EnrichVdsFn
|
||||
/**
|
||||
* Optional post-process after all VDS mapped (e.g. shared balance → paidUntil).
|
||||
* Receives list in same order as sync will upsert.
|
||||
*/
|
||||
enrichVdsBatch?: EnrichVdsBatchFn
|
||||
}
|
||||
|
||||
/** Partial map for hoster overrides — lists every hook explicitly (IDE/Partial safe). */
|
||||
export type BillmanagerMapOverrides = {
|
||||
vds?: BillmanagerMap['vds']
|
||||
payment?: BillmanagerMap['payment']
|
||||
enrichVds?: EnrichVdsFn
|
||||
enrichVdsBatch?: EnrichVdsBatchFn
|
||||
}
|
||||
|
||||
export type BillmanagerRequestParams = {
|
||||
@@ -72,7 +92,7 @@ export type BillmanagerProfileOverrides = {
|
||||
match?: BillmanagerMatch
|
||||
funcs?: Partial<BillmanagerFuncs>
|
||||
extract?: Partial<BillmanagerExtract>
|
||||
map?: Partial<BillmanagerMap>
|
||||
map?: BillmanagerMapOverrides
|
||||
requestParams?: BillmanagerRequestParams
|
||||
options?: BillmanagerProfileOptions
|
||||
}
|
||||
|
||||
@@ -137,16 +137,27 @@ export async function syncFromBillmanager(
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsPayments) {
|
||||
const mappedRaw: Awaited<ReturnType<typeof mapVdsWithSpecs>>[] = []
|
||||
for (const item of vdsItems) {
|
||||
const vps = await mapVdsWithSpecs(
|
||||
profile,
|
||||
item,
|
||||
providerId,
|
||||
accountId,
|
||||
tariffItems,
|
||||
apiBaseUrl,
|
||||
authinfo,
|
||||
mappedRaw.push(
|
||||
await mapVdsWithSpecs(
|
||||
profile,
|
||||
item,
|
||||
providerId,
|
||||
accountId,
|
||||
tariffItems,
|
||||
apiBaseUrl,
|
||||
authinfo,
|
||||
),
|
||||
)
|
||||
}
|
||||
const mappedList = profile.map.enrichVdsBatch
|
||||
? profile.map.enrichVdsBatch(mappedRaw, {
|
||||
balance: dashboardInfo?.balance ?? null,
|
||||
})
|
||||
: mappedRaw
|
||||
|
||||
for (const vps of mappedList) {
|
||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
const dailyRate = vps.dailyRate
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 4VPS datacenter location helpers
|
||||
* @see https://4vps.su/page/api — getDcList: dc_name + flag
|
||||
*/
|
||||
|
||||
/** ISO 3166-1 alpha-2 (flag) → русское название страны */
|
||||
export const FOURVPS_FLAG_COUNTRY: Record<string, string> = {
|
||||
AE: 'ОАЭ',
|
||||
AT: 'Австрия',
|
||||
AU: 'Австралия',
|
||||
BE: 'Бельгия',
|
||||
BG: 'Болгария',
|
||||
CA: 'Канада',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
DE: 'Германия',
|
||||
DK: 'Дания',
|
||||
EE: 'Эстония',
|
||||
ES: 'Испания',
|
||||
FI: 'Финляндия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
HK: 'Гонконг',
|
||||
HU: 'Венгрия',
|
||||
IE: 'Ирландия',
|
||||
IL: 'Израиль',
|
||||
IT: 'Италия',
|
||||
JP: 'Япония',
|
||||
KZ: 'Казахстан',
|
||||
LT: 'Литва',
|
||||
LV: 'Латвия',
|
||||
MD: 'Молдова',
|
||||
NL: 'Нидерланды',
|
||||
NO: 'Норвегия',
|
||||
PL: 'Польша',
|
||||
PT: 'Португалия',
|
||||
RO: 'Румыния',
|
||||
RU: 'Россия',
|
||||
SE: 'Швеция',
|
||||
SG: 'Сингапур',
|
||||
TR: 'Турция',
|
||||
UA: 'Украина',
|
||||
UK: 'Великобритания',
|
||||
US: 'США',
|
||||
}
|
||||
|
||||
/** English / short codes often used in dc_name / tname prefixes */
|
||||
const NAME_COUNTRY_ALIASES: Record<string, string> = {
|
||||
UAE: 'ОАЭ',
|
||||
USA: 'США',
|
||||
UK: 'Великобритания',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
DE: 'Германия',
|
||||
FR: 'Франция',
|
||||
FI: 'Финляндия',
|
||||
SE: 'Швеция',
|
||||
PL: 'Польша',
|
||||
CZ: 'Чехия',
|
||||
RU: 'Россия',
|
||||
AE: 'ОАЭ',
|
||||
CA: 'Канада',
|
||||
HK: 'Гонконг',
|
||||
SG: 'Сингапур',
|
||||
TR: 'Турция',
|
||||
KZ: 'Казахстан',
|
||||
}
|
||||
|
||||
function countryFromFlag(flag: unknown): string {
|
||||
const code = String(flag || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (!code) return ''
|
||||
return FOURVPS_FLAG_COUNTRY[code] || NAME_COUNTRY_ALIASES[code] || ''
|
||||
}
|
||||
|
||||
/** Strip trailing «ДЦ1» / «DC 2» labels (not a city). */
|
||||
function stripDcOrdinal(s: string): string {
|
||||
return s.replace(/\s*(?:ДЦ|DC)\s*\d+\s*$/i, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse 4VPS dc_name + flag → country / city.
|
||||
* Examples:
|
||||
* - («ОАЭ ДЦ1», ae) → ОАЭ / ''
|
||||
* - («USA DC1», us) → США / ''
|
||||
* - («Нидерланды Амстердам», nl) → Нидерланды / Амстердам
|
||||
* - («Германия, Франкфурт», de) → Германия / Франкфурт
|
||||
*/
|
||||
export function parseFourVpsDcLocation(
|
||||
dcName: unknown,
|
||||
flag?: unknown,
|
||||
): { country: string; city: string } {
|
||||
const raw = String(dcName || '').trim()
|
||||
const fromFlag = countryFromFlag(flag)
|
||||
|
||||
if (!raw) return { country: fromFlag, city: '' }
|
||||
|
||||
const comma = raw.match(/^([^,]+),\s*(.+)$/)
|
||||
if (comma) {
|
||||
const left = comma[1]!.trim()
|
||||
const right = stripDcOrdinal(comma[2]!.trim())
|
||||
const leftCountry =
|
||||
NAME_COUNTRY_ALIASES[left.toUpperCase()] ||
|
||||
FOURVPS_FLAG_COUNTRY[left.toUpperCase()] ||
|
||||
(fromFlag && left.toUpperCase() === String(flag).toUpperCase() ? fromFlag : left)
|
||||
return {
|
||||
country: fromFlag || leftCountry,
|
||||
city: right,
|
||||
}
|
||||
}
|
||||
|
||||
const withoutOrdinal = stripDcOrdinal(raw)
|
||||
if (!withoutOrdinal) {
|
||||
return { country: fromFlag || raw, city: '' }
|
||||
}
|
||||
|
||||
// Exact country / alias match for whole string (e.g. «ОАЭ», «USA»)
|
||||
const upper = withoutOrdinal.toUpperCase()
|
||||
if (NAME_COUNTRY_ALIASES[upper] || FOURVPS_FLAG_COUNTRY[upper]) {
|
||||
return {
|
||||
country: fromFlag || NAME_COUNTRY_ALIASES[upper] || FOURVPS_FLAG_COUNTRY[upper]!,
|
||||
city: '',
|
||||
}
|
||||
}
|
||||
|
||||
// Country name at start (RU list + aliases), rest = city
|
||||
const countryNames = [
|
||||
...Object.values(FOURVPS_FLAG_COUNTRY),
|
||||
...Object.keys(NAME_COUNTRY_ALIASES),
|
||||
].sort((a, b) => b.length - a.length)
|
||||
|
||||
for (const name of countryNames) {
|
||||
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s+|$)`, 'i')
|
||||
if (re.test(withoutOrdinal)) {
|
||||
const rest = withoutOrdinal.replace(re, '').trim()
|
||||
const country =
|
||||
fromFlag ||
|
||||
NAME_COUNTRY_ALIASES[name.toUpperCase()] ||
|
||||
FOURVPS_FLAG_COUNTRY[name.toUpperCase()] ||
|
||||
name
|
||||
return { country, city: rest }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: first token country-ish, rest city
|
||||
const parts = withoutOrdinal.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
const first = parts[0]!
|
||||
const firstCountry =
|
||||
NAME_COUNTRY_ALIASES[first.toUpperCase()] ||
|
||||
FOURVPS_FLAG_COUNTRY[first.toUpperCase()] ||
|
||||
''
|
||||
if (firstCountry || fromFlag) {
|
||||
return {
|
||||
country: fromFlag || firstCountry,
|
||||
city: parts.slice(1).join(' '),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { country: fromFlag || withoutOrdinal, city: '' }
|
||||
}
|
||||
|
||||
/** Country for tariffs when only flag is known. */
|
||||
export function countryFromFourVpsFlag(flag: unknown): string {
|
||||
return countryFromFlag(flag)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseFourVpsDcLocation } from './location.js'
|
||||
import { mapServerToVps } from './mappers.js'
|
||||
import type { FourVpsServer } from './operations.js'
|
||||
|
||||
@@ -19,8 +20,39 @@ const sampleServer: FourVpsServer = {
|
||||
expired: 1666781101,
|
||||
}
|
||||
|
||||
describe('parseFourVpsDcLocation', () => {
|
||||
it('parses UAE DC ordinal → country only', () => {
|
||||
expect(parseFourVpsDcLocation('ОАЭ ДЦ1', 'ae')).toEqual({
|
||||
country: 'ОАЭ',
|
||||
city: '',
|
||||
})
|
||||
expect(parseFourVpsDcLocation('AE DC1', 'ae')).toEqual({
|
||||
country: 'ОАЭ',
|
||||
city: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses USA DC1 → США', () => {
|
||||
expect(parseFourVpsDcLocation('USA DC1', 'us')).toEqual({
|
||||
country: 'США',
|
||||
city: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses country + city', () => {
|
||||
expect(parseFourVpsDcLocation('Нидерланды Амстердам', 'nl')).toEqual({
|
||||
country: 'Нидерланды',
|
||||
city: 'Амстердам',
|
||||
})
|
||||
expect(parseFourVpsDcLocation('Германия, Франкфурт', 'de')).toEqual({
|
||||
country: 'Германия',
|
||||
city: 'Франкфурт',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapServerToVps', () => {
|
||||
it('maps myservers fields to VPS model', () => {
|
||||
it('maps myservers fields to VPS model with country/city from DC', () => {
|
||||
const dcMap = new Map([
|
||||
[7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }],
|
||||
])
|
||||
@@ -35,8 +67,15 @@ describe('mapServerToVps', () => {
|
||||
expect(vps.status).toBe('active')
|
||||
expect(vps.monthlyRate).toBe(420)
|
||||
expect(vps.datacenter).toBe('USA DC1')
|
||||
expect(vps.country).toBe('US')
|
||||
expect(vps.country).toBe('США')
|
||||
expect(vps.city).toBe('')
|
||||
expect(vps.paidUntil).toBe('2022-10-26')
|
||||
expect(vps.notes).toContain('4vps-4140')
|
||||
})
|
||||
|
||||
it('falls back to tname prefix when DC map empty', () => {
|
||||
const vps = mapServerToVps(sampleServer, 'prov-1', 'acc-1', new Map())
|
||||
expect(vps.country).toBe('США')
|
||||
expect(vps.datacenter).toBe('7')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { FourVpsDatacenter, FourVpsServer } from './operations.js'
|
||||
import { parseFourVpsDcLocation } from './location.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
@@ -16,6 +17,14 @@ function unixToIso(ts: number | null | undefined): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Fallback when getDcList miss: «USA-cx01» / «AE-cx01» → flag-like prefix. */
|
||||
function locationFromTariffName(tname: unknown): { country: string; city: string } {
|
||||
const raw = String(tname || '').trim()
|
||||
const m = raw.match(/^([A-Za-z]{2,3})-/)
|
||||
if (!m) return { country: '', city: '' }
|
||||
return parseFourVpsDcLocation(m[1], m[1])
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
externalId: string
|
||||
ip: string
|
||||
@@ -59,7 +68,12 @@ export function mapServerToVps(
|
||||
): MappedVps {
|
||||
const dc = dcMap.get(server.dc)
|
||||
const datacenter = dc?.dc_name ?? String(server.dc)
|
||||
const country = (dc?.flag ?? '').toUpperCase()
|
||||
let { country, city } = parseFourVpsDcLocation(dc?.dc_name, dc?.flag)
|
||||
if (!country) {
|
||||
const fromTariff = locationFromTariffName(server.tname)
|
||||
country = fromTariff.country
|
||||
city = city || fromTariff.city
|
||||
}
|
||||
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
|
||||
const monthlyRate = Number.isFinite(server.price) ? server.price : null
|
||||
const name = String(server.name || '').trim()
|
||||
@@ -73,7 +87,7 @@ export function mapServerToVps(
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country,
|
||||
city: '',
|
||||
city,
|
||||
datacenter,
|
||||
os: String(server.image || '').trim(),
|
||||
vcpu: server.cpu ?? 0,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { fourvpsRequest } from './client.js'
|
||||
import { countryFromFourVpsFlag, parseFourVpsDcLocation } from './location.js'
|
||||
|
||||
export interface FourVpsServer {
|
||||
id: number
|
||||
@@ -126,6 +127,7 @@ function mapPresetToTariffItem(
|
||||
dcName: string,
|
||||
country: string,
|
||||
cpuModel: string,
|
||||
location = dcName,
|
||||
): FourVpsTariffItem {
|
||||
const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0)
|
||||
const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0
|
||||
@@ -145,7 +147,7 @@ function mapPresetToTariffItem(
|
||||
diskType: diskTag.toUpperCase(),
|
||||
virtualization: 'KVM',
|
||||
channel: preset.commentParsed?.eth ?? '',
|
||||
location: dcName,
|
||||
location,
|
||||
country,
|
||||
cpuModel,
|
||||
orderAvailable: true,
|
||||
@@ -187,13 +189,16 @@ export async function fetchTarifList(
|
||||
const dcId = String(clusterInfo.id ?? dcKey)
|
||||
const dcFromList = dcMap.get(Number(clusterInfo.id ?? dcKey))
|
||||
const dcName = clusterInfo.dc_name ?? dcFromList?.dc_name ?? ''
|
||||
const country = (clusterInfo.flag ?? dcFromList?.flag ?? '').toUpperCase()
|
||||
const flag = clusterInfo.flag ?? dcFromList?.flag ?? ''
|
||||
const { country: parsedCountry, city } = parseFourVpsDcLocation(dcName, flag)
|
||||
const country = parsedCountry || countryFromFourVpsFlag(flag)
|
||||
const location = city || dcName
|
||||
const cpuModel = clusterInfo.cpu_name ?? dcFromList?.cpu_name ?? ''
|
||||
const presets = cluster.presets ?? {}
|
||||
|
||||
for (const preset of Object.values(presets)) {
|
||||
if (!preset?.id) continue
|
||||
items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel))
|
||||
items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel, location))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,10 +113,19 @@ describe('syncFromFourvps', () => {
|
||||
expect(result.balance?.balance).toBe(77825)
|
||||
|
||||
const vps = getSqlite()
|
||||
.prepare(`SELECT id, ip FROM vps WHERE providerAccountId = ?`)
|
||||
.get('acc-4vps') as { id: string; ip: string }
|
||||
.prepare(`SELECT id, ip, country, city, datacenter FROM vps WHERE providerAccountId = ?`)
|
||||
.get('acc-4vps') as {
|
||||
id: string
|
||||
ip: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
}
|
||||
expect(vps.id).toBe('vps-4vps-acc-4vps-100')
|
||||
expect(vps.ip).toBe('1.2.3.4')
|
||||
expect(vps.country).toBe('ОАЭ')
|
||||
expect(vps.city).toBe('')
|
||||
expect(vps.datacenter).toBe('AE DC1')
|
||||
|
||||
const tariffs = getSqlite()
|
||||
.prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`)
|
||||
|
||||
Reference in New Issue
Block a user