From 4c6240c6360afa12bc71748bd1a240ae39180dca Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 19 Jul 2026 23:26:50 +0700 Subject: [PATCH] =?UTF-8?q?feat(sync):=20FirstByte=20daily=20paidUntil=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B3=D0=B5=D0=BE=204VPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для daily FirstByte считать dailyRate из cost/30 и общий paidUntil по балансу аккаунта; для 4VPS парсить страну/город из dc_name+flag. Co-authored-by: Cursor --- apps/api/src/services/billmanager/mappers.ts | 2 +- .../src/services/billmanager/profiles.test.ts | 59 +++++- .../services/billmanager/profiles/README.md | 2 +- .../billmanager/profiles/firstbyte.ts | 117 ++++++++++-- .../services/billmanager/profiles/index.ts | 2 +- .../services/billmanager/profiles/types.ts | 30 +++- apps/api/src/services/billmanager/sync.ts | 27 ++- apps/api/src/services/fourvps/location.ts | 168 ++++++++++++++++++ apps/api/src/services/fourvps/mappers.test.ts | 43 ++++- apps/api/src/services/fourvps/mappers.ts | 18 +- apps/api/src/services/fourvps/operations.ts | 11 +- apps/api/src/services/fourvps/sync.test.ts | 13 +- 12 files changed, 456 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/services/fourvps/location.ts diff --git a/apps/api/src/services/billmanager/mappers.ts b/apps/api/src/services/billmanager/mappers.ts index 26b4198..91f81d2 100644 --- a/apps/api/src/services/billmanager/mappers.ts +++ b/apps/api/src/services/billmanager/mappers.ts @@ -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 diff --git a/apps/api/src/services/billmanager/profiles.test.ts b/apps/api/src/services/billmanager/profiles.test.ts index d6b2d9a..240702c 100644 --- a/apps/api/src/services/billmanager/profiles.test.ts +++ b/apps/api/src/services/billmanager/profiles.test.ts @@ -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', () => { diff --git a/apps/api/src/services/billmanager/profiles/README.md b/apps/api/src/services/billmanager/profiles/README.md index 4f95917..c3476b6 100644 --- a/apps/api/src/services/billmanager/profiles/README.md +++ b/apps/api/src/services/billmanager/profiles/README.md @@ -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` | diff --git a/apps/api/src/services/billmanager/profiles/firstbyte.ts b/apps/api/src/services/billmanager/profiles/firstbyte.ts index 57d0a5f..02a3d5c 100644 --- a/apps/api/src/services/billmanager/profiles/firstbyte.ts +++ b/apps/api/src/services/billmanager/profiles/firstbyte.ts @@ -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 { +export 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})/) @@ -12,6 +18,66 @@ function pickPaidUntilDate(item: Record): string { return '' } +export function isFirstbyteDailyBilling(item: Record): 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): 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): { diskType?: string @@ -36,8 +102,10 @@ export function inferFirstbyteHardwareHints(item: Record): { * 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, @@ -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, }, diff --git a/apps/api/src/services/billmanager/profiles/index.ts b/apps/api/src/services/billmanager/profiles/index.ts index 6653fd0..7b31da0 100644 --- a/apps/api/src/services/billmanager/profiles/index.ts +++ b/apps/api/src/services/billmanager/profiles/index.ts @@ -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 { diff --git a/apps/api/src/services/billmanager/profiles/types.ts b/apps/api/src/services/billmanager/profiles/types.ts index befb64d..3c20be5 100644 --- a/apps/api/src/services/billmanager/profiles/types.ts +++ b/apps/api/src/services/billmanager/profiles/types.ts @@ -24,6 +24,16 @@ export type BillmanagerExtract = { paymentsKey: string } +export type EnrichVdsFn = ( + item: Record, + mapped: MappedVps, +) => MappedVps + +export type EnrichVdsBatchFn = ( + list: MappedVps[], + ctx: { balance: number | null }, +) => MappedVps[] + export type BillmanagerMap = { vds: ( item: Record, @@ -35,10 +45,20 @@ export type BillmanagerMap = { accountId: string, ) => MappedPayment | null /** Optional post-process after map.vds (specs / geo / etc.) */ - enrichVds?: ( - item: Record, - 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 extract?: Partial - map?: Partial + map?: BillmanagerMapOverrides requestParams?: BillmanagerRequestParams options?: BillmanagerProfileOptions } diff --git a/apps/api/src/services/billmanager/sync.ts b/apps/api/src/services/billmanager/sync.ts index 6c0d7f4..fffce26 100644 --- a/apps/api/src/services/billmanager/sync.ts +++ b/apps/api/src/services/billmanager/sync.ts @@ -137,16 +137,27 @@ export async function syncFromBillmanager( const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 } if (fetchVpsPayments) { + const mappedRaw: Awaited>[] = [] 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 diff --git a/apps/api/src/services/fourvps/location.ts b/apps/api/src/services/fourvps/location.ts new file mode 100644 index 0000000..8aaf11e --- /dev/null +++ b/apps/api/src/services/fourvps/location.ts @@ -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 = { + 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 = { + 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) +} diff --git a/apps/api/src/services/fourvps/mappers.test.ts b/apps/api/src/services/fourvps/mappers.test.ts index af077b6..a24a836 100644 --- a/apps/api/src/services/fourvps/mappers.test.ts +++ b/apps/api/src/services/fourvps/mappers.test.ts @@ -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') + }) }) diff --git a/apps/api/src/services/fourvps/mappers.ts b/apps/api/src/services/fourvps/mappers.ts index f1b5438..741ac9f 100644 --- a/apps/api/src/services/fourvps/mappers.ts +++ b/apps/api/src/services/fourvps/mappers.ts @@ -3,6 +3,7 @@ */ import type { FourVpsDatacenter, FourVpsServer } from './operations.js' +import { parseFourVpsDcLocation } from './location.js' const STATUS_MAP: Record = { 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, diff --git a/apps/api/src/services/fourvps/operations.ts b/apps/api/src/services/fourvps/operations.ts index 5b308d9..54f60dc 100644 --- a/apps/api/src/services/fourvps/operations.ts +++ b/apps/api/src/services/fourvps/operations.ts @@ -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)) } } diff --git a/apps/api/src/services/fourvps/sync.test.ts b/apps/api/src/services/fourvps/sync.test.ts index 4295734..d3653dc 100644 --- a/apps/api/src/services/fourvps/sync.test.ts +++ b/apps/api/src/services/fourvps/sync.test.ts @@ -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 = ?`)