diff --git a/apps/api/src/routes/sync.test.ts b/apps/api/src/routes/sync.test.ts index 9903309..c813799 100644 --- a/apps/api/src/routes/sync.test.ts +++ b/apps/api/src/routes/sync.test.ts @@ -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> @@ -189,3 +200,80 @@ describe('sync routes — vdsina', () => { expect(body.synced?.vpsCount).toBe(1) }) }) + +describe('sync routes — veesp', () => { + let app: Awaited> + + 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() + }) +}) diff --git a/apps/api/src/services/providers/index.ts b/apps/api/src/services/providers/index.ts index 71fbfe6..2a24514 100644 --- a/apps/api/src/services/providers/index.ts +++ b/apps/api/src/services/providers/index.ts @@ -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 = { '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 = { '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', } diff --git a/apps/api/src/services/providers/veesp-adapter.ts b/apps/api/src/services/providers/veesp-adapter.ts new file mode 100644 index 0000000..e8f4fa4 --- /dev/null +++ b/apps/api/src/services/providers/veesp-adapter.ts @@ -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 { + 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 || '', + } + }, +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index bc31155..adcce8d 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] { .all(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 `) diff --git a/apps/api/src/services/veesp/client.test.ts b/apps/api/src/services/veesp/client.test.ts new file mode 100644 index 0000000..3a26be0 --- /dev/null +++ b/apps/api/src/services/veesp/client.test.ts @@ -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).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') + }) +}) diff --git a/apps/api/src/services/veesp/client.ts b/apps/api/src/services/veesp/client.ts new file mode 100644 index 0000000..06c9a3a --- /dev/null +++ b/apps/api/src/services/veesp/client.ts @@ -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 +} + +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 + 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 { + 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 + 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 { + if (this.token) return this.token + try { + this.token = await veespLogin(this.baseUrl, this.creds) + return this.token + } catch { + return '' + } + } + + async request(path: string, opts: VeespRequestOptions = {}): Promise { + const { method = 'GET', body } = opts + const url = joinUrl(this.baseUrl, path) + const token = await this.ensureToken() + + const headers: Record = { + 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 + 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)) +} diff --git a/apps/api/src/services/veesp/context.ts b/apps/api/src/services/veesp/context.ts new file mode 100644 index 0000000..7cabdce --- /dev/null +++ b/apps/api/src/services/veesp/context.ts @@ -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}` +} diff --git a/apps/api/src/services/veesp/index.ts b/apps/api/src/services/veesp/index.ts new file mode 100644 index 0000000..f800bc6 --- /dev/null +++ b/apps/api/src/services/veesp/index.ts @@ -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' diff --git a/apps/api/src/services/veesp/mappers.test.ts b/apps/api/src/services/veesp/mappers.test.ts new file mode 100644 index 0000000..cc8becc --- /dev/null +++ b/apps/api/src/services/veesp/mappers.test.ts @@ -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() + }) +}) diff --git a/apps/api/src/services/veesp/mappers.ts b/apps/api/src/services/veesp/mappers.ts new file mode 100644 index 0000000..d2ee8a6 --- /dev/null +++ b/apps/api/src/services/veesp/mappers.ts @@ -0,0 +1,232 @@ +/** + * Veesp API response → vps-tracker model mappers + */ + +import type { VeespInvoice, VeespVpsRecord } from './operations.js' + +const STATUS_MAP: Record = { + 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}`, + } +} diff --git a/apps/api/src/services/veesp/operations.ts b/apps/api/src/services/veesp/operations.ts new file mode 100644 index 0000000..e8073d7 --- /dev/null +++ b/apps/api/src/services/veesp/operations.ts @@ -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> + 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): 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(json: unknown, key: string): T[] { + if (Array.isArray(json)) return json as T[] + if (json && typeof json === 'object') { + const obj = json as Record + 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(json: unknown, key: string): T | null { + if (!json || typeof json !== 'object') return null + const obj = json as Record + 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 { + const client = clientFor(baseUrl, credentials) + const json = await client.request('/service') + return unwrapList(json, 'services') +} + +export async function fetchServiceDetail( + baseUrl: string, + credentials: string, + serviceId: string | number, +): Promise { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/service/${serviceId}`) + return unwrapObject(json, 'service') + } catch { + return null + } +} + +export async function fetchVms( + baseUrl: string, + credentials: string, + serviceId: string | number, +): Promise { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/service/${serviceId}/vms`) + return unwrapList(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 { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/service/${serviceId}/vms/${vmId}`) + return unwrapObject(json, 'vm') ?? unwrapObject(json, 'vms') ?? (json as VeespVmDetail) + } catch { + return null + } +} + +export async function fetchServiceIps( + baseUrl: string, + credentials: string, + serviceId: string | number, +): Promise { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/service/${serviceId}/ips`) + return unwrapList(json, 'ips') + } catch { + return [] + } +} + +export async function fetchServiceInfo( + baseUrl: string, + credentials: string, + serviceId: string | number, +): Promise { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/service/${serviceId}/info`) + return unwrapObject(json, 'info') ?? unwrapObject(json, 'server') ?? (json as VeespServiceInfo) + } catch { + return null + } +} + +export async function fetchCategories(baseUrl: string, credentials: string): Promise { + const client = clientFor(baseUrl, credentials) + const json = await client.request('/category') + return unwrapList(json, 'categories') +} + +export async function fetchVpsCategoryIds(baseUrl: string, credentials: string): Promise> { + const categories = await fetchCategories(baseUrl, credentials) + const ids = new Set() + 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 { + const client = clientFor(baseUrl, credentials) + try { + const json = await client.request(`/category/${categoryId}/product`) + return unwrapList(json, 'products') + } catch { + return [] + } +} + +export async function fetchBalance( + baseUrl: string, + credentials: string, + fallbackCurrency = 'EUR', +): Promise { + const client = clientFor(baseUrl, credentials) + const json = await client.request>('/balance') + const details = + (json.details as Record | undefined) ?? + (json.balance as Record | 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 { + const client = clientFor(baseUrl, credentials) + const json = await client.request('/invoice') + return unwrapList(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 | undefined) ?? + (pricing[currency.toLowerCase()] as Record | undefined) ?? + (Object.values(pricing)[0] as Record | 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 { + 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 { + const [services, vpsCategoryIds] = await Promise.all([ + fetchServices(baseUrl, credentials), + fetchVpsCategoryIds(baseUrl, credentials).catch(() => new Set()), + ]) + + 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 } + } +} diff --git a/apps/api/src/services/veesp/sync.test.ts b/apps/api/src/services/veesp/sync.test.ts new file mode 100644 index 0000000..653ea83 --- /dev/null +++ b/apps/api/src/services/veesp/sync.test.ts @@ -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() + 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) + }) +}) diff --git a/apps/api/src/services/veesp/sync.ts b/apps/api/src/services/veesp/sync.ts new file mode 100644 index 0000000..44cbb92 --- /dev/null +++ b/apps/api/src/services/veesp/sync.ts @@ -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 { + 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, + } +} diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index fecc0a6..809c13c 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -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} diff --git a/apps/web/src/components/domain/provider-edit-sheet.tsx b/apps/web/src/components/domain/provider-edit-sheet.tsx index 4e4b4cd..80f6d63 100644 --- a/apps/web/src/components/domain/provider-edit-sheet.tsx +++ b/apps/web/src/components/domain/provider-edit-sheet.tsx @@ -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: 'Нет' }, ]} /> diff --git a/apps/web/src/lib/provider-sync.ts b/apps/web/src/lib/provider-sync.ts index 8d55d5a..d11a9f8 100644 --- a/apps/web/src/lib/provider-sync.ts +++ b/apps/web/src/lib/provider-sync.ts @@ -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: '', diff --git a/packages/shared/src/contracts/provider.ts b/packages/shared/src/contracts/provider.ts index d187116..b61699f 100644 --- a/packages/shared/src/contracts/provider.ts +++ b/packages/shared/src/contracts/provider.ts @@ -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 = { 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)