feat(api, web): интеграция хостера 4VPS.SU — синк VPS, баланса и тарифов
Docker / build (push) Has been cancelled

Добавлен адаптер 4vps, обобщён pipeline синхронизации через ProviderAdapter и обновлён UI для Panel ID и API Key.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 01:13:50 +07:00
co-authored by Cursor
parent 05e7bf829e
commit 9be50aa03b
33 changed files with 1564 additions and 174 deletions
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providersRepository } from '@cfdm/db/repositories/providers'
import { buildApp } from '../index.js'
vi.mock('../services/fourvps/sync.js', () => ({
syncFromFourvps: vi.fn().mockResolvedValue({
vpsCount: 2,
paymentsCount: 0,
tariffsCount: 3,
newTariffs: [],
balance: { balance: 1000, currency: 'RUB' },
syncSummary: { added: [], updated: [], paymentsAdded: 0 },
}),
}))
describe('sync routes — 4vps', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
seedTestProvider('prov-4vps')
providersRepository.update('prov-4vps', {
apiType: '4vps',
apiBaseUrl: 'https://4vps.su/api',
})
providerAccountsRepository.create({
id: 'acc-4vps',
providerId: 'prov-4vps',
name: '4VPS',
apiCredentials: '1:secret-key',
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('POST /api/sync/:accountId syncs 4vps account', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/sync/acc-4vps',
payload: {},
})
expect(res.statusCode).toBe(200)
const body = res.json() as { ok?: boolean; synced?: { vpsCount?: number } }
expect(body.ok).toBe(true)
expect(body.synced?.vpsCount).toBe(2)
})
it('POST /api/sync/test-connection uses apiType 4vps', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ error: false, data: { userBalance: 500, serverlist: [] } }),
}),
)
const res = await app.inject({
method: 'POST',
url: '/api/sync/test-connection',
payload: {
apiBaseUrl: 'https://4vps.su/api',
apiCredentials: '1:key',
apiType: '4vps',
},
})
expect(res.statusCode).toBe(200)
const body = res.json() as { ok?: boolean }
expect(body.ok).toBe(true)
vi.unstubAllGlobals()
})
})
+28 -19
View File
@@ -5,11 +5,11 @@ import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accou
import { providersRepository } from '@cfdm/db/repositories/providers'
import {
billmanagerAccountRowForSync,
fetchDashboardInfo,
runBillmanagerAccountSync,
testConnection,
} from '../services/billmanager/index.js'
getProviderAdapter,
resolveSyncAccount,
SYNC_SETUP_ERRORS,
} from '../services/providers/index.js'
import { runAccountSync } from '../services/providers/sync-job.js'
interface SyncLogRow {
id: string
@@ -45,8 +45,9 @@ function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
}
}
const BILLMANAGER_SETUP_ERROR =
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API'
function setupErrorMessage(apiType: string): string {
return SYNC_SETUP_ERRORS[apiType] ?? 'Настройте API хостера и учётные данные аккаунта'
}
export const syncRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/sync/status', async () => {
@@ -67,16 +68,18 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
const syncRow = billmanagerAccountRowForSync(account, provider)
if (!syncRow) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
const resolved = resolveSyncAccount(account, provider)
if (!resolved) {
const apiType = String(provider?.apiType || 'billmanager').toLowerCase()
return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } })
}
const onlyTariffs = Boolean(req.body?.onlyTariffs)
const opts = onlyTariffs ? { skipVpsPayments: true } : {}
const adapter = getProviderAdapter(resolved.apiType)
try {
const result = await runBillmanagerAccountSync(syncRow, opts)
const result = await runAccountSync(adapter, resolved.account, opts)
return {
ok: true,
synced: {
@@ -99,15 +102,19 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
const syncRow = billmanagerAccountRowForSync(account, provider)
if (!syncRow) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
const resolved = resolveSyncAccount(account, provider)
if (!resolved) {
const apiType = String(provider?.apiType || 'billmanager').toLowerCase()
return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } })
}
const adapter = getProviderAdapter(resolved.apiType)
if (!adapter.fetchBalance) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Баланс через API недоступен для этого типа хостера' } })
}
try {
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), {
fallbackCurrency: account.currency,
})
const info = await adapter.fetchBalance(resolved.account)
getDb()
.update(schema.providerAccounts)
.set({
@@ -127,15 +134,17 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
})
app.post('/api/sync/test-connection', async (req, reply) => {
const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as {
const { apiBaseUrl, apiCredentials, apiType } = (req.body ?? {}) as {
apiBaseUrl?: string
apiCredentials?: string
apiType?: string
}
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' })
}
try {
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
const adapter = getProviderAdapter(apiType || 'billmanager')
const result = await adapter.testConnection(apiBaseUrl.trim(), apiCredentials.trim())
return result
} catch (err) {
req.log.error(err)
+4 -48
View File
@@ -2,11 +2,10 @@
* Запуск синхронизации BILLmanager с записью в sync_log
*/
import { eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import type { BillmanagerSyncAccount } from './context.js'
import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js'
import type { SyncFromBillmanagerOptions, SyncFromBillmanagerResult } from './sync.js'
import { billmanagerAdapter } from '../providers/billmanager-adapter.js'
import { runAccountSync } from '../providers/sync-job.js'
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
ok: true
@@ -17,48 +16,5 @@ export async function runBillmanagerAccountSync(
account: BillmanagerSyncAccount,
opts: SyncFromBillmanagerOptions = {},
): Promise<RunBillmanagerAccountSyncResult> {
const db = getDb()
const logId = `sync-${account.id}-${Date.now()}`
db.insert(schema.syncLog)
.values({
id: logId,
accountId: account.id,
startedAt: new Date().toISOString(),
status: 'running',
})
.run()
try {
const result = await syncFromBillmanager(account, opts)
const summaryPayload = {
...(result.syncSummary || {}),
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount ?? 0,
}
db.update(schema.syncLog)
.set({
finishedAt: new Date().toISOString(),
status: 'ok',
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
summary: JSON.stringify(summaryPayload),
})
.where(eq(schema.syncLog.id, logId))
.run()
return { ok: true, logId, ...result }
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
db.update(schema.syncLog)
.set({
finishedAt: new Date().toISOString(),
status: 'error',
error: message,
summary: JSON.stringify({ error: message }),
})
.where(eq(schema.syncLog.id, logId))
.run()
throw err
}
return runAccountSync(billmanagerAdapter, account, opts)
}
+4 -3
View File
@@ -1,6 +1,7 @@
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
const STALE_SYNC_HOURS = 48
@@ -84,11 +85,11 @@ export function computeDashboardStats(): DashboardStats {
const providerById = new Map(snap.providers.map((p) => [p.id, p]))
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const bmAccounts = snap.providerAccounts.filter((a) => {
const syncAccounts = snap.providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
const staleSyncAccountCount = bmAccounts.filter((a) => {
const staleSyncAccountCount = syncAccounts.filter((a) => {
const t = lastOkSyncAt(a.id, snap.syncLog)
if (t == null) return true
return now.getTime() - t > staleMs
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fourvpsRequest, FourVpsApiError } from './client.js'
describe('fourvpsRequest', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('sends Bearer auth and panel_id query param', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ error: false, data: { userBalance: 100 } }),
})
vi.stubGlobal('fetch', fetchMock)
const data = await fourvpsRequest('https://4vps.su/api', 'test-key', '/userBalance', {
panelId: 2,
})
expect(data).toEqual({ userBalance: 100 })
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('panel_id=2')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer test-key')
})
it('throws FourVpsApiError on API error flag', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
error: true,
errorMessage: 'Authentication error',
data: false,
}),
}),
)
await expect(fourvpsRequest('https://4vps.su/api', 'bad', '/userBalance')).rejects.toThrow(
FourVpsApiError,
)
})
})
+78
View File
@@ -0,0 +1,78 @@
/**
* 4VPS.SU REST API HTTP client
* @see https://4vps.su/page/api
*/
export interface FourVpsResponse<T = unknown> {
error: boolean
errorMessage?: string | Record<string, unknown>
data: T
}
export class FourVpsApiError extends Error {
constructor(message: string) {
super(message)
this.name = 'FourVpsApiError'
}
}
function formatErrorMessage(errorMessage: FourVpsResponse['errorMessage']): string {
if (!errorMessage) return '4VPS API error'
if (typeof errorMessage === 'string') return errorMessage
const msg = errorMessage.message
if (typeof msg === 'string') return msg
return JSON.stringify(errorMessage)
}
function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${base}${p}`
}
export interface FourVpsRequestOptions {
method?: 'GET' | 'POST'
panelId?: number | null
body?: Record<string, string | number>
}
export async function fourvpsRequest<T = unknown>(
baseUrl: string,
apiKey: string,
path: string,
opts: FourVpsRequestOptions = {},
): Promise<T> {
const { method = 'GET', panelId, body } = opts
const url = new URL(joinUrl(baseUrl, path))
if (panelId != null) {
url.searchParams.set('panel_id', String(panelId))
}
const init: RequestInit = {
method,
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
Accept: 'application/json',
},
}
if (method === 'POST') {
const payload: Record<string, string | number> = { ...(body ?? {}) }
if (panelId != null && payload.panel_id == null) {
payload.panel_id = panelId
}
init.headers = { ...init.headers, 'Content-Type': 'application/json' }
init.body = JSON.stringify(payload)
}
const res = await fetch(url.toString(), init)
if (!res.ok) {
throw new FourVpsApiError(`4VPS API HTTP ${res.status}: ${res.statusText}`)
}
const json = (await res.json()) as FourVpsResponse<T>
if (json.error) {
throw new FourVpsApiError(formatErrorMessage(json.errorMessage))
}
return json.data
}
+33
View File
@@ -0,0 +1,33 @@
import type { schema } from '@cfdm/db'
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type ProviderRow = typeof schema.providers.$inferSelect
export interface FourvpsSyncAccount extends AccountRow {
apiType: '4vps'
apiBaseUrl: string
panelId: number | null
apiKey: string
}
export function resolveFourvpsApi(
accountRow: AccountRow | null | undefined,
providerRow: ProviderRow | null | undefined,
): { apiType: string; apiBaseUrl: string } {
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
return { apiType, apiBaseUrl }
}
export function fourvpsAccountRowForSync(
accountRow: AccountRow | null | undefined,
providerRow: ProviderRow | null | undefined,
): FourvpsSyncAccount | null {
if (!accountRow) return null
const { apiType, apiBaseUrl } = resolveFourvpsApi(accountRow, providerRow)
const cred = String(accountRow.apiCredentials || '').trim()
const { panelId, apiKey } = parseFourVpsCredentials(cred)
if (apiType !== '4vps' || !apiBaseUrl || !apiKey) return null
return { ...accountRow, apiType: '4vps', apiBaseUrl, panelId, apiKey }
}
+13
View File
@@ -0,0 +1,13 @@
export { fourvpsRequest, FourVpsApiError } from './client.js'
export type { FourvpsSyncAccount } from './context.js'
export { fourvpsAccountRowForSync, resolveFourvpsApi } from './context.js'
export { mapServerToVps } from './mappers.js'
export {
fetchDcList,
fetchMyServers,
fetchTarifList,
fetchUserBalance,
testConnection,
} from './operations.js'
export { syncFromFourvps } from './sync.js'
export type { SyncFromFourvpsOptions, SyncFromFourvpsResult } from './sync.js'
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { mapServerToVps } from './mappers.js'
import type { FourVpsServer } from './operations.js'
const sampleServer: FourVpsServer = {
id: 4140,
name: 'MyFirstServer',
price: 420,
dc: 7,
image: 'Alma Linux 8',
mem: 1,
cpu: 1,
disk: 10,
ipv4: '185.143.223.29',
status: 'active',
tname: 'USA-cx01',
time: 1664275501,
expired: 1666781101,
}
describe('mapServerToVps', () => {
it('maps myservers fields to VPS model', () => {
const dcMap = new Map([
[7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }],
])
const vps = mapServerToVps(sampleServer, 'prov-1', 'acc-1', dcMap)
expect(vps.externalId).toBe('4140')
expect(vps.ip).toBe('185.143.223.29')
expect(vps.vcpu).toBe(1)
expect(vps.ramGb).toBe(1)
expect(vps.diskGb).toBe(10)
expect(vps.os).toBe('Alma Linux 8')
expect(vps.status).toBe('active')
expect(vps.monthlyRate).toBe(420)
expect(vps.datacenter).toBe('USA DC1')
expect(vps.country).toBe('US')
expect(vps.paidUntil).toBe('2022-10-26')
expect(vps.notes).toContain('4vps-4140')
})
})
+101
View File
@@ -0,0 +1,101 @@
/**
* 4VPS API response → vps-tracker model mappers
*/
import type { FourVpsDatacenter, FourVpsServer } from './operations.js'
const STATUS_MAP: Record<string, string> = {
active: 'active',
paused: 'paused',
suspended: 'paused',
deleted: 'archived',
}
function unixToIso(ts: number | null | undefined): string {
if (ts == null || !Number.isFinite(ts) || ts <= 0) return ''
return new Date(ts * 1000).toISOString().slice(0, 10)
}
export interface MappedVps {
externalId: string
ip: string
dns: string
ipv6: string
additionalIps: string[]
providerId: string
providerAccountId: string
country: string
city: string
datacenter: string
os: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
bandwidthTb: number
sshPort: number
rootUser: string
purpose: string
environment: string
project: string
monitoringEnabled: boolean
backupEnabled: boolean
status: string
tariffType: string
currency: string
dailyRate: null
monthlyRate: number | null
createdAt: string
paidUntil: string
notes: string
}
export function mapServerToVps(
server: FourVpsServer,
providerId: string,
providerAccountId: string,
dcMap: Map<number, FourVpsDatacenter>,
): MappedVps {
const dc = dcMap.get(server.dc)
const datacenter = dc?.dc_name ?? String(server.dc)
const country = (dc?.flag ?? '').toUpperCase()
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
const monthlyRate = Number.isFinite(server.price) ? server.price : null
const name = String(server.name || '').trim()
return {
externalId: String(server.id),
ip: String(server.ipv4 || '').trim(),
dns: name,
ipv6: '',
additionalIps: [],
providerId,
providerAccountId,
country,
city: '',
datacenter,
os: String(server.image || '').trim(),
vcpu: server.cpu ?? 0,
ramGb: server.mem ?? 0,
diskGb: server.disk ?? 0,
diskType: 'NVMe',
virtualization: 'KVM',
bandwidthTb: 0,
sshPort: 22,
rootUser: 'root',
purpose: '',
environment: '',
project: '',
monitoringEnabled: false,
backupEnabled: false,
status,
tariffType: 'monthly',
currency: 'RUB',
dailyRate: null,
monthlyRate,
createdAt: unixToIso(server.time),
paidUntil: unixToIso(server.expired),
notes: name ? `${name} [4vps-${server.id}]` : `4vps-${server.id}`,
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* 4VPS.SU API operations
*/
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
import { fourvpsRequest } from './client.js'
export interface FourVpsServer {
id: number
name: string
price: number
dc: number
image: string
mem: number
cpu: number
disk: number
ipv4: string
status: string
tname: string
time: number
expired: number
autoprolong?: number
}
export interface FourVpsDatacenter {
id: number
dc_name: string
flag: string
cpu_name?: string
t_name?: string
}
export interface FourVpsTariffPreset {
id: number
name: string
nameFull?: string
cpu_number: number
ram_mib: number
rom?: number
commentParsed?: { price?: string | number; eth?: string }
disks?: { size_mib?: number; tags?: { name?: string }[] }[]
}
export interface FourVpsTariffItem {
externalId: string
datacenterKey: string
datacenterName: string
name: string
desc: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
channel: string
location: string
country: string
cpuModel: string
orderAvailable: boolean
price: string
}
export interface FourVpsBalanceInfo {
balance: number
currency: string
}
function parseCredentials(baseUrl: string, credentials: string) {
const url = baseUrl.trim()
const { panelId, apiKey } = parseFourVpsCredentials(credentials)
if (!url || !apiKey) {
throw new Error('API URL and credentials are required')
}
return { baseUrl: url, panelId, apiKey }
}
export async function fetchUserBalance(
baseUrl: string,
credentials: string,
fallbackCurrency = 'RUB',
): Promise<FourVpsBalanceInfo> {
const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials)
const data = await fourvpsRequest<{ userBalance?: number }>(url, apiKey, '/userBalance')
const raw = data?.userBalance
const balance = typeof raw === 'number' ? raw : Number.parseFloat(String(raw ?? ''))
return {
balance: Number.isFinite(balance) ? balance : 0,
currency: fallbackCurrency || 'RUB',
}
}
export async function fetchMyServers(baseUrl: string, credentials: string): Promise<FourVpsServer[]> {
const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials)
const data = await fourvpsRequest<{ serverlist?: FourVpsServer[] }>(url, apiKey, '/myservers')
return Array.isArray(data?.serverlist) ? data.serverlist : []
}
export async function fetchDcList(
baseUrl: string,
credentials: string,
): Promise<Map<number, FourVpsDatacenter>> {
const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials)
if (panelId == null) return new Map()
const data = await fourvpsRequest<{ dcList?: Record<string, FourVpsDatacenter> }>(
url,
apiKey,
'/getDcList',
{ panelId },
)
const map = new Map<number, FourVpsDatacenter>()
const dcList = data?.dcList ?? {}
for (const [key, dc] of Object.entries(dcList)) {
const id = dc?.id ?? Number.parseInt(key, 10)
if (Number.isFinite(id)) {
map.set(id, { ...dc, id })
}
}
return map
}
function mapPresetToTariffItem(
preset: FourVpsTariffPreset,
dcId: string,
dcName: string,
country: string,
cpuModel: string,
): FourVpsTariffItem {
const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0)
const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0
const diskTag = preset.disks?.[0]?.tags?.find((t) => t.name)?.name ?? 'nvme'
const priceRaw = preset.commentParsed?.price
const price = priceRaw != null ? String(priceRaw) : ''
return {
externalId: String(preset.id),
datacenterKey: dcId,
datacenterName: dcName,
name: preset.nameFull || preset.name || '',
desc: preset.commentParsed?.eth ? `Канал: ${preset.commentParsed.eth}` : '',
vcpu: preset.cpu_number ?? 0,
ramGb: preset.ram_mib ? Math.round((preset.ram_mib / 1024) * 10) / 10 : 0,
diskGb: typeof diskGb === 'number' ? diskGb : 0,
diskType: diskTag.toUpperCase(),
virtualization: 'KVM',
channel: preset.commentParsed?.eth ?? '',
location: dcName,
country,
cpuModel,
orderAvailable: true,
price,
}
}
export async function fetchTarifList(
baseUrl: string,
credentials: string,
): Promise<FourVpsTariffItem[]> {
const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials)
if (panelId == null) return []
const [tarifData, dcMap] = await Promise.all([
fourvpsRequest<{
tarifList?: Record<
string,
{
clusterInfo?: {
id?: number
dc_name?: string
flag?: string
cpu_name?: string
presets?: number[]
}
presets?: Record<string, FourVpsTariffPreset>
}
>
}>(url, apiKey, '/getTarifList', { panelId }),
fetchDcList(baseUrl, credentials),
])
const items: FourVpsTariffItem[] = []
const tarifList = tarifData?.tarifList ?? {}
for (const [dcKey, cluster] of Object.entries(tarifList)) {
const clusterInfo = cluster.clusterInfo ?? {}
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 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))
}
}
return items
}
export async function testConnection(
baseUrl: string,
credentials: string,
): Promise<{ ok: boolean; error?: string; vdsCount?: number; balance?: number }> {
if (!baseUrl?.trim() || !credentials?.trim()) {
return { ok: false, error: 'Укажите URL и учётные данные' }
}
try {
const [balanceInfo, servers] = await Promise.all([
fetchUserBalance(baseUrl, credentials),
fetchMyServers(baseUrl, credentials),
])
return { ok: true, vdsCount: servers.length, balance: balanceInfo.balance }
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка подключения'
return { ok: false, error: message }
}
}
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb, getSqlite } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { syncFromFourvps } from './sync.js'
import type { FourvpsSyncAccount } from './context.js'
vi.mock('./operations.js', () => ({
fetchMyServers: vi.fn(),
fetchUserBalance: vi.fn(),
fetchTarifList: vi.fn(),
fetchDcList: vi.fn(),
}))
import {
fetchDcList,
fetchMyServers,
fetchTarifList,
fetchUserBalance,
} from './operations.js'
const account: FourvpsSyncAccount = {
id: 'acc-4vps',
providerId: 'prov-4vps',
name: '4VPS Account',
panelUrl: '',
currency: 'RUB',
billingMode: 'monthly',
notes: '',
apiType: '4vps',
apiBaseUrl: 'https://4vps.su/api',
apiCredentials: '1:secret',
panelId: 1,
apiKey: 'secret',
balanceApi: null,
balanceCurrency: null,
balanceUpdatedAt: null,
enoughmoneyto: '',
balanceAlertBelow: null,
}
describe('syncFromFourvps', () => {
beforeEach(() => {
resetTestDb()
getSqlite()
.prepare(
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-4vps', '4VPS', '4vps', 'https://4vps.su/api')`,
)
.run()
providerAccountsRepository.create({
id: 'acc-4vps',
providerId: 'prov-4vps',
name: '4VPS Account',
apiCredentials: '1:secret',
})
vi.mocked(fetchMyServers).mockResolvedValue([
{
id: 100,
name: 'srv1',
price: 420,
dc: 1,
image: 'Debian 11',
mem: 2,
cpu: 2,
disk: 20,
ipv4: '1.2.3.4',
status: 'active',
tname: 'cx01',
time: 1664275501,
expired: 1666781101,
},
])
vi.mocked(fetchUserBalance).mockResolvedValue({ balance: 77825, currency: 'RUB' })
vi.mocked(fetchDcList).mockResolvedValue(
new Map([[1, { id: 1, dc_name: 'AE DC1', flag: 'ae' }]]),
)
vi.mocked(fetchTarifList).mockResolvedValue([
{
externalId: '13',
datacenterKey: '1',
datacenterName: 'AE DC1',
name: 'AE-cx01',
desc: '',
vcpu: 1,
ramGb: 1,
diskGb: 10,
diskType: 'NVME',
virtualization: 'KVM',
channel: '1Gbit/s',
location: 'AE DC1',
country: 'AE',
cpuModel: 'E5',
orderAvailable: true,
price: '420',
},
])
})
afterEach(() => {
closeDb()
vi.clearAllMocks()
})
it('upserts VPS, balance and tariffs', async () => {
const result = await syncFromFourvps(account)
expect(result.vpsCount).toBe(1)
expect(result.paymentsCount).toBe(0)
expect(result.tariffsCount).toBe(1)
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 }
expect(vps.id).toBe('vps-4vps-acc-4vps-100')
expect(vps.ip).toBe('1.2.3.4')
const tariffs = getSqlite()
.prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`)
.get('acc-4vps') as { c: number }
expect(tariffs.c).toBe(1)
const bal = getSqlite()
.prepare(`SELECT balance_api FROM provider_accounts WHERE id = ?`)
.get('acc-4vps') as { balance_api: number }
expect(bal.balance_api).toBe(77825)
})
})
+293
View File
@@ -0,0 +1,293 @@
/**
* Sync 4VPS data into vps-tracker DB (Drizzle / @cfdm/db)
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import type { FourvpsSyncAccount } from './context.js'
import { mapServerToVps } from './mappers.js'
import {
fetchDcList,
fetchMyServers,
fetchTarifList,
fetchUserBalance,
type FourVpsBalanceInfo,
} from './operations.js'
export interface SyncFromFourvpsOptions {
skipTariffs?: boolean
skipVpsPayments?: boolean
}
export interface SyncSummary {
added: { id: string; label: string }[]
updated: { id: string; label: string; fields: string[] }[]
paymentsAdded: number
tariffsOnly?: boolean
}
export interface SyncFromFourvpsResult {
vpsCount: number
paymentsCount: number
tariffsCount: number
newTariffs: { name: string; price: string; providerId: string }[]
balance: FourVpsBalanceInfo | null
syncSummary: SyncSummary
}
const SYNC_UPDATE_FIELDS = [
'country',
'city',
'datacenter',
'os',
'notes',
'status',
'tariffType',
'currency',
'dailyRate',
'monthlyRate',
'paidUntil',
] 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)
}
function buildCredentials(account: FourvpsSyncAccount): string {
const { panelId, apiKey } = account
if (panelId != null) return `${panelId}:${apiKey}`
return apiKey
}
export async function syncFromFourvps(
account: FourvpsSyncAccount,
opts: SyncFromFourvpsOptions = {},
): Promise<SyncFromFourvpsResult> {
const { skipTariffs = false, skipVpsPayments = false } = opts
const { apiBaseUrl, providerId, id: accountId } = account
const credentials = buildCredentials(account)
if (!apiBaseUrl?.trim() || !account.apiKey?.trim()) {
throw new Error('API URL and credentials are required')
}
const db = getDb()
const fetchVpsData = !skipVpsPayments
const fetchTariffs = !skipTariffs
const [servers, balanceInfo, tariffItems, dcMap] = await Promise.all([
fetchVpsData ? fetchMyServers(apiBaseUrl, credentials) : [],
fetchVpsData
? fetchUserBalance(apiBaseUrl, credentials, account.currency || 'RUB').catch(() => null)
: null,
fetchTariffs ? fetchTarifList(apiBaseUrl, credentials).catch(() => []) : [],
fetchVpsData || fetchTariffs ? fetchDcList(apiBaseUrl, credentials).catch(() => new Map()) : new Map(),
])
let vpsCount = 0
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
if (fetchVpsData) {
for (const server of servers) {
const vps = mapServerToVps(server, providerId, accountId, dcMap)
const id = `vps-4vps-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate
const monthlyRate = vps.monthlyRate
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%4vps-${vps.externalId}%`)),
),
)
.get()
if (existing) {
let userOverrides: string[] = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
const merged = {
ip: vps.ip,
ipv6: vps.ipv6,
additionalIps,
dns: vps.dns,
country: vps.country,
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
dailyRate,
monthlyRate,
paidUntil,
notes,
}
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
const changedFields = compareFields.filter(
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
)
if (changedFields.length > 0) {
const label = merged.dns || merged.ip || existing.id
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
}
db.update(schema.vps)
.set({
ip: merged.ip,
ipv6: merged.ipv6,
additionalIps: merged.additionalIps,
dns: merged.dns,
country: merged.country,
city: merged.city,
datacenter: merged.datacenter,
os: merged.os,
status: merged.status,
tariffType: merged.tariffType,
currency: merged.currency,
dailyRate: merged.dailyRate,
monthlyRate: merged.monthlyRate,
paidUntil: merged.paidUntil,
notes: merged.notes,
})
.where(eq(schema.vps.id, existing.id))
.run()
} else {
const label = vps.dns || vps.ip || id
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
id,
ip: vps.ip,
ipv6: vps.ipv6,
additionalIps,
dns: vps.dns,
providerId: vps.providerId,
providerAccountId: vps.providerAccountId,
country: vps.country,
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
diskType: vps.diskType,
virtualization: vps.virtualization,
bandwidthTb: vps.bandwidthTb,
sshPort: vps.sshPort,
rootUser: vps.rootUser,
purpose: vps.purpose,
environment: vps.environment,
project: vps.project,
projectId: null,
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
backupEnabled: vps.backupEnabled ? 1 : 0,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
dailyRate,
monthlyRate,
createdAt: vps.createdAt || new Date().toISOString().slice(0, 10),
paidUntil,
notes,
userOverrides: '[]',
})
.run()
}
vpsCount++
}
}
if (fetchVpsData && balanceInfo) {
db.update(schema.providerAccounts)
.set({
balanceApi: balanceInfo.balance,
balanceCurrency: balanceInfo.currency || 'RUB',
balanceUpdatedAt: new Date().toISOString(),
enoughmoneyto: '',
})
.where(eq(schema.providerAccounts.id, accountId))
.run()
}
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-4vps-${accountId}-${t.externalId}-${dcKey}`
: `tariff-4vps-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
}
if (!fetchVpsData) {
syncSummary.tariffsOnly = true
}
return {
vpsCount,
paymentsCount: 0,
tariffsCount,
newTariffs,
balance: balanceInfo,
syncSummary,
}
}
@@ -0,0 +1,40 @@
import { syncFromBillmanager } from '../billmanager/sync.js'
import { testConnection as bmTestConnection, fetchDashboardInfo } from '../billmanager/operations.js'
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
import type { ProviderAdapter, SyncResult } from './types.js'
export const billmanagerAdapter: ProviderAdapter = {
type: 'billmanager',
async testConnection(apiBaseUrl: string, apiCredentials: string) {
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
return { ok: result.ok, message: result.error }
},
async syncAccount(
account: BillmanagerSyncAccount,
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
): Promise<SyncResult> {
const result = await syncFromBillmanager(account, options)
return {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount,
balance: result.balance,
syncSummary: result.syncSummary,
newTariffs: result.newTariffs,
}
},
async fetchBalance(account: BillmanagerSyncAccount) {
const info = await fetchDashboardInfo(account.apiBaseUrl, String(account.apiCredentials).trim(), {
fallbackCurrency: account.currency,
})
return {
balance: info.balance,
currency: info.currency || 'RUB',
enoughmoneyto: info.enoughmoneyto || '',
}
},
}
@@ -0,0 +1,43 @@
import { syncFromFourvps } from '../fourvps/sync.js'
import {
fetchUserBalance as fetchFourvpsBalance,
testConnection as fourvpsTestConnection,
} from '../fourvps/operations.js'
import type { FourvpsSyncAccount } from '../fourvps/context.js'
import type { ProviderAdapter, SyncResult } from './types.js'
export const fourvpsAdapter: ProviderAdapter = {
type: '4vps',
async testConnection(apiBaseUrl: string, apiCredentials: string) {
const result = await fourvpsTestConnection(apiBaseUrl, apiCredentials)
return { ok: result.ok, message: result.error }
},
async syncAccount(
account: FourvpsSyncAccount,
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
): Promise<SyncResult> {
const result = await syncFromFourvps(account, options)
return {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount,
balance: result.balance,
syncSummary: result.syncSummary,
newTariffs: result.newTariffs,
}
},
async fetchBalance(account: FourvpsSyncAccount) {
const cred =
account.panelId != null ? `${account.panelId}:${account.apiKey}` : account.apiKey
const info = await fetchFourvpsBalance(account.apiBaseUrl, cred, account.currency || 'RUB')
return {
balance: info.balance,
currency: info.currency || 'RUB',
enoughmoneyto: '',
}
},
}
+41 -22
View File
@@ -1,28 +1,16 @@
import type { ProviderAdapter, SyncResult } from './types.js'
import { syncFromBillmanager } from '../billmanager/sync.js'
import type { schema } from '@cfdm/db'
import { billmanagerAccountRowForSync } from '../billmanager/context.js'
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
import { testConnection as bmTestConnection } from '../billmanager/operations.js'
import type { FourvpsSyncAccount } from '../fourvps/context.js'
export const billmanagerAdapter: ProviderAdapter = {
type: 'billmanager',
import { billmanagerAdapter } from './billmanager-adapter.js'
import { fourvpsAdapter } from './fourvps-adapter.js'
import type { ProviderAdapter } from './types.js'
async testConnection(apiBaseUrl: string, apiCredentials: string) {
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
return { ok: result.ok, message: result.error }
},
async syncAccount(account: BillmanagerSyncAccount, options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }): Promise<SyncResult> {
const result = await syncFromBillmanager(account, options)
return {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount,
balance: result.balance,
syncSummary: result.syncSummary,
newTariffs: result.newTariffs,
}
},
}
export { billmanagerAdapter } from './billmanager-adapter.js'
export { fourvpsAdapter } from './fourvps-adapter.js'
export const manualAdapter: ProviderAdapter = {
type: 'manual',
@@ -43,6 +31,7 @@ export const manualAdapter: ProviderAdapter = {
const adapters: Record<string, ProviderAdapter> = {
billmanager: billmanagerAdapter,
'4vps': fourvpsAdapter,
manual: manualAdapter,
none: manualAdapter,
}
@@ -51,3 +40,33 @@ export function getProviderAdapter(apiType: string | null | undefined): Provider
const key = (apiType || 'none').toLowerCase().trim()
return adapters[key] ?? manualAdapter
}
type AccountRow = typeof schema.providerAccounts.$inferSelect
type ProviderRow = typeof schema.providers.$inferSelect
export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount
export function resolveSyncAccount(
accountRow: AccountRow | null | undefined,
providerRow: ProviderRow | null | undefined,
): { apiType: string; account: SyncReadyAccount } | null {
if (!accountRow) return null
const apiType = String(providerRow?.apiType || accountRow.apiType || '')
.trim()
.toLowerCase()
if (apiType === 'billmanager') {
const account = billmanagerAccountRowForSync(accountRow, providerRow)
return account ? { apiType, account } : null
}
if (apiType === '4vps') {
const account = fourvpsAccountRowForSync(accountRow, providerRow)
return account ? { apiType, account } : null
}
return null
}
export const SYNC_SETUP_ERRORS: Record<string, string> = {
billmanager: 'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
'4vps': 'Укажите в настройках хостера тип API 4VPS и URL; в аккаунте — Panel ID и API Key',
}
@@ -0,0 +1,65 @@
/**
* Generic account sync with sync_log recording
*/
import { eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import type { ProviderAdapter, SyncResult } from './types.js'
export interface RunAccountSyncResult extends SyncResult {
ok: true
logId: string
}
export async function runAccountSync(
adapter: ProviderAdapter,
account: unknown,
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
): Promise<RunAccountSyncResult> {
const db = getDb()
const accountRow = account as { id: string }
const logId = `sync-${accountRow.id}-${Date.now()}`
db.insert(schema.syncLog)
.values({
id: logId,
accountId: accountRow.id,
startedAt: new Date().toISOString(),
status: 'running',
})
.run()
try {
const result = await adapter.syncAccount(account, opts)
const summaryPayload = {
...(result.syncSummary || {}),
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount ?? 0,
}
db.update(schema.syncLog)
.set({
finishedAt: new Date().toISOString(),
status: 'ok',
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
summary: JSON.stringify(summaryPayload),
})
.where(eq(schema.syncLog.id, logId))
.run()
return { ok: true, logId, ...result }
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
db.update(schema.syncLog)
.set({
finishedAt: new Date().toISOString(),
status: 'error',
error: message,
summary: JSON.stringify({ error: message }),
})
.where(eq(schema.syncLog.id, logId))
.run()
throw err
}
}
+5
View File
@@ -21,4 +21,9 @@ export interface ProviderAdapter {
account: unknown,
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
): Promise<SyncResult>
fetchBalance?(account: unknown): Promise<{
balance?: number
currency?: string
enoughmoneyto?: string
}>
}
+22 -12
View File
@@ -2,8 +2,8 @@ import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { billmanagerAccountRowForSync } from './billmanager/context.js'
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
import { runAccountSync } from './providers/sync-job.js'
import { runVpsUptimeChecks } from './uptime-check.js'
import { publishMany, publishNotification } from './notifications/engine.js'
import {
@@ -23,21 +23,29 @@ const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
interface SyncableAccountEntry {
account: SyncReadyAccount
apiType: string
}
function getSyncableAccounts(): SyncableAccountEntry[] {
const db = getDb()
const rows = db
.all<AccountRow>(sql`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps')
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`)
const providers = db.select().from(schema.providers).all()
const providerById = new Map(providers.map((p) => [p.id, p]))
return rows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter((a): a is NonNullable<typeof a> => a != null)
.map((a) => {
const resolved = resolveSyncAccount(a, providerById.get(a.providerId))
return resolved ? { account: resolved.account, apiType: resolved.apiType } : null
})
.filter((e): e is SyncableAccountEntry => e != null)
}
export async function runNotificationTick(): Promise<void> {
@@ -56,13 +64,14 @@ export async function runScheduledSync(): Promise<void> {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
const accounts = getBillmanagerAccounts()
const entries = getSyncableAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
for (const account of accounts) {
for (const { account, apiType } of entries) {
try {
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipTariffs: true })
const s = result.syncSummary
const parts: string[] = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
@@ -103,12 +112,13 @@ export async function runScheduledSyncTariffs(): Promise<void> {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
const accounts = getBillmanagerAccounts()
const entries = getSyncableAccounts()
const providers = getDb().select().from(schema.providers).all()
for (const account of accounts) {
for (const { account, apiType } of entries) {
try {
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
const provider = providers.find((p) => p.id === account.providerId)
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
buildFourVpsCredentials,
parseFourVpsCredentials,
} from '@cfdm/shared/utils/api-credentials'
describe('parseFourVpsCredentials', () => {
it('parses panelId:apiKey', () => {
expect(parseFourVpsCredentials('1:secret-key')).toEqual({
panelId: 1,
apiKey: 'secret-key',
})
})
it('returns apiKey only when no colon', () => {
expect(parseFourVpsCredentials('secret-only')).toEqual({
panelId: null,
apiKey: 'secret-only',
})
})
it('handles empty', () => {
expect(parseFourVpsCredentials('')).toEqual({ panelId: null, apiKey: '' })
})
})
describe('buildFourVpsCredentials', () => {
it('builds panelId:apiKey', () => {
expect(buildFourVpsCredentials('1', 'key')).toBe('1:key')
})
it('returns key only without panel', () => {
expect(buildFourVpsCredentials('', 'key')).toBe('key')
})
})