feat(api, web): интеграция Veesp client area API
Docker / build (push) Has been cancelled

Добавлен отдельный модуль veesp для синка VPS, баланса, счетов и тарифов через JWT/Basic auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 10:25:12 +07:00
co-authored by Cursor
parent b15e10f117
commit 579c00392d
17 changed files with 1773 additions and 5 deletions
+88
View File
@@ -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()
})
})
+15 -1
View File
@@ -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 || '',
}
},
}
+1 -1
View File
@@ -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')
})
})
+149
View File
@@ -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))
}
+51
View File
@@ -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}`
}
+16
View File
@@ -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'
+116
View File
@@ -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()
})
})
+232
View File
@@ -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}`,
}
}
+476
View File
@@ -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 }
}
}
+146
View File
@@ -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)
})
})
+328
View File
@@ -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: 'Нет' },
]}
/>
+7
View File
@@ -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: '',
+4 -2
View File
@@ -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)