Добавлен отдельный модуль veesp для синка VPS, баланса, счетов и тарифов через JWT/Basic auth. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,17 @@ vi.mock('../services/userapi/sync.js', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../services/veesp/sync.js', () => ({
|
||||
syncFromVeesp: vi.fn().mockResolvedValue({
|
||||
vpsCount: 2,
|
||||
paymentsCount: 1,
|
||||
tariffsCount: 1,
|
||||
newTariffs: [],
|
||||
balance: { balance: 123.45, currency: 'EUR', enoughmoneyto: '' },
|
||||
syncSummary: { added: [], updated: [], paymentsAdded: 1 },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('sync routes — 4vps', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
@@ -189,3 +200,80 @@ describe('sync routes — vdsina', () => {
|
||||
expect(body.synced?.vpsCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync routes — veesp', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
seedTestProvider('prov-veesp')
|
||||
providersRepository.update('prov-veesp', {
|
||||
apiType: 'veesp',
|
||||
apiBaseUrl: 'https://secure.veesp.com/api',
|
||||
})
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-veesp',
|
||||
providerId: 'prov-veesp',
|
||||
name: 'Veesp',
|
||||
apiCredentials: 'user@example.com:secret',
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('POST /api/sync/:accountId syncs veesp account', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/acc-veesp',
|
||||
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 veesp', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(async (url: string) => {
|
||||
const path = String(url)
|
||||
if (path.includes('/login')) {
|
||||
return { ok: true, json: async () => ({ token: 'jwt' }) }
|
||||
}
|
||||
if (path.includes('/balance')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
details: { currency: 'EUR', acc_balance: '100.00', acc_credit: '0.00' },
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (path.includes('/category')) {
|
||||
return { ok: true, json: async () => ({ categories: [] }) }
|
||||
}
|
||||
if (path.includes('/service')) {
|
||||
return { ok: true, json: async () => ({ services: [] }) }
|
||||
}
|
||||
return { ok: true, json: async () => ({}) }
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sync/test-connection',
|
||||
payload: {
|
||||
apiBaseUrl: 'https://secure.veesp.com/api',
|
||||
apiCredentials: 'user@example.com:secret',
|
||||
apiType: 'veesp',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect((res.json() as { ok?: boolean }).ok).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,22 @@ import type { schema } from '@cfdm/db'
|
||||
import { billmanagerAccountRowForSync } from '../billmanager/context.js'
|
||||
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
|
||||
import { userApiAccountRowForSync } from '../userapi/context.js'
|
||||
import { veespAccountRowForSync } from '../veesp/context.js'
|
||||
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||
import type { UserApiSyncAccount } from '../userapi/context.js'
|
||||
import type { VeespSyncAccount } from '../veesp/context.js'
|
||||
|
||||
import { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
import { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
import { userapiAdapter } from './userapi-adapter.js'
|
||||
import { veespAdapter } from './veesp-adapter.js'
|
||||
import type { ProviderAdapter } from './types.js'
|
||||
|
||||
export { billmanagerAdapter } from './billmanager-adapter.js'
|
||||
export { fourvpsAdapter } from './fourvps-adapter.js'
|
||||
export { userapiAdapter } from './userapi-adapter.js'
|
||||
export { veespAdapter } from './veesp-adapter.js'
|
||||
|
||||
export const manualAdapter: ProviderAdapter = {
|
||||
type: 'manual',
|
||||
@@ -38,6 +42,7 @@ const adapters: Record<string, ProviderAdapter> = {
|
||||
'4vps': fourvpsAdapter,
|
||||
macloud: userapiAdapter,
|
||||
vdsina: userapiAdapter,
|
||||
veesp: veespAdapter,
|
||||
manual: manualAdapter,
|
||||
none: manualAdapter,
|
||||
}
|
||||
@@ -50,7 +55,11 @@ export function getProviderAdapter(apiType: string | null | undefined): Provider
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount | UserApiSyncAccount
|
||||
export type SyncReadyAccount =
|
||||
| BillmanagerSyncAccount
|
||||
| FourvpsSyncAccount
|
||||
| UserApiSyncAccount
|
||||
| VeespSyncAccount
|
||||
|
||||
export function resolveSyncAccount(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
@@ -73,6 +82,10 @@ export function resolveSyncAccount(
|
||||
const account = userApiAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
if (apiType === 'veesp') {
|
||||
const account = veespAccountRowForSync(accountRow, providerRow)
|
||||
return account ? { apiType, account } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -81,4 +94,5 @@ export const SYNC_SETUP_ERRORS: Record<string, string> = {
|
||||
'4vps': 'Укажите в настройках хостера тип API 4VPS и URL; в аккаунте — Panel ID и API Key',
|
||||
macloud: 'Укажите тип API Маклауд и URL; в аккаунте — API Token',
|
||||
vdsina: 'Укажите тип API VDSina и URL; в аккаунте — API Token',
|
||||
veesp: 'Укажите тип API Veesp и URL; в аккаунте — email и пароль client area',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { syncFromVeesp } from '../veesp/sync.js'
|
||||
import { fetchBalance, testConnection } from '../veesp/operations.js'
|
||||
import type { VeespSyncAccount } from '../veesp/context.js'
|
||||
import { veespCredentialsString } from '../veesp/context.js'
|
||||
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
export const veespAdapter: ProviderAdapter = {
|
||||
type: 'veesp',
|
||||
|
||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||
const result = await testConnection(apiBaseUrl, apiCredentials)
|
||||
return { ok: result.ok, message: result.error }
|
||||
},
|
||||
|
||||
async syncAccount(
|
||||
account: VeespSyncAccount,
|
||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<SyncResult> {
|
||||
const result = await syncFromVeesp(account, options)
|
||||
return {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount,
|
||||
balance: result.balance,
|
||||
syncSummary: result.syncSummary,
|
||||
newTariffs: result.newTariffs,
|
||||
}
|
||||
},
|
||||
|
||||
async fetchBalance(account: VeespSyncAccount) {
|
||||
const fallbackCurrency = syncFallbackCurrency(account)
|
||||
const info = await fetchBalance(
|
||||
account.apiBaseUrl,
|
||||
veespCredentialsString(account),
|
||||
fallbackCurrency,
|
||||
)
|
||||
return {
|
||||
balance: info.balance,
|
||||
currency: info.currency || fallbackCurrency,
|
||||
enoughmoneyto: info.enoughmoneyto || '',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||
.all<AccountRow>(sql`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina')
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp')
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { VeespClient, parseVeespCredentials, veespLogin } from './client.js'
|
||||
|
||||
describe('parseVeespCredentials', () => {
|
||||
it('parses email:password', () => {
|
||||
expect(parseVeespCredentials('user@example.com:secret')).toEqual({
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when format invalid', () => {
|
||||
expect(() => parseVeespCredentials('token-only')).toThrow(/email:password/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('veespLogin', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns token from login response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'jwt-token' }),
|
||||
}),
|
||||
)
|
||||
|
||||
const token = await veespLogin('https://secure.veesp.com/api', {
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
})
|
||||
expect(token).toBe('jwt-token')
|
||||
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('VeespClient.request', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses Bearer token after login', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'jwt-token' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ services: [] }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const client = new VeespClient('https://secure.veesp.com/api', {
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
})
|
||||
const data = await client.request<{ services: unknown[] }>('/service')
|
||||
|
||||
expect(data.services).toEqual([])
|
||||
const [, init] = fetchMock.mock.calls[1] as [string, RequestInit]
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer jwt-token')
|
||||
})
|
||||
|
||||
it('throws VeespApiError on HTTP error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ message: 'Unauthorized' }),
|
||||
}),
|
||||
)
|
||||
|
||||
const client = new VeespClient('https://secure.veesp.com/api', {
|
||||
username: 'user@example.com',
|
||||
password: 'bad',
|
||||
})
|
||||
|
||||
await expect(client.request('/service')).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Veesp client area REST API HTTP client
|
||||
* @see https://secure.veesp.com/userapi
|
||||
*/
|
||||
|
||||
export class VeespApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'VeespApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${p}`
|
||||
}
|
||||
|
||||
export interface VeespCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface VeespLoginResponse {
|
||||
token?: string
|
||||
refresh?: string
|
||||
}
|
||||
|
||||
export interface VeespRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
body?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function parseVeespCredentials(credentials: string): VeespCredentials {
|
||||
const cred = credentials.trim()
|
||||
const idx = cred.indexOf(':')
|
||||
if (idx <= 0) {
|
||||
throw new VeespApiError('Укажите учётные данные в формате email:password')
|
||||
}
|
||||
const username = cred.slice(0, idx).trim()
|
||||
const password = cred.slice(idx + 1)
|
||||
if (!username || !password) {
|
||||
throw new VeespApiError('Укажите email и пароль client area Veesp')
|
||||
}
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
function basicAuthHeader(creds: VeespCredentials): string {
|
||||
const encoded = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
|
||||
return `Basic ${encoded}`
|
||||
}
|
||||
|
||||
function extractErrorMessage(json: unknown, status: number): string {
|
||||
if (json && typeof json === 'object') {
|
||||
const obj = json as Record<string, unknown>
|
||||
const msg = obj.message ?? obj.error ?? obj.description
|
||||
if (typeof msg === 'string' && msg.trim()) return msg
|
||||
if (obj.success === false && typeof obj.info === 'string') return obj.info
|
||||
}
|
||||
return `Veesp API HTTP ${status}`
|
||||
}
|
||||
|
||||
export async function veespLogin(baseUrl: string, creds: VeespCredentials): Promise<string> {
|
||||
const url = joinUrl(baseUrl, '/login')
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ username: creds.username, password: creds.password }),
|
||||
})
|
||||
const json = (await res.json()) as VeespLoginResponse & Record<string, unknown>
|
||||
if (!res.ok || !json.token) {
|
||||
throw new VeespApiError(extractErrorMessage(json, res.status) || 'Не удалось получить JWT token')
|
||||
}
|
||||
return String(json.token)
|
||||
}
|
||||
|
||||
export class VeespClient {
|
||||
private token: string | null = null
|
||||
|
||||
constructor(
|
||||
readonly baseUrl: string,
|
||||
readonly creds: VeespCredentials,
|
||||
) {}
|
||||
|
||||
async ensureToken(): Promise<string> {
|
||||
if (this.token) return this.token
|
||||
try {
|
||||
this.token = await veespLogin(this.baseUrl, this.creds)
|
||||
return this.token
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = unknown>(path: string, opts: VeespRequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body } = opts
|
||||
const url = joinUrl(this.baseUrl, path)
|
||||
const token = await this.ensureToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
Authorization: token ? `Bearer ${token}` : basicAuthHeader(this.creds),
|
||||
}
|
||||
if (body && method !== 'GET') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const init: RequestInit = { method, headers }
|
||||
if (body && method !== 'GET') {
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
let res = await fetch(url, init)
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
this.token = null
|
||||
const retryToken = await this.ensureToken()
|
||||
if (retryToken) {
|
||||
headers.Authorization = `Bearer ${retryToken}`
|
||||
res = await fetch(url, { ...init, headers })
|
||||
}
|
||||
}
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch {
|
||||
if (!res.ok) throw new VeespApiError(`Veesp API HTTP ${res.status}`)
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new VeespApiError(extractErrorMessage(json, res.status))
|
||||
}
|
||||
|
||||
if (json && typeof json === 'object') {
|
||||
const obj = json as Record<string, unknown>
|
||||
if (obj.success === false) {
|
||||
throw new VeespApiError(extractErrorMessage(json, res.status))
|
||||
}
|
||||
}
|
||||
|
||||
return json as T
|
||||
}
|
||||
}
|
||||
|
||||
export function createVeespClient(baseUrl: string, credentials: string): VeespClient {
|
||||
return new VeespClient(baseUrl.trim(), parseVeespCredentials(credentials))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
import { parseApiLogin } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface VeespSyncAccount extends AccountRow {
|
||||
apiType: 'veesp'
|
||||
apiBaseUrl: string
|
||||
apiLogin: string
|
||||
apiPassword: string
|
||||
providerBaseCurrency?: string | null
|
||||
}
|
||||
|
||||
function parseApiPassword(credentials: string | null | undefined): string {
|
||||
const cred = String(credentials ?? '').trim()
|
||||
const idx = cred.indexOf(':')
|
||||
return idx > 0 ? cred.slice(idx + 1) : ''
|
||||
}
|
||||
|
||||
export function resolveVeespApi(
|
||||
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 veespAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): VeespSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveVeespApi(accountRow, providerRow)
|
||||
const apiLogin = parseApiLogin(accountRow.apiCredentials)
|
||||
const apiPassword = parseApiPassword(accountRow.apiCredentials)
|
||||
if (apiType !== 'veesp' || !apiBaseUrl || !apiLogin || !apiPassword) return null
|
||||
return {
|
||||
...accountRow,
|
||||
apiType: 'veesp',
|
||||
apiBaseUrl,
|
||||
apiLogin,
|
||||
apiPassword,
|
||||
providerBaseCurrency: providerRow?.baseCurrency ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function veespCredentialsString(account: VeespSyncAccount): string {
|
||||
return `${account.apiLogin}:${account.apiPassword}`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { VeespApiError, createVeespClient, veespLogin, parseVeespCredentials } from './client.js'
|
||||
export type { VeespClient, VeespCredentials } from './client.js'
|
||||
export type { VeespSyncAccount } from './context.js'
|
||||
export { veespAccountRowForSync, resolveVeespApi, veespCredentialsString } from './context.js'
|
||||
export { mapVpsRecordToVps, mapInvoiceToPayment } from './mappers.js'
|
||||
export {
|
||||
fetchBalance,
|
||||
fetchInvoices,
|
||||
fetchServices,
|
||||
fetchTariffList,
|
||||
fetchVpsRecords,
|
||||
isVpsService,
|
||||
testConnection,
|
||||
} from './operations.js'
|
||||
export { syncFromVeesp } from './sync.js'
|
||||
export type { SyncFromVeespOptions, SyncFromVeespResult } from './sync.js'
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapInvoiceToPayment, mapVpsRecordToVps } from './mappers.js'
|
||||
import type { VeespVpsRecord } from './operations.js'
|
||||
|
||||
const baseRecord: VeespVpsRecord = {
|
||||
serviceId: '32723',
|
||||
vmId: '18867',
|
||||
service: {
|
||||
id: '32723',
|
||||
domain: 'vm.example.com',
|
||||
total: '9.99',
|
||||
status: 'Active',
|
||||
billingcycle: 'Monthly',
|
||||
next_due: '2027-06-01',
|
||||
category: 'Proxmox',
|
||||
category_url: 'virtual-private-servers',
|
||||
name: 'VPS Starter',
|
||||
},
|
||||
serviceDetail: {
|
||||
id: '32723',
|
||||
domain: 'vm.example.com',
|
||||
total: '9.99',
|
||||
billingcycle: 'Monthly',
|
||||
next_due: '2027-06-01',
|
||||
status: 'Active',
|
||||
name: 'VPS Starter',
|
||||
date_created: '2026-01-01',
|
||||
},
|
||||
vm: {
|
||||
id: '18867',
|
||||
hostname: 'vm.example.com',
|
||||
ip: '203.0.113.10',
|
||||
status: 'active',
|
||||
os: 'Debian 12',
|
||||
cpu: 2,
|
||||
ram: 4,
|
||||
disk: 80,
|
||||
},
|
||||
ips: [{ ip: '203.0.113.10', main: true }],
|
||||
info: { os: 'Debian 12', cpu: 2, ram: 4, disk: 80 },
|
||||
}
|
||||
|
||||
describe('mapVpsRecordToVps', () => {
|
||||
it('maps veesp VPS with externalId service-vm', () => {
|
||||
const vps = mapVpsRecordToVps(baseRecord, 'prov-1', 'acc-1', 'EUR')
|
||||
expect(vps.externalId).toBe('32723-18867')
|
||||
expect(vps.ip).toBe('203.0.113.10')
|
||||
expect(vps.dns).toBe('vm.example.com')
|
||||
expect(vps.monthlyRate).toBe(9.99)
|
||||
expect(vps.tariffType).toBe('monthly')
|
||||
expect(vps.paidUntil).toBe('2027-06-01')
|
||||
expect(vps.notes).toContain('veesp-32723-18867')
|
||||
})
|
||||
|
||||
it('maps suspended service to paused', () => {
|
||||
const vps = mapVpsRecordToVps(
|
||||
{
|
||||
...baseRecord,
|
||||
service: { ...baseRecord.service, status: 'Suspended' },
|
||||
},
|
||||
'prov-1',
|
||||
'acc-1',
|
||||
'EUR',
|
||||
)
|
||||
expect(vps.status).toBe('paused')
|
||||
})
|
||||
|
||||
it('uses service id when vm is absent', () => {
|
||||
const vps = mapVpsRecordToVps(
|
||||
{
|
||||
...baseRecord,
|
||||
vmId: null,
|
||||
vm: null,
|
||||
},
|
||||
'prov-1',
|
||||
'acc-1',
|
||||
'EUR',
|
||||
)
|
||||
expect(vps.externalId).toBe('32723')
|
||||
expect(vps.notes).toContain('veesp-32723')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapInvoiceToPayment', () => {
|
||||
it('maps paid invoice to topup payment', () => {
|
||||
const payment = mapInvoiceToPayment(
|
||||
{
|
||||
id: '308976',
|
||||
date: '2016-12-30',
|
||||
datepaid: '2016-12-30 12:40:47',
|
||||
total: '19.65',
|
||||
status: 'Paid',
|
||||
currency: 'USD',
|
||||
},
|
||||
'acc-1',
|
||||
'EUR',
|
||||
)
|
||||
expect(payment).toEqual({
|
||||
externalId: '308976',
|
||||
type: 'topup',
|
||||
date: '2016-12-30',
|
||||
amount: 19.65,
|
||||
currency: 'USD',
|
||||
providerAccountId: 'acc-1',
|
||||
vpsId: null,
|
||||
note: 'veesp-invoice-308976',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for unpaid invoice', () => {
|
||||
expect(
|
||||
mapInvoiceToPayment({ id: '1', status: 'Unpaid', total: '10' }, 'acc-1', 'EUR'),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Veesp API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import type { VeespInvoice, VeespVpsRecord } from './operations.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
pending: 'paused',
|
||||
suspended: 'paused',
|
||||
paused: 'paused',
|
||||
cancelled: 'archived',
|
||||
canceled: 'archived',
|
||||
terminated: 'archived',
|
||||
deleted: 'archived',
|
||||
}
|
||||
|
||||
function dateToIso(value: string | undefined | null): string {
|
||||
if (!value) return ''
|
||||
return String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
function parseNumber(raw: string | number | undefined | null): number {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0
|
||||
const n = Number.parseFloat(String(raw ?? '').replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function roundRate(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function mapBillingCycle(cycle: string | undefined): {
|
||||
tariffType: string
|
||||
dailyRate: number | null
|
||||
monthlyRate: number | null
|
||||
} {
|
||||
const c = String(cycle ?? '').toLowerCase()
|
||||
return { tariffType: c.includes('day') ? 'daily' : 'monthly', dailyRate: null, monthlyRate: null }
|
||||
}
|
||||
|
||||
function ratesFromTotal(
|
||||
total: number,
|
||||
cycle: string | undefined,
|
||||
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
|
||||
const base = mapBillingCycle(cycle)
|
||||
const c = String(cycle ?? '').toLowerCase()
|
||||
if (c.includes('day')) {
|
||||
return { tariffType: 'daily', dailyRate: roundRate(total), monthlyRate: null }
|
||||
}
|
||||
if (c.includes('week')) {
|
||||
return { tariffType: 'daily', dailyRate: roundRate(total / 7), monthlyRate: null }
|
||||
}
|
||||
if (c.includes('quarter')) {
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total / 3) }
|
||||
}
|
||||
if (c.includes('semi') || c.includes('6')) {
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total / 6) }
|
||||
}
|
||||
if (c.includes('annual') || c.includes('year')) {
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total / 12) }
|
||||
}
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) }
|
||||
}
|
||||
|
||||
function pickPrimaryIp(ips: VeespVpsRecord['ips'], vm: VeespVpsRecord['vm'], domain?: string): string {
|
||||
const vmIp = String(vm?.ip ?? vm?.ipv4 ?? '').trim()
|
||||
if (vmIp) return vmIp
|
||||
|
||||
for (const item of ips) {
|
||||
const ip = String(item.ip ?? item.address ?? '').trim()
|
||||
if (!ip) continue
|
||||
if (item.main === true || item.main === 1 || String(item.main) === '1') return ip
|
||||
}
|
||||
for (const item of ips) {
|
||||
const ip = String(item.ip ?? item.address ?? '').trim()
|
||||
if (ip) return ip
|
||||
}
|
||||
const d = String(domain ?? '').trim()
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(d)) return d
|
||||
return ''
|
||||
}
|
||||
|
||||
function pickServiceStatus(...values: (string | undefined)[]): string | undefined {
|
||||
for (const value of values) {
|
||||
const key = String(value ?? '').toLowerCase()
|
||||
if (key && STATUS_MAP[key] && STATUS_MAP[key] !== 'active') return value
|
||||
}
|
||||
return values.find(Boolean)
|
||||
}
|
||||
|
||||
function mapStatus(serviceStatus?: string, vmStatus?: string): string {
|
||||
const serviceKey = String(serviceStatus ?? '').toLowerCase()
|
||||
const vmKey = String(vmStatus ?? '').toLowerCase()
|
||||
const serviceMapped = STATUS_MAP[serviceKey]
|
||||
const vmMapped = STATUS_MAP[vmKey]
|
||||
if (serviceMapped && serviceMapped !== 'active') return serviceMapped
|
||||
if (vmMapped) return vmMapped
|
||||
return 'active'
|
||||
}
|
||||
|
||||
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: number | null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface MappedPayment {
|
||||
externalId: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId: string | null
|
||||
note: string
|
||||
}
|
||||
|
||||
export function mapVpsRecordToVps(
|
||||
record: VeespVpsRecord,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
currency: string,
|
||||
): MappedVps {
|
||||
const { service, serviceDetail, vm, info, ips, serviceId, vmId } = record
|
||||
const externalId = vmId ? `${serviceId}-${vmId}` : serviceId
|
||||
const detail = serviceDetail ?? service
|
||||
const total = parseNumber(detail.total ?? service.total)
|
||||
const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle)
|
||||
const hostname = String(
|
||||
vm?.hostname ?? vm?.name ?? info?.hostname ?? detail.domain ?? service.domain ?? detail.name ?? service.name ?? '',
|
||||
).trim()
|
||||
const ip = pickPrimaryIp(ips, vm, detail.domain ?? service.domain)
|
||||
const os = String(vm?.os ?? vm?.template ?? info?.os ?? info?.template ?? '').trim()
|
||||
const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? info?.cpu)
|
||||
const ramGb = parseNumber(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.memory)
|
||||
const diskGb = parseNumber(vm?.disk ?? info?.disk)
|
||||
const status = mapStatus(
|
||||
pickServiceStatus(service.status, detail.status),
|
||||
vm?.status ?? vm?.state,
|
||||
)
|
||||
const label = hostname || ip || detail.name || service.name || externalId
|
||||
|
||||
return {
|
||||
externalId,
|
||||
ip,
|
||||
dns: hostname,
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: '',
|
||||
city: '',
|
||||
datacenter: String(service.category ?? '').trim(),
|
||||
os,
|
||||
vcpu,
|
||||
ramGb,
|
||||
diskGb,
|
||||
diskType: 'NVMe',
|
||||
virtualization: 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: '',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: rates.tariffType,
|
||||
currency,
|
||||
dailyRate: rates.dailyRate,
|
||||
monthlyRate: rates.monthlyRate,
|
||||
createdAt: dateToIso(detail.date_created),
|
||||
paidUntil: dateToIso(detail.next_due ?? service.next_due),
|
||||
notes: label ? `${label} [veesp-${externalId}]` : `veesp-${externalId}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapInvoiceToPayment(
|
||||
invoice: VeespInvoice,
|
||||
accountId: string,
|
||||
fallbackCurrency: string,
|
||||
): MappedPayment | null {
|
||||
const status = String(invoice.status ?? '').toLowerCase()
|
||||
if (status !== 'paid') return null
|
||||
const id = invoice.id
|
||||
if (id == null) return null
|
||||
const amount = parseNumber(invoice.total)
|
||||
if (amount <= 0) return null
|
||||
const currency = String(invoice.currency ?? fallbackCurrency).trim() || fallbackCurrency
|
||||
const date = dateToIso(invoice.datepaid ?? invoice.date ?? invoice.dateorig)
|
||||
return {
|
||||
externalId: String(id),
|
||||
type: 'topup',
|
||||
date: date || new Date().toISOString().slice(0, 10),
|
||||
amount,
|
||||
currency,
|
||||
providerAccountId: accountId,
|
||||
vpsId: null,
|
||||
note: `veesp-invoice-${id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Veesp client area API operations
|
||||
*/
|
||||
|
||||
import { createVeespClient, VeespApiError, type VeespClient } from './client.js'
|
||||
|
||||
export const VEESP_VPS_CATEGORY_SLUGS = [
|
||||
'virtual-private-servers',
|
||||
'virtual-private-server',
|
||||
'vps',
|
||||
'proxmox',
|
||||
] as const
|
||||
|
||||
export interface VeespServiceListItem {
|
||||
id: string | number
|
||||
domain?: string
|
||||
total?: string | number
|
||||
status?: string
|
||||
billingcycle?: string
|
||||
next_due?: string
|
||||
category?: string
|
||||
category_url?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface VeespServiceDetail {
|
||||
id?: string | number
|
||||
date_created?: string
|
||||
domain?: string
|
||||
firstpayment?: string | number
|
||||
total?: string | number
|
||||
billingcycle?: string
|
||||
next_due?: string
|
||||
next_invoice?: string
|
||||
status?: string
|
||||
label?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface VeespVmListItem {
|
||||
id?: string | number
|
||||
vmid?: string | number
|
||||
name?: string
|
||||
hostname?: string
|
||||
status?: string
|
||||
state?: string
|
||||
ip?: string
|
||||
ipv4?: string
|
||||
template?: string
|
||||
os?: string
|
||||
}
|
||||
|
||||
export interface VeespVmDetail extends VeespVmListItem {
|
||||
ram?: number | string
|
||||
cpu?: number | string
|
||||
disk?: number | string
|
||||
memory?: number | string
|
||||
cores?: number | string
|
||||
}
|
||||
|
||||
export interface VeespIpItem {
|
||||
ip?: string
|
||||
address?: string
|
||||
type?: string
|
||||
main?: boolean | number | string
|
||||
}
|
||||
|
||||
export interface VeespServiceInfo {
|
||||
os?: string
|
||||
template?: string
|
||||
cpu?: number | string
|
||||
ram?: number | string
|
||||
memory?: number | string
|
||||
disk?: number | string
|
||||
hostname?: string
|
||||
}
|
||||
|
||||
export interface VeespInvoice {
|
||||
id?: string | number
|
||||
date?: string
|
||||
dateorig?: string
|
||||
duedate?: string
|
||||
total?: string | number
|
||||
datepaid?: string
|
||||
status?: string
|
||||
number?: string
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export interface VeespCategory {
|
||||
id?: string | number
|
||||
name?: string
|
||||
slug?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface VeespProduct {
|
||||
id?: string | number
|
||||
name?: string
|
||||
description?: string
|
||||
paytype?: string
|
||||
pricing?: Record<string, Record<string, string | number>>
|
||||
configoptions?: unknown
|
||||
}
|
||||
|
||||
export interface VeespBalanceResult {
|
||||
balance: number
|
||||
currency: string
|
||||
enoughmoneyto: string
|
||||
}
|
||||
|
||||
export interface VeespTariffItem {
|
||||
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 VeespVpsRecord {
|
||||
serviceId: string
|
||||
vmId: string | null
|
||||
service: VeespServiceListItem
|
||||
serviceDetail: VeespServiceDetail | null
|
||||
vm: VeespVmDetail | null
|
||||
ips: VeespIpItem[]
|
||||
info: VeespServiceInfo | null
|
||||
}
|
||||
|
||||
function clientFor(baseUrl: string, credentials: string): VeespClient {
|
||||
return createVeespClient(baseUrl, credentials)
|
||||
}
|
||||
|
||||
function parseAmount(raw: string | number | undefined | null): number {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0
|
||||
const n = Number.parseFloat(String(raw ?? '').replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function normalizeSlug(value: string | undefined | null): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, '-')
|
||||
}
|
||||
|
||||
function isVpsCategorySlug(slug: string): boolean {
|
||||
const s = normalizeSlug(slug)
|
||||
if (!s) return false
|
||||
if (VEESP_VPS_CATEGORY_SLUGS.some((v) => s === v || s.includes(v))) return true
|
||||
return s.includes('vps') || s.includes('virtual-private')
|
||||
}
|
||||
|
||||
export function isVpsService(service: VeespServiceListItem, vpsCategoryIds?: Set<string>): boolean {
|
||||
const slug = normalizeSlug(service.category_url)
|
||||
const category = String(service.category ?? '').toLowerCase()
|
||||
if (isVpsCategorySlug(slug)) return true
|
||||
if (category.includes('vps') || category.includes('virtual private') || category.includes('proxmox')) {
|
||||
return true
|
||||
}
|
||||
if (vpsCategoryIds && service.category_url) {
|
||||
const catKey = normalizeSlug(service.category_url)
|
||||
if (vpsCategoryIds.has(catKey)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function unwrapList<T>(json: unknown, key: string): T[] {
|
||||
if (Array.isArray(json)) return json as T[]
|
||||
if (json && typeof json === 'object') {
|
||||
const obj = json as Record<string, unknown>
|
||||
const list = obj[key]
|
||||
if (Array.isArray(list)) return list as T[]
|
||||
const vms = obj.vms
|
||||
if (Array.isArray(vms)) return vms as T[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function unwrapObject<T>(json: unknown, key: string): T | null {
|
||||
if (!json || typeof json !== 'object') return null
|
||||
const obj = json as Record<string, unknown>
|
||||
const nested = obj[key]
|
||||
if (nested && typeof nested === 'object') return nested as T
|
||||
return obj as T
|
||||
}
|
||||
|
||||
export async function fetchServices(baseUrl: string, credentials: string): Promise<VeespServiceListItem[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<unknown>('/service')
|
||||
return unwrapList<VeespServiceListItem>(json, 'services')
|
||||
}
|
||||
|
||||
export async function fetchServiceDetail(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serviceId: string | number,
|
||||
): Promise<VeespServiceDetail | null> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/service/${serviceId}`)
|
||||
return unwrapObject<VeespServiceDetail>(json, 'service')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVms(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serviceId: string | number,
|
||||
): Promise<VeespVmListItem[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/service/${serviceId}/vms`)
|
||||
return unwrapList<VeespVmListItem>(json, 'vms')
|
||||
} catch (err) {
|
||||
if (err instanceof VeespApiError && /404|not found/i.test(err.message)) return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVmDetail(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serviceId: string | number,
|
||||
vmId: string | number,
|
||||
): Promise<VeespVmDetail | null> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}`)
|
||||
return unwrapObject<VeespVmDetail>(json, 'vm') ?? unwrapObject<VeespVmDetail>(json, 'vms') ?? (json as VeespVmDetail)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServiceIps(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serviceId: string | number,
|
||||
): Promise<VeespIpItem[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/service/${serviceId}/ips`)
|
||||
return unwrapList<VeespIpItem>(json, 'ips')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServiceInfo(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serviceId: string | number,
|
||||
): Promise<VeespServiceInfo | null> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/service/${serviceId}/info`)
|
||||
return unwrapObject<VeespServiceInfo>(json, 'info') ?? unwrapObject<VeespServiceInfo>(json, 'server') ?? (json as VeespServiceInfo)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCategories(baseUrl: string, credentials: string): Promise<VeespCategory[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<unknown>('/category')
|
||||
return unwrapList<VeespCategory>(json, 'categories')
|
||||
}
|
||||
|
||||
export async function fetchVpsCategoryIds(baseUrl: string, credentials: string): Promise<Set<string>> {
|
||||
const categories = await fetchCategories(baseUrl, credentials)
|
||||
const ids = new Set<string>()
|
||||
for (const cat of categories) {
|
||||
const slug = normalizeSlug(cat.slug)
|
||||
if (isVpsCategorySlug(slug) || String(cat.name ?? '').toLowerCase().includes('vps')) {
|
||||
if (slug) ids.add(slug)
|
||||
if (cat.id != null) ids.add(String(cat.id))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export async function fetchProducts(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
categoryId: string | number,
|
||||
): Promise<VeespProduct[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/category/${categoryId}/product`)
|
||||
return unwrapList<VeespProduct>(json, 'products')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBalance(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
fallbackCurrency = 'EUR',
|
||||
): Promise<VeespBalanceResult> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<Record<string, unknown>>('/balance')
|
||||
const details =
|
||||
(json.details as Record<string, unknown> | undefined) ??
|
||||
(json.balance as Record<string, unknown> | undefined) ??
|
||||
json
|
||||
const currency = String(details.currency ?? fallbackCurrency).trim() || fallbackCurrency
|
||||
const balance = parseAmount(details.acc_balance as string | number | undefined)
|
||||
return { balance, currency, enoughmoneyto: '' }
|
||||
}
|
||||
|
||||
export async function fetchInvoices(baseUrl: string, credentials: string): Promise<VeespInvoice[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
const json = await client.request<unknown>('/invoice')
|
||||
return unwrapList<VeespInvoice>(json, 'invoices')
|
||||
}
|
||||
|
||||
function extractProductPrice(product: VeespProduct, currency: string): string {
|
||||
const pricing = product.pricing
|
||||
if (!pricing || typeof pricing !== 'object') return ''
|
||||
const cur = currency.toUpperCase()
|
||||
const bucket =
|
||||
(pricing[cur] as Record<string, string | number> | undefined) ??
|
||||
(pricing[currency.toLowerCase()] as Record<string, string | number> | undefined) ??
|
||||
(Object.values(pricing)[0] as Record<string, string | number> | undefined)
|
||||
if (!bucket) return ''
|
||||
const monthly = bucket.monthly ?? bucket.Monthly
|
||||
const monthlyVal = monthly ?? bucket.quarterly ?? bucket.annually ?? bucket.semiannually
|
||||
if (monthlyVal == null) return ''
|
||||
return `${parseAmount(monthlyVal)} ${cur}/month`
|
||||
}
|
||||
|
||||
export async function fetchTariffList(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
currency = 'EUR',
|
||||
): Promise<VeespTariffItem[]> {
|
||||
const categories = await fetchCategories(baseUrl, credentials)
|
||||
const vpsCategories = categories.filter((cat) => {
|
||||
const slug = normalizeSlug(cat.slug)
|
||||
return isVpsCategorySlug(slug) || String(cat.name ?? '').toLowerCase().includes('vps')
|
||||
})
|
||||
|
||||
const items: VeespTariffItem[] = []
|
||||
for (const cat of vpsCategories) {
|
||||
if (cat.id == null) continue
|
||||
const products = await fetchProducts(baseUrl, credentials, cat.id)
|
||||
const catKey = String(cat.id)
|
||||
const catName = cat.name || cat.slug || catKey
|
||||
for (const product of products) {
|
||||
if (product.id == null) continue
|
||||
items.push({
|
||||
externalId: String(product.id),
|
||||
datacenterKey: catKey,
|
||||
datacenterName: catName,
|
||||
name: product.name || '',
|
||||
desc: product.description || '',
|
||||
vcpu: 0,
|
||||
ramGb: 0,
|
||||
diskGb: 0,
|
||||
diskType: 'NVMe',
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: catName,
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: extractProductPrice(product, currency),
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function vmExternalId(serviceId: string, vm: VeespVmListItem): string {
|
||||
const vmId = vm.id ?? vm.vmid
|
||||
return vmId != null ? `${serviceId}-${vmId}` : serviceId
|
||||
}
|
||||
|
||||
export async function fetchVpsRecords(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
concurrency = 10,
|
||||
): Promise<VeespVpsRecord[]> {
|
||||
const [services, vpsCategoryIds] = await Promise.all([
|
||||
fetchServices(baseUrl, credentials),
|
||||
fetchVpsCategoryIds(baseUrl, credentials).catch(() => new Set<string>()),
|
||||
])
|
||||
|
||||
const vpsServices = services.filter((s) => isVpsService(s, vpsCategoryIds))
|
||||
const records: VeespVpsRecord[] = []
|
||||
|
||||
for (let i = 0; i < vpsServices.length; i += concurrency) {
|
||||
const batch = vpsServices.slice(i, i + concurrency)
|
||||
const batchRecords = await Promise.all(
|
||||
batch.map(async (service) => {
|
||||
const serviceId = String(service.id)
|
||||
const [serviceDetail, vms, ips, info] = await Promise.all([
|
||||
fetchServiceDetail(baseUrl, credentials, serviceId),
|
||||
fetchVms(baseUrl, credentials, serviceId),
|
||||
fetchServiceIps(baseUrl, credentials, serviceId),
|
||||
fetchServiceInfo(baseUrl, credentials, serviceId),
|
||||
])
|
||||
|
||||
if (vms.length === 0) {
|
||||
return [
|
||||
{
|
||||
serviceId,
|
||||
vmId: null,
|
||||
service,
|
||||
serviceDetail,
|
||||
vm: null,
|
||||
ips,
|
||||
info,
|
||||
} satisfies VeespVpsRecord,
|
||||
]
|
||||
}
|
||||
|
||||
const vmDetails = await Promise.all(
|
||||
vms.map(async (vm) => {
|
||||
const vmId = vm.id ?? vm.vmid
|
||||
if (vmId == null) return vm as VeespVmDetail
|
||||
const detail = await fetchVmDetail(baseUrl, credentials, serviceId, vmId)
|
||||
return { ...vm, ...detail } as VeespVmDetail
|
||||
}),
|
||||
)
|
||||
|
||||
return vmDetails.map((vm) => ({
|
||||
serviceId,
|
||||
vmId: String(vm.id ?? vm.vmid ?? vmExternalId(serviceId, vm)),
|
||||
service,
|
||||
serviceDetail,
|
||||
vm,
|
||||
ips,
|
||||
info,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
records.push(...batchRecords.flat())
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
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, records] = await Promise.all([
|
||||
fetchBalance(baseUrl, credentials),
|
||||
fetchVpsRecords(baseUrl, credentials),
|
||||
])
|
||||
return { ok: true, vdsCount: records.length, balance: balanceInfo.balance }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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 { syncFromVeesp } from './sync.js'
|
||||
import type { VeespSyncAccount } from './context.js'
|
||||
|
||||
vi.mock('./operations.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./operations.js')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchVpsRecords: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchTariffList: vi.fn(),
|
||||
fetchInvoices: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { fetchBalance, fetchInvoices, fetchTariffList, fetchVpsRecords } from './operations.js'
|
||||
|
||||
function makeAccount(): VeespSyncAccount {
|
||||
return {
|
||||
id: 'acc-veesp',
|
||||
providerId: 'prov-veesp',
|
||||
name: 'Veesp',
|
||||
panelUrl: '',
|
||||
currency: 'EUR',
|
||||
billingMode: 'monthly',
|
||||
notes: '',
|
||||
apiType: 'veesp',
|
||||
apiBaseUrl: 'https://secure.veesp.com/api',
|
||||
apiCredentials: 'user@example.com:secret',
|
||||
apiLogin: 'user@example.com',
|
||||
apiPassword: 'secret',
|
||||
balanceApi: null,
|
||||
balanceCurrency: null,
|
||||
balanceUpdatedAt: null,
|
||||
enoughmoneyto: '',
|
||||
balanceAlertBelow: null,
|
||||
providerBaseCurrency: 'EUR',
|
||||
}
|
||||
}
|
||||
|
||||
describe('syncFromVeesp', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
getSqlite().exec(
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl, baseCurrency) VALUES ('prov-veesp', 'Veesp', 'veesp', 'https://secure.veesp.com/api', 'EUR')`,
|
||||
)
|
||||
providerAccountsRepository.create({
|
||||
id: 'acc-veesp',
|
||||
providerId: 'prov-veesp',
|
||||
name: 'Veesp',
|
||||
apiCredentials: 'user@example.com:secret',
|
||||
})
|
||||
|
||||
vi.mocked(fetchVpsRecords).mockResolvedValue([
|
||||
{
|
||||
serviceId: '100',
|
||||
vmId: '200',
|
||||
service: {
|
||||
id: '100',
|
||||
domain: 'vps.example.com',
|
||||
total: '5.00',
|
||||
status: 'Active',
|
||||
billingcycle: 'Monthly',
|
||||
next_due: '2027-01-01',
|
||||
category: 'Proxmox',
|
||||
category_url: 'virtual-private-servers',
|
||||
name: 'VPS 1',
|
||||
},
|
||||
serviceDetail: {
|
||||
id: '100',
|
||||
total: '5.00',
|
||||
billingcycle: 'Monthly',
|
||||
next_due: '2027-01-01',
|
||||
status: 'Active',
|
||||
domain: 'vps.example.com',
|
||||
date_created: '2026-01-01',
|
||||
},
|
||||
vm: {
|
||||
id: '200',
|
||||
hostname: 'vps.example.com',
|
||||
ip: '198.51.100.1',
|
||||
status: 'active',
|
||||
},
|
||||
ips: [{ ip: '198.51.100.1', main: true }],
|
||||
info: null,
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchBalance).mockResolvedValue({
|
||||
balance: 123.45,
|
||||
currency: 'EUR',
|
||||
enoughmoneyto: '',
|
||||
})
|
||||
vi.mocked(fetchTariffList).mockResolvedValue([
|
||||
{
|
||||
externalId: '840',
|
||||
datacenterKey: '19',
|
||||
datacenterName: 'Proxmox',
|
||||
name: 'VPS',
|
||||
desc: '',
|
||||
vcpu: 0,
|
||||
ramGb: 0,
|
||||
diskGb: 0,
|
||||
diskType: 'NVMe',
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: 'Proxmox',
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: '5.00 EUR/month',
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchInvoices).mockResolvedValue([
|
||||
{
|
||||
id: '308976',
|
||||
datepaid: '2016-12-30 12:40:47',
|
||||
total: '19.65',
|
||||
status: 'Paid',
|
||||
currency: 'EUR',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('syncs veesp account with correct id prefix', async () => {
|
||||
const result = await syncFromVeesp(makeAccount())
|
||||
|
||||
expect(result.vpsCount).toBe(1)
|
||||
expect(result.paymentsCount).toBe(1)
|
||||
expect(result.tariffsCount).toBe(1)
|
||||
expect(result.balance?.balance).toBe(123.45)
|
||||
|
||||
const vps = getSqlite()
|
||||
.prepare('SELECT id, notes, monthlyRate FROM vps WHERE id = ?')
|
||||
.get('vps-veesp-acc-veesp-100-200') as { id: string; notes: string; monthlyRate: number | null }
|
||||
expect(vps.notes).toContain('veesp-100-200')
|
||||
expect(vps.monthlyRate).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Sync Veesp client area data into vps-tracker DB
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { VeespSyncAccount } from './context.js'
|
||||
import { veespCredentialsString } from './context.js'
|
||||
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
|
||||
import { mapInvoiceToPayment, mapVpsRecordToVps } from './mappers.js'
|
||||
import {
|
||||
fetchBalance,
|
||||
fetchInvoices,
|
||||
fetchTariffList,
|
||||
fetchVpsRecords,
|
||||
type VeespBalanceResult,
|
||||
} from './operations.js'
|
||||
|
||||
export interface SyncFromVeespOptions {
|
||||
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 SyncFromVeespResult {
|
||||
vpsCount: number
|
||||
paymentsCount: number
|
||||
tariffsCount: number
|
||||
newTariffs: { name: string; price: string; providerId: string }[]
|
||||
balance: VeespBalanceResult | 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)
|
||||
}
|
||||
|
||||
export async function syncFromVeesp(
|
||||
account: VeespSyncAccount,
|
||||
opts: SyncFromVeespOptions = {},
|
||||
): Promise<SyncFromVeespResult> {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, providerId, id: accountId } = account
|
||||
const credentials = veespCredentialsString(account)
|
||||
if (!apiBaseUrl?.trim() || !account.apiLogin?.trim() || !account.apiPassword) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const db = getDb()
|
||||
|
||||
const fetchVpsData = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
const fallbackCurrency = syncFallbackCurrency(account)
|
||||
|
||||
const [records, balanceInfo, tariffItems, invoices] = await Promise.all([
|
||||
fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [],
|
||||
fetchVpsData
|
||||
? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => []) : [],
|
||||
fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
])
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsData) {
|
||||
for (const record of records) {
|
||||
const vps = mapVpsRecordToVps(record, providerId, accountId, fallbackCurrency)
|
||||
const id = `vps-veesp-${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(
|
||||
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
|
||||
like(schema.vps.notes, `%veesp-${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++
|
||||
}
|
||||
}
|
||||
|
||||
let paymentsCount = 0
|
||||
if (fetchVpsData) {
|
||||
const existingPaymentRows = db
|
||||
.select({ note: schema.payments.note })
|
||||
.from(schema.payments)
|
||||
.where(eq(schema.payments.providerAccountId, accountId))
|
||||
.all()
|
||||
const existingPayments = new Set(
|
||||
existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)),
|
||||
)
|
||||
|
||||
for (const item of invoices) {
|
||||
const payment = mapInvoiceToPayment(item, accountId, fallbackCurrency)
|
||||
if (!payment) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const payId = `pay-veesp-${accountId}-${payment.externalId}`
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
id: payId,
|
||||
type: payment.type,
|
||||
date: payment.date,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
providerAccountId: payment.providerAccountId,
|
||||
vpsId: payment.vpsId,
|
||||
note,
|
||||
})
|
||||
.run()
|
||||
existingPayments.add(note)
|
||||
paymentsCount++
|
||||
syncSummary.paymentsAdded += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchVpsData && balanceInfo) {
|
||||
db.update(schema.providerAccounts)
|
||||
.set({
|
||||
balanceApi: balanceInfo.balance,
|
||||
balanceCurrency: balanceInfo.currency || fallbackCurrency,
|
||||
balanceUpdatedAt: new Date().toISOString(),
|
||||
enoughmoneyto: balanceInfo.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-veesp-${accountId}-${t.externalId}-${dcKey}`
|
||||
: `tariff-veesp-${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,
|
||||
tariffsCount,
|
||||
newTariffs,
|
||||
balance: balanceInfo,
|
||||
syncSummary,
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export function ProviderAccountEditSheet({
|
||||
? 'API Token хранится на сервере и используется для синка с UserAPI'
|
||||
: initialProvider?.apiType === '4vps'
|
||||
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
|
||||
: initialProvider?.apiType === 'veesp'
|
||||
? 'Email и пароль client area Veesp хранятся на сервере и используются для синка'
|
||||
: 'API-креды хранятся на сервере и используются для синка с BILLmanager'
|
||||
}
|
||||
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelectField } from '@/components/select-field'
|
||||
import type { ZodType } from 'zod'
|
||||
import { providerSchema, type ProviderFormValues } from '@/lib/schemas'
|
||||
import type { ApiType } from '@/types/entities'
|
||||
import { isUserApiType, USER_API_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider'
|
||||
import { isUserApiType, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
const EMPTY: ProviderFormValues = {
|
||||
name: '',
|
||||
@@ -58,12 +58,16 @@ export function ProviderEditSheet({
|
||||
const apiUrlHint =
|
||||
apiType === '4vps'
|
||||
? 'Базовый URL API, например https://4vps.su/api'
|
||||
: apiType === 'veesp'
|
||||
? 'Базовый URL Veesp API, например https://secure.veesp.com/api'
|
||||
: isUserApiType(apiType)
|
||||
? `Базовый URL UserAPI, например ${USER_API_DEFAULT_BASE_URL[apiType]}`
|
||||
: 'Один URL на хостера для BILLmanager'
|
||||
const apiUrlPlaceholder =
|
||||
apiType === '4vps'
|
||||
? 'https://4vps.su/api'
|
||||
: apiType === 'veesp'
|
||||
? VEESP_DEFAULT_BASE_URL
|
||||
: isUserApiType(apiType)
|
||||
? USER_API_DEFAULT_BASE_URL[apiType]
|
||||
: undefined
|
||||
@@ -86,6 +90,7 @@ export function ProviderEditSheet({
|
||||
{ value: '4vps', label: '4VPS.SU' },
|
||||
{ value: 'macloud', label: 'Маклауд' },
|
||||
{ value: 'vdsina', label: 'VDSina' },
|
||||
{ value: 'veesp', label: 'Veesp' },
|
||||
{ value: 'none', label: 'Нет' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,13 @@ export function accountCredentialLabels(apiType?: string | null): {
|
||||
loginPlaceholder: '1',
|
||||
}
|
||||
}
|
||||
if (String(apiType).toLowerCase() === 'veesp') {
|
||||
return {
|
||||
loginLabel: 'Email',
|
||||
passwordLabel: 'Пароль',
|
||||
loginPlaceholder: 'user@example.com',
|
||||
}
|
||||
}
|
||||
if (isUserApiType(apiType)) {
|
||||
return {
|
||||
loginLabel: '',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'none'] as const
|
||||
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'none'] as const
|
||||
export type ApiType = (typeof API_TYPES)[number]
|
||||
export const apiTypeSchema = z.enum(API_TYPES).optional().default('none')
|
||||
|
||||
export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina'] as const
|
||||
export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp'] as const
|
||||
export type SyncApiType = (typeof SYNC_API_TYPES)[number]
|
||||
|
||||
export const USER_API_TYPES = ['macloud', 'vdsina'] as const
|
||||
@@ -15,6 +15,8 @@ export const USER_API_DEFAULT_BASE_URL: Record<UserApiType, string> = {
|
||||
vdsina: 'https://userapi.vdsina.com/v1',
|
||||
}
|
||||
|
||||
export const VEESP_DEFAULT_BASE_URL = 'https://secure.veesp.com/api'
|
||||
|
||||
export function isUserApiType(apiType?: string | null): apiType is UserApiType {
|
||||
const key = String(apiType || '').toLowerCase()
|
||||
return (USER_API_TYPES as readonly string[]).includes(key)
|
||||
|
||||
Reference in New Issue
Block a user