feat(api, web): интеграция хостера 4VPS.SU — синк VPS, баланса и тарифов
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлен адаптер 4vps, обобщён pipeline синхронизации через ProviderAdapter и обновлён UI для Panel ID и API Key. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { closeDb } from '@cfdm/db'
|
||||||
|
import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup'
|
||||||
|
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||||
|
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||||
|
|
||||||
|
import { buildApp } from '../index.js'
|
||||||
|
|
||||||
|
vi.mock('../services/fourvps/sync.js', () => ({
|
||||||
|
syncFromFourvps: vi.fn().mockResolvedValue({
|
||||||
|
vpsCount: 2,
|
||||||
|
paymentsCount: 0,
|
||||||
|
tariffsCount: 3,
|
||||||
|
newTariffs: [],
|
||||||
|
balance: { balance: 1000, currency: 'RUB' },
|
||||||
|
syncSummary: { added: [], updated: [], paymentsAdded: 0 },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('sync routes — 4vps', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetTestDb()
|
||||||
|
seedTestProvider('prov-4vps')
|
||||||
|
providersRepository.update('prov-4vps', {
|
||||||
|
apiType: '4vps',
|
||||||
|
apiBaseUrl: 'https://4vps.su/api',
|
||||||
|
})
|
||||||
|
providerAccountsRepository.create({
|
||||||
|
id: 'acc-4vps',
|
||||||
|
providerId: 'prov-4vps',
|
||||||
|
name: '4VPS',
|
||||||
|
apiCredentials: '1:secret-key',
|
||||||
|
})
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
closeDb()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /api/sync/:accountId syncs 4vps account', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sync/acc-4vps',
|
||||||
|
payload: {},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = res.json() as { ok?: boolean; synced?: { vpsCount?: number } }
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
expect(body.synced?.vpsCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /api/sync/test-connection uses apiType 4vps', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ error: false, data: { userBalance: 500, serverlist: [] } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sync/test-connection',
|
||||||
|
payload: {
|
||||||
|
apiBaseUrl: 'https://4vps.su/api',
|
||||||
|
apiCredentials: '1:key',
|
||||||
|
apiType: '4vps',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = res.json() as { ok?: boolean }
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
})
|
||||||
+28
-19
@@ -5,11 +5,11 @@ import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accou
|
|||||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
billmanagerAccountRowForSync,
|
getProviderAdapter,
|
||||||
fetchDashboardInfo,
|
resolveSyncAccount,
|
||||||
runBillmanagerAccountSync,
|
SYNC_SETUP_ERRORS,
|
||||||
testConnection,
|
} from '../services/providers/index.js'
|
||||||
} from '../services/billmanager/index.js'
|
import { runAccountSync } from '../services/providers/sync-job.js'
|
||||||
|
|
||||||
interface SyncLogRow {
|
interface SyncLogRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -45,8 +45,9 @@ function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BILLMANAGER_SETUP_ERROR =
|
function setupErrorMessage(apiType: string): string {
|
||||||
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API'
|
return SYNC_SETUP_ERRORS[apiType] ?? 'Настройте API хостера и учётные данные аккаунта'
|
||||||
|
}
|
||||||
|
|
||||||
export const syncRoutes: FastifyPluginAsync = async (app) => {
|
export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.get('/api/sync/status', async () => {
|
app.get('/api/sync/status', async () => {
|
||||||
@@ -67,16 +68,18 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||||
}
|
}
|
||||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||||
const syncRow = billmanagerAccountRowForSync(account, provider)
|
const resolved = resolveSyncAccount(account, provider)
|
||||||
if (!syncRow) {
|
if (!resolved) {
|
||||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
|
const apiType = String(provider?.apiType || 'billmanager').toLowerCase()
|
||||||
|
return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onlyTariffs = Boolean(req.body?.onlyTariffs)
|
const onlyTariffs = Boolean(req.body?.onlyTariffs)
|
||||||
const opts = onlyTariffs ? { skipVpsPayments: true } : {}
|
const opts = onlyTariffs ? { skipVpsPayments: true } : {}
|
||||||
|
const adapter = getProviderAdapter(resolved.apiType)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runBillmanagerAccountSync(syncRow, opts)
|
const result = await runAccountSync(adapter, resolved.account, opts)
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
synced: {
|
synced: {
|
||||||
@@ -99,15 +102,19 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||||
}
|
}
|
||||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||||
const syncRow = billmanagerAccountRowForSync(account, provider)
|
const resolved = resolveSyncAccount(account, provider)
|
||||||
if (!syncRow) {
|
if (!resolved) {
|
||||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
|
const apiType = String(provider?.apiType || 'billmanager').toLowerCase()
|
||||||
|
return reply.code(400).send({ error: { code: 'VALIDATION', message: setupErrorMessage(apiType) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = getProviderAdapter(resolved.apiType)
|
||||||
|
if (!adapter.fetchBalance) {
|
||||||
|
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Баланс через API недоступен для этого типа хостера' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), {
|
const info = await adapter.fetchBalance(resolved.account)
|
||||||
fallbackCurrency: account.currency,
|
|
||||||
})
|
|
||||||
getDb()
|
getDb()
|
||||||
.update(schema.providerAccounts)
|
.update(schema.providerAccounts)
|
||||||
.set({
|
.set({
|
||||||
@@ -127,15 +134,17 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/sync/test-connection', async (req, reply) => {
|
app.post('/api/sync/test-connection', async (req, reply) => {
|
||||||
const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as {
|
const { apiBaseUrl, apiCredentials, apiType } = (req.body ?? {}) as {
|
||||||
apiBaseUrl?: string
|
apiBaseUrl?: string
|
||||||
apiCredentials?: string
|
apiCredentials?: string
|
||||||
|
apiType?: string
|
||||||
}
|
}
|
||||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||||
return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' })
|
return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
const adapter = getProviderAdapter(apiType || 'billmanager')
|
||||||
|
const result = await adapter.testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||||
return result
|
return result
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.log.error(err)
|
req.log.error(err)
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
* Запуск синхронизации BILLmanager с записью в sync_log
|
* Запуск синхронизации BILLmanager с записью в sync_log
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { getDb, schema } from '@cfdm/db'
|
|
||||||
|
|
||||||
import type { BillmanagerSyncAccount } from './context.js'
|
import type { BillmanagerSyncAccount } from './context.js'
|
||||||
import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js'
|
import type { SyncFromBillmanagerOptions, SyncFromBillmanagerResult } from './sync.js'
|
||||||
|
import { billmanagerAdapter } from '../providers/billmanager-adapter.js'
|
||||||
|
import { runAccountSync } from '../providers/sync-job.js'
|
||||||
|
|
||||||
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
|
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
|
||||||
ok: true
|
ok: true
|
||||||
@@ -17,48 +16,5 @@ export async function runBillmanagerAccountSync(
|
|||||||
account: BillmanagerSyncAccount,
|
account: BillmanagerSyncAccount,
|
||||||
opts: SyncFromBillmanagerOptions = {},
|
opts: SyncFromBillmanagerOptions = {},
|
||||||
): Promise<RunBillmanagerAccountSyncResult> {
|
): Promise<RunBillmanagerAccountSyncResult> {
|
||||||
const db = getDb()
|
return runAccountSync(billmanagerAdapter, account, opts)
|
||||||
const logId = `sync-${account.id}-${Date.now()}`
|
|
||||||
|
|
||||||
db.insert(schema.syncLog)
|
|
||||||
.values({
|
|
||||||
id: logId,
|
|
||||||
accountId: account.id,
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
status: 'running',
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await syncFromBillmanager(account, opts)
|
|
||||||
const summaryPayload = {
|
|
||||||
...(result.syncSummary || {}),
|
|
||||||
vpsCount: result.vpsCount,
|
|
||||||
paymentsCount: result.paymentsCount,
|
|
||||||
tariffsCount: result.tariffsCount ?? 0,
|
|
||||||
}
|
|
||||||
db.update(schema.syncLog)
|
|
||||||
.set({
|
|
||||||
finishedAt: new Date().toISOString(),
|
|
||||||
status: 'ok',
|
|
||||||
vpsCount: result.vpsCount,
|
|
||||||
paymentsCount: result.paymentsCount,
|
|
||||||
summary: JSON.stringify(summaryPayload),
|
|
||||||
})
|
|
||||||
.where(eq(schema.syncLog.id, logId))
|
|
||||||
.run()
|
|
||||||
return { ok: true, logId, ...result }
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
||||||
db.update(schema.syncLog)
|
|
||||||
.set({
|
|
||||||
finishedAt: new Date().toISOString(),
|
|
||||||
status: 'error',
|
|
||||||
error: message,
|
|
||||||
summary: JSON.stringify({ error: message }),
|
|
||||||
})
|
|
||||||
.where(eq(schema.syncLog.id, logId))
|
|
||||||
.run()
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||||
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
|
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
|
||||||
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
||||||
|
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
||||||
|
|
||||||
const STALE_SYNC_HOURS = 48
|
const STALE_SYNC_HOURS = 48
|
||||||
|
|
||||||
@@ -84,11 +85,11 @@ export function computeDashboardStats(): DashboardStats {
|
|||||||
|
|
||||||
const providerById = new Map(snap.providers.map((p) => [p.id, p]))
|
const providerById = new Map(snap.providers.map((p) => [p.id, p]))
|
||||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
const bmAccounts = snap.providerAccounts.filter((a) => {
|
const syncAccounts = snap.providerAccounts.filter((a) => {
|
||||||
const p = providerById.get(a.providerId)
|
const p = providerById.get(a.providerId)
|
||||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||||
})
|
})
|
||||||
const staleSyncAccountCount = bmAccounts.filter((a) => {
|
const staleSyncAccountCount = syncAccounts.filter((a) => {
|
||||||
const t = lastOkSyncAt(a.id, snap.syncLog)
|
const t = lastOkSyncAt(a.id, snap.syncLog)
|
||||||
if (t == null) return true
|
if (t == null) return true
|
||||||
return now.getTime() - t > staleMs
|
return now.getTime() - t > staleMs
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { fourvpsRequest, FourVpsApiError } from './client.js'
|
||||||
|
|
||||||
|
describe('fourvpsRequest', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends Bearer auth and panel_id query param', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ error: false, data: { userBalance: 100 } }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const data = await fourvpsRequest('https://4vps.su/api', 'test-key', '/userBalance', {
|
||||||
|
panelId: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(data).toEqual({ userBalance: 100 })
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(url).toContain('panel_id=2')
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer test-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws FourVpsApiError on API error flag', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
error: true,
|
||||||
|
errorMessage: 'Authentication error',
|
||||||
|
data: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(fourvpsRequest('https://4vps.su/api', 'bad', '/userBalance')).rejects.toThrow(
|
||||||
|
FourVpsApiError,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 4VPS.SU REST API HTTP client
|
||||||
|
* @see https://4vps.su/page/api
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FourVpsResponse<T = unknown> {
|
||||||
|
error: boolean
|
||||||
|
errorMessage?: string | Record<string, unknown>
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FourVpsApiError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'FourVpsApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorMessage(errorMessage: FourVpsResponse['errorMessage']): string {
|
||||||
|
if (!errorMessage) return '4VPS API error'
|
||||||
|
if (typeof errorMessage === 'string') return errorMessage
|
||||||
|
const msg = errorMessage.message
|
||||||
|
if (typeof msg === 'string') return msg
|
||||||
|
return JSON.stringify(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(baseUrl: string, path: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, '')
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${base}${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FourVpsRequestOptions {
|
||||||
|
method?: 'GET' | 'POST'
|
||||||
|
panelId?: number | null
|
||||||
|
body?: Record<string, string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fourvpsRequest<T = unknown>(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
path: string,
|
||||||
|
opts: FourVpsRequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { method = 'GET', panelId, body } = opts
|
||||||
|
const url = new URL(joinUrl(baseUrl, path))
|
||||||
|
if (panelId != null) {
|
||||||
|
url.searchParams.set('panel_id', String(panelId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey.trim()}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST') {
|
||||||
|
const payload: Record<string, string | number> = { ...(body ?? {}) }
|
||||||
|
if (panelId != null && payload.panel_id == null) {
|
||||||
|
payload.panel_id = panelId
|
||||||
|
}
|
||||||
|
init.headers = { ...init.headers, 'Content-Type': 'application/json' }
|
||||||
|
init.body = JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), init)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new FourVpsApiError(`4VPS API HTTP ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as FourVpsResponse<T>
|
||||||
|
if (json.error) {
|
||||||
|
throw new FourVpsApiError(formatErrorMessage(json.errorMessage))
|
||||||
|
}
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { schema } from '@cfdm/db'
|
||||||
|
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||||
|
|
||||||
|
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||||
|
type ProviderRow = typeof schema.providers.$inferSelect
|
||||||
|
|
||||||
|
export interface FourvpsSyncAccount extends AccountRow {
|
||||||
|
apiType: '4vps'
|
||||||
|
apiBaseUrl: string
|
||||||
|
panelId: number | null
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFourvpsApi(
|
||||||
|
accountRow: AccountRow | null | undefined,
|
||||||
|
providerRow: ProviderRow | null | undefined,
|
||||||
|
): { apiType: string; apiBaseUrl: string } {
|
||||||
|
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
|
||||||
|
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||||
|
return { apiType, apiBaseUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fourvpsAccountRowForSync(
|
||||||
|
accountRow: AccountRow | null | undefined,
|
||||||
|
providerRow: ProviderRow | null | undefined,
|
||||||
|
): FourvpsSyncAccount | null {
|
||||||
|
if (!accountRow) return null
|
||||||
|
const { apiType, apiBaseUrl } = resolveFourvpsApi(accountRow, providerRow)
|
||||||
|
const cred = String(accountRow.apiCredentials || '').trim()
|
||||||
|
const { panelId, apiKey } = parseFourVpsCredentials(cred)
|
||||||
|
if (apiType !== '4vps' || !apiBaseUrl || !apiKey) return null
|
||||||
|
return { ...accountRow, apiType: '4vps', apiBaseUrl, panelId, apiKey }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { fourvpsRequest, FourVpsApiError } from './client.js'
|
||||||
|
export type { FourvpsSyncAccount } from './context.js'
|
||||||
|
export { fourvpsAccountRowForSync, resolveFourvpsApi } from './context.js'
|
||||||
|
export { mapServerToVps } from './mappers.js'
|
||||||
|
export {
|
||||||
|
fetchDcList,
|
||||||
|
fetchMyServers,
|
||||||
|
fetchTarifList,
|
||||||
|
fetchUserBalance,
|
||||||
|
testConnection,
|
||||||
|
} from './operations.js'
|
||||||
|
export { syncFromFourvps } from './sync.js'
|
||||||
|
export type { SyncFromFourvpsOptions, SyncFromFourvpsResult } from './sync.js'
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { mapServerToVps } from './mappers.js'
|
||||||
|
import type { FourVpsServer } from './operations.js'
|
||||||
|
|
||||||
|
const sampleServer: FourVpsServer = {
|
||||||
|
id: 4140,
|
||||||
|
name: 'MyFirstServer',
|
||||||
|
price: 420,
|
||||||
|
dc: 7,
|
||||||
|
image: 'Alma Linux 8',
|
||||||
|
mem: 1,
|
||||||
|
cpu: 1,
|
||||||
|
disk: 10,
|
||||||
|
ipv4: '185.143.223.29',
|
||||||
|
status: 'active',
|
||||||
|
tname: 'USA-cx01',
|
||||||
|
time: 1664275501,
|
||||||
|
expired: 1666781101,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mapServerToVps', () => {
|
||||||
|
it('maps myservers fields to VPS model', () => {
|
||||||
|
const dcMap = new Map([
|
||||||
|
[7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }],
|
||||||
|
])
|
||||||
|
const vps = mapServerToVps(sampleServer, 'prov-1', 'acc-1', dcMap)
|
||||||
|
|
||||||
|
expect(vps.externalId).toBe('4140')
|
||||||
|
expect(vps.ip).toBe('185.143.223.29')
|
||||||
|
expect(vps.vcpu).toBe(1)
|
||||||
|
expect(vps.ramGb).toBe(1)
|
||||||
|
expect(vps.diskGb).toBe(10)
|
||||||
|
expect(vps.os).toBe('Alma Linux 8')
|
||||||
|
expect(vps.status).toBe('active')
|
||||||
|
expect(vps.monthlyRate).toBe(420)
|
||||||
|
expect(vps.datacenter).toBe('USA DC1')
|
||||||
|
expect(vps.country).toBe('US')
|
||||||
|
expect(vps.paidUntil).toBe('2022-10-26')
|
||||||
|
expect(vps.notes).toContain('4vps-4140')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 4VPS API response → vps-tracker model mappers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FourVpsDatacenter, FourVpsServer } from './operations.js'
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, string> = {
|
||||||
|
active: 'active',
|
||||||
|
paused: 'paused',
|
||||||
|
suspended: 'paused',
|
||||||
|
deleted: 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
function unixToIso(ts: number | null | undefined): string {
|
||||||
|
if (ts == null || !Number.isFinite(ts) || ts <= 0) return ''
|
||||||
|
return new Date(ts * 1000).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MappedVps {
|
||||||
|
externalId: string
|
||||||
|
ip: string
|
||||||
|
dns: string
|
||||||
|
ipv6: string
|
||||||
|
additionalIps: string[]
|
||||||
|
providerId: string
|
||||||
|
providerAccountId: string
|
||||||
|
country: string
|
||||||
|
city: string
|
||||||
|
datacenter: string
|
||||||
|
os: string
|
||||||
|
vcpu: number
|
||||||
|
ramGb: number
|
||||||
|
diskGb: number
|
||||||
|
diskType: string
|
||||||
|
virtualization: string
|
||||||
|
bandwidthTb: number
|
||||||
|
sshPort: number
|
||||||
|
rootUser: string
|
||||||
|
purpose: string
|
||||||
|
environment: string
|
||||||
|
project: string
|
||||||
|
monitoringEnabled: boolean
|
||||||
|
backupEnabled: boolean
|
||||||
|
status: string
|
||||||
|
tariffType: string
|
||||||
|
currency: string
|
||||||
|
dailyRate: null
|
||||||
|
monthlyRate: number | null
|
||||||
|
createdAt: string
|
||||||
|
paidUntil: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapServerToVps(
|
||||||
|
server: FourVpsServer,
|
||||||
|
providerId: string,
|
||||||
|
providerAccountId: string,
|
||||||
|
dcMap: Map<number, FourVpsDatacenter>,
|
||||||
|
): MappedVps {
|
||||||
|
const dc = dcMap.get(server.dc)
|
||||||
|
const datacenter = dc?.dc_name ?? String(server.dc)
|
||||||
|
const country = (dc?.flag ?? '').toUpperCase()
|
||||||
|
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
|
||||||
|
const monthlyRate = Number.isFinite(server.price) ? server.price : null
|
||||||
|
const name = String(server.name || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: String(server.id),
|
||||||
|
ip: String(server.ipv4 || '').trim(),
|
||||||
|
dns: name,
|
||||||
|
ipv6: '',
|
||||||
|
additionalIps: [],
|
||||||
|
providerId,
|
||||||
|
providerAccountId,
|
||||||
|
country,
|
||||||
|
city: '',
|
||||||
|
datacenter,
|
||||||
|
os: String(server.image || '').trim(),
|
||||||
|
vcpu: server.cpu ?? 0,
|
||||||
|
ramGb: server.mem ?? 0,
|
||||||
|
diskGb: server.disk ?? 0,
|
||||||
|
diskType: 'NVMe',
|
||||||
|
virtualization: 'KVM',
|
||||||
|
bandwidthTb: 0,
|
||||||
|
sshPort: 22,
|
||||||
|
rootUser: 'root',
|
||||||
|
purpose: '',
|
||||||
|
environment: '',
|
||||||
|
project: '',
|
||||||
|
monitoringEnabled: false,
|
||||||
|
backupEnabled: false,
|
||||||
|
status,
|
||||||
|
tariffType: 'monthly',
|
||||||
|
currency: 'RUB',
|
||||||
|
dailyRate: null,
|
||||||
|
monthlyRate,
|
||||||
|
createdAt: unixToIso(server.time),
|
||||||
|
paidUntil: unixToIso(server.expired),
|
||||||
|
notes: name ? `${name} [4vps-${server.id}]` : `4vps-${server.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* 4VPS.SU API operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||||
|
|
||||||
|
import { fourvpsRequest } from './client.js'
|
||||||
|
|
||||||
|
export interface FourVpsServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
dc: number
|
||||||
|
image: string
|
||||||
|
mem: number
|
||||||
|
cpu: number
|
||||||
|
disk: number
|
||||||
|
ipv4: string
|
||||||
|
status: string
|
||||||
|
tname: string
|
||||||
|
time: number
|
||||||
|
expired: number
|
||||||
|
autoprolong?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FourVpsDatacenter {
|
||||||
|
id: number
|
||||||
|
dc_name: string
|
||||||
|
flag: string
|
||||||
|
cpu_name?: string
|
||||||
|
t_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FourVpsTariffPreset {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
nameFull?: string
|
||||||
|
cpu_number: number
|
||||||
|
ram_mib: number
|
||||||
|
rom?: number
|
||||||
|
commentParsed?: { price?: string | number; eth?: string }
|
||||||
|
disks?: { size_mib?: number; tags?: { name?: string }[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FourVpsTariffItem {
|
||||||
|
externalId: string
|
||||||
|
datacenterKey: string
|
||||||
|
datacenterName: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
vcpu: number
|
||||||
|
ramGb: number
|
||||||
|
diskGb: number
|
||||||
|
diskType: string
|
||||||
|
virtualization: string
|
||||||
|
channel: string
|
||||||
|
location: string
|
||||||
|
country: string
|
||||||
|
cpuModel: string
|
||||||
|
orderAvailable: boolean
|
||||||
|
price: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FourVpsBalanceInfo {
|
||||||
|
balance: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCredentials(baseUrl: string, credentials: string) {
|
||||||
|
const url = baseUrl.trim()
|
||||||
|
const { panelId, apiKey } = parseFourVpsCredentials(credentials)
|
||||||
|
if (!url || !apiKey) {
|
||||||
|
throw new Error('API URL and credentials are required')
|
||||||
|
}
|
||||||
|
return { baseUrl: url, panelId, apiKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserBalance(
|
||||||
|
baseUrl: string,
|
||||||
|
credentials: string,
|
||||||
|
fallbackCurrency = 'RUB',
|
||||||
|
): Promise<FourVpsBalanceInfo> {
|
||||||
|
const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials)
|
||||||
|
const data = await fourvpsRequest<{ userBalance?: number }>(url, apiKey, '/userBalance')
|
||||||
|
const raw = data?.userBalance
|
||||||
|
const balance = typeof raw === 'number' ? raw : Number.parseFloat(String(raw ?? ''))
|
||||||
|
return {
|
||||||
|
balance: Number.isFinite(balance) ? balance : 0,
|
||||||
|
currency: fallbackCurrency || 'RUB',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyServers(baseUrl: string, credentials: string): Promise<FourVpsServer[]> {
|
||||||
|
const { baseUrl: url, apiKey } = parseCredentials(baseUrl, credentials)
|
||||||
|
const data = await fourvpsRequest<{ serverlist?: FourVpsServer[] }>(url, apiKey, '/myservers')
|
||||||
|
return Array.isArray(data?.serverlist) ? data.serverlist : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDcList(
|
||||||
|
baseUrl: string,
|
||||||
|
credentials: string,
|
||||||
|
): Promise<Map<number, FourVpsDatacenter>> {
|
||||||
|
const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials)
|
||||||
|
if (panelId == null) return new Map()
|
||||||
|
|
||||||
|
const data = await fourvpsRequest<{ dcList?: Record<string, FourVpsDatacenter> }>(
|
||||||
|
url,
|
||||||
|
apiKey,
|
||||||
|
'/getDcList',
|
||||||
|
{ panelId },
|
||||||
|
)
|
||||||
|
const map = new Map<number, FourVpsDatacenter>()
|
||||||
|
const dcList = data?.dcList ?? {}
|
||||||
|
for (const [key, dc] of Object.entries(dcList)) {
|
||||||
|
const id = dc?.id ?? Number.parseInt(key, 10)
|
||||||
|
if (Number.isFinite(id)) {
|
||||||
|
map.set(id, { ...dc, id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPresetToTariffItem(
|
||||||
|
preset: FourVpsTariffPreset,
|
||||||
|
dcId: string,
|
||||||
|
dcName: string,
|
||||||
|
country: string,
|
||||||
|
cpuModel: string,
|
||||||
|
): FourVpsTariffItem {
|
||||||
|
const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0)
|
||||||
|
const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0
|
||||||
|
const diskTag = preset.disks?.[0]?.tags?.find((t) => t.name)?.name ?? 'nvme'
|
||||||
|
const priceRaw = preset.commentParsed?.price
|
||||||
|
const price = priceRaw != null ? String(priceRaw) : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: String(preset.id),
|
||||||
|
datacenterKey: dcId,
|
||||||
|
datacenterName: dcName,
|
||||||
|
name: preset.nameFull || preset.name || '',
|
||||||
|
desc: preset.commentParsed?.eth ? `Канал: ${preset.commentParsed.eth}` : '',
|
||||||
|
vcpu: preset.cpu_number ?? 0,
|
||||||
|
ramGb: preset.ram_mib ? Math.round((preset.ram_mib / 1024) * 10) / 10 : 0,
|
||||||
|
diskGb: typeof diskGb === 'number' ? diskGb : 0,
|
||||||
|
diskType: diskTag.toUpperCase(),
|
||||||
|
virtualization: 'KVM',
|
||||||
|
channel: preset.commentParsed?.eth ?? '',
|
||||||
|
location: dcName,
|
||||||
|
country,
|
||||||
|
cpuModel,
|
||||||
|
orderAvailable: true,
|
||||||
|
price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTarifList(
|
||||||
|
baseUrl: string,
|
||||||
|
credentials: string,
|
||||||
|
): Promise<FourVpsTariffItem[]> {
|
||||||
|
const { baseUrl: url, panelId, apiKey } = parseCredentials(baseUrl, credentials)
|
||||||
|
if (panelId == null) return []
|
||||||
|
|
||||||
|
const [tarifData, dcMap] = await Promise.all([
|
||||||
|
fourvpsRequest<{
|
||||||
|
tarifList?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
clusterInfo?: {
|
||||||
|
id?: number
|
||||||
|
dc_name?: string
|
||||||
|
flag?: string
|
||||||
|
cpu_name?: string
|
||||||
|
presets?: number[]
|
||||||
|
}
|
||||||
|
presets?: Record<string, FourVpsTariffPreset>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}>(url, apiKey, '/getTarifList', { panelId }),
|
||||||
|
fetchDcList(baseUrl, credentials),
|
||||||
|
])
|
||||||
|
|
||||||
|
const items: FourVpsTariffItem[] = []
|
||||||
|
const tarifList = tarifData?.tarifList ?? {}
|
||||||
|
|
||||||
|
for (const [dcKey, cluster] of Object.entries(tarifList)) {
|
||||||
|
const clusterInfo = cluster.clusterInfo ?? {}
|
||||||
|
const dcId = String(clusterInfo.id ?? dcKey)
|
||||||
|
const dcFromList = dcMap.get(Number(clusterInfo.id ?? dcKey))
|
||||||
|
const dcName = clusterInfo.dc_name ?? dcFromList?.dc_name ?? ''
|
||||||
|
const country = (clusterInfo.flag ?? dcFromList?.flag ?? '').toUpperCase()
|
||||||
|
const cpuModel = clusterInfo.cpu_name ?? dcFromList?.cpu_name ?? ''
|
||||||
|
const presets = cluster.presets ?? {}
|
||||||
|
|
||||||
|
for (const preset of Object.values(presets)) {
|
||||||
|
if (!preset?.id) continue
|
||||||
|
items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConnection(
|
||||||
|
baseUrl: string,
|
||||||
|
credentials: string,
|
||||||
|
): Promise<{ ok: boolean; error?: string; vdsCount?: number; balance?: number }> {
|
||||||
|
if (!baseUrl?.trim() || !credentials?.trim()) {
|
||||||
|
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [balanceInfo, servers] = await Promise.all([
|
||||||
|
fetchUserBalance(baseUrl, credentials),
|
||||||
|
fetchMyServers(baseUrl, credentials),
|
||||||
|
])
|
||||||
|
return { ok: true, vdsCount: servers.length, balance: balanceInfo.balance }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||||
|
return { ok: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { closeDb, getSqlite } from '@cfdm/db'
|
||||||
|
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||||
|
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||||
|
|
||||||
|
import { syncFromFourvps } from './sync.js'
|
||||||
|
import type { FourvpsSyncAccount } from './context.js'
|
||||||
|
|
||||||
|
vi.mock('./operations.js', () => ({
|
||||||
|
fetchMyServers: vi.fn(),
|
||||||
|
fetchUserBalance: vi.fn(),
|
||||||
|
fetchTarifList: vi.fn(),
|
||||||
|
fetchDcList: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchDcList,
|
||||||
|
fetchMyServers,
|
||||||
|
fetchTarifList,
|
||||||
|
fetchUserBalance,
|
||||||
|
} from './operations.js'
|
||||||
|
|
||||||
|
const account: FourvpsSyncAccount = {
|
||||||
|
id: 'acc-4vps',
|
||||||
|
providerId: 'prov-4vps',
|
||||||
|
name: '4VPS Account',
|
||||||
|
panelUrl: '',
|
||||||
|
currency: 'RUB',
|
||||||
|
billingMode: 'monthly',
|
||||||
|
notes: '',
|
||||||
|
apiType: '4vps',
|
||||||
|
apiBaseUrl: 'https://4vps.su/api',
|
||||||
|
apiCredentials: '1:secret',
|
||||||
|
panelId: 1,
|
||||||
|
apiKey: 'secret',
|
||||||
|
balanceApi: null,
|
||||||
|
balanceCurrency: null,
|
||||||
|
balanceUpdatedAt: null,
|
||||||
|
enoughmoneyto: '',
|
||||||
|
balanceAlertBelow: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('syncFromFourvps', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb()
|
||||||
|
getSqlite()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-4vps', '4VPS', '4vps', 'https://4vps.su/api')`,
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
providerAccountsRepository.create({
|
||||||
|
id: 'acc-4vps',
|
||||||
|
providerId: 'prov-4vps',
|
||||||
|
name: '4VPS Account',
|
||||||
|
apiCredentials: '1:secret',
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(fetchMyServers).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
name: 'srv1',
|
||||||
|
price: 420,
|
||||||
|
dc: 1,
|
||||||
|
image: 'Debian 11',
|
||||||
|
mem: 2,
|
||||||
|
cpu: 2,
|
||||||
|
disk: 20,
|
||||||
|
ipv4: '1.2.3.4',
|
||||||
|
status: 'active',
|
||||||
|
tname: 'cx01',
|
||||||
|
time: 1664275501,
|
||||||
|
expired: 1666781101,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
vi.mocked(fetchUserBalance).mockResolvedValue({ balance: 77825, currency: 'RUB' })
|
||||||
|
vi.mocked(fetchDcList).mockResolvedValue(
|
||||||
|
new Map([[1, { id: 1, dc_name: 'AE DC1', flag: 'ae' }]]),
|
||||||
|
)
|
||||||
|
vi.mocked(fetchTarifList).mockResolvedValue([
|
||||||
|
{
|
||||||
|
externalId: '13',
|
||||||
|
datacenterKey: '1',
|
||||||
|
datacenterName: 'AE DC1',
|
||||||
|
name: 'AE-cx01',
|
||||||
|
desc: '',
|
||||||
|
vcpu: 1,
|
||||||
|
ramGb: 1,
|
||||||
|
diskGb: 10,
|
||||||
|
diskType: 'NVME',
|
||||||
|
virtualization: 'KVM',
|
||||||
|
channel: '1Gbit/s',
|
||||||
|
location: 'AE DC1',
|
||||||
|
country: 'AE',
|
||||||
|
cpuModel: 'E5',
|
||||||
|
orderAvailable: true,
|
||||||
|
price: '420',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeDb()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts VPS, balance and tariffs', async () => {
|
||||||
|
const result = await syncFromFourvps(account)
|
||||||
|
|
||||||
|
expect(result.vpsCount).toBe(1)
|
||||||
|
expect(result.paymentsCount).toBe(0)
|
||||||
|
expect(result.tariffsCount).toBe(1)
|
||||||
|
expect(result.balance?.balance).toBe(77825)
|
||||||
|
|
||||||
|
const vps = getSqlite()
|
||||||
|
.prepare(`SELECT id, ip FROM vps WHERE providerAccountId = ?`)
|
||||||
|
.get('acc-4vps') as { id: string; ip: string }
|
||||||
|
expect(vps.id).toBe('vps-4vps-acc-4vps-100')
|
||||||
|
expect(vps.ip).toBe('1.2.3.4')
|
||||||
|
|
||||||
|
const tariffs = getSqlite()
|
||||||
|
.prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`)
|
||||||
|
.get('acc-4vps') as { c: number }
|
||||||
|
expect(tariffs.c).toBe(1)
|
||||||
|
|
||||||
|
const bal = getSqlite()
|
||||||
|
.prepare(`SELECT balance_api FROM provider_accounts WHERE id = ?`)
|
||||||
|
.get('acc-4vps') as { balance_api: number }
|
||||||
|
expect(bal.balance_api).toBe(77825)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Sync 4VPS data into vps-tracker DB (Drizzle / @cfdm/db)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, like, or } from 'drizzle-orm'
|
||||||
|
import { getDb, schema } from '@cfdm/db'
|
||||||
|
|
||||||
|
import type { FourvpsSyncAccount } from './context.js'
|
||||||
|
import { mapServerToVps } from './mappers.js'
|
||||||
|
import {
|
||||||
|
fetchDcList,
|
||||||
|
fetchMyServers,
|
||||||
|
fetchTarifList,
|
||||||
|
fetchUserBalance,
|
||||||
|
type FourVpsBalanceInfo,
|
||||||
|
} from './operations.js'
|
||||||
|
|
||||||
|
export interface SyncFromFourvpsOptions {
|
||||||
|
skipTariffs?: boolean
|
||||||
|
skipVpsPayments?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncSummary {
|
||||||
|
added: { id: string; label: string }[]
|
||||||
|
updated: { id: string; label: string; fields: string[] }[]
|
||||||
|
paymentsAdded: number
|
||||||
|
tariffsOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncFromFourvpsResult {
|
||||||
|
vpsCount: number
|
||||||
|
paymentsCount: number
|
||||||
|
tariffsCount: number
|
||||||
|
newTariffs: { name: string; price: string; providerId: string }[]
|
||||||
|
balance: FourVpsBalanceInfo | null
|
||||||
|
syncSummary: SyncSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYNC_UPDATE_FIELDS = [
|
||||||
|
'country',
|
||||||
|
'city',
|
||||||
|
'datacenter',
|
||||||
|
'os',
|
||||||
|
'notes',
|
||||||
|
'status',
|
||||||
|
'tariffType',
|
||||||
|
'currency',
|
||||||
|
'dailyRate',
|
||||||
|
'monthlyRate',
|
||||||
|
'paidUntil',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function normVal(v: unknown): string {
|
||||||
|
if (v == null || v === '') return ''
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCredentials(account: FourvpsSyncAccount): string {
|
||||||
|
const { panelId, apiKey } = account
|
||||||
|
if (panelId != null) return `${panelId}:${apiKey}`
|
||||||
|
return apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncFromFourvps(
|
||||||
|
account: FourvpsSyncAccount,
|
||||||
|
opts: SyncFromFourvpsOptions = {},
|
||||||
|
): Promise<SyncFromFourvpsResult> {
|
||||||
|
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||||
|
const { apiBaseUrl, providerId, id: accountId } = account
|
||||||
|
const credentials = buildCredentials(account)
|
||||||
|
if (!apiBaseUrl?.trim() || !account.apiKey?.trim()) {
|
||||||
|
throw new Error('API URL and credentials are required')
|
||||||
|
}
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const fetchVpsData = !skipVpsPayments
|
||||||
|
const fetchTariffs = !skipTariffs
|
||||||
|
|
||||||
|
const [servers, balanceInfo, tariffItems, dcMap] = await Promise.all([
|
||||||
|
fetchVpsData ? fetchMyServers(apiBaseUrl, credentials) : [],
|
||||||
|
fetchVpsData
|
||||||
|
? fetchUserBalance(apiBaseUrl, credentials, account.currency || 'RUB').catch(() => null)
|
||||||
|
: null,
|
||||||
|
fetchTariffs ? fetchTarifList(apiBaseUrl, credentials).catch(() => []) : [],
|
||||||
|
fetchVpsData || fetchTariffs ? fetchDcList(apiBaseUrl, credentials).catch(() => new Map()) : new Map(),
|
||||||
|
])
|
||||||
|
|
||||||
|
let vpsCount = 0
|
||||||
|
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||||
|
|
||||||
|
if (fetchVpsData) {
|
||||||
|
for (const server of servers) {
|
||||||
|
const vps = mapServerToVps(server, providerId, accountId, dcMap)
|
||||||
|
const id = `vps-4vps-${accountId}-${vps.externalId}`
|
||||||
|
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||||
|
const dailyRate = vps.dailyRate
|
||||||
|
const monthlyRate = vps.monthlyRate
|
||||||
|
const paidUntil = vps.paidUntil || ''
|
||||||
|
const notes = vps.notes
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.vps)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.vps.providerAccountId, accountId),
|
||||||
|
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%4vps-${vps.externalId}%`)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
let userOverrides: string[] = []
|
||||||
|
try {
|
||||||
|
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||||
|
} catch {
|
||||||
|
userOverrides = []
|
||||||
|
}
|
||||||
|
const merged = {
|
||||||
|
ip: vps.ip,
|
||||||
|
ipv6: vps.ipv6,
|
||||||
|
additionalIps,
|
||||||
|
dns: vps.dns,
|
||||||
|
country: vps.country,
|
||||||
|
city: vps.city,
|
||||||
|
datacenter: vps.datacenter,
|
||||||
|
os: vps.os,
|
||||||
|
status: vps.status,
|
||||||
|
tariffType: vps.tariffType,
|
||||||
|
currency: vps.currency,
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
paidUntil,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
for (const f of SYNC_UPDATE_FIELDS) {
|
||||||
|
if (userOverrides.includes(f)) {
|
||||||
|
merged[f] = existing[f as keyof typeof existing] as never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
|
||||||
|
const changedFields = compareFields.filter(
|
||||||
|
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
||||||
|
)
|
||||||
|
if (changedFields.length > 0) {
|
||||||
|
const label = merged.dns || merged.ip || existing.id
|
||||||
|
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
|
||||||
|
}
|
||||||
|
db.update(schema.vps)
|
||||||
|
.set({
|
||||||
|
ip: merged.ip,
|
||||||
|
ipv6: merged.ipv6,
|
||||||
|
additionalIps: merged.additionalIps,
|
||||||
|
dns: merged.dns,
|
||||||
|
country: merged.country,
|
||||||
|
city: merged.city,
|
||||||
|
datacenter: merged.datacenter,
|
||||||
|
os: merged.os,
|
||||||
|
status: merged.status,
|
||||||
|
tariffType: merged.tariffType,
|
||||||
|
currency: merged.currency,
|
||||||
|
dailyRate: merged.dailyRate,
|
||||||
|
monthlyRate: merged.monthlyRate,
|
||||||
|
paidUntil: merged.paidUntil,
|
||||||
|
notes: merged.notes,
|
||||||
|
})
|
||||||
|
.where(eq(schema.vps.id, existing.id))
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
const label = vps.dns || vps.ip || id
|
||||||
|
syncSummary.added.push({ id, label })
|
||||||
|
db.insert(schema.vps)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
ip: vps.ip,
|
||||||
|
ipv6: vps.ipv6,
|
||||||
|
additionalIps,
|
||||||
|
dns: vps.dns,
|
||||||
|
providerId: vps.providerId,
|
||||||
|
providerAccountId: vps.providerAccountId,
|
||||||
|
country: vps.country,
|
||||||
|
city: vps.city,
|
||||||
|
datacenter: vps.datacenter,
|
||||||
|
os: vps.os,
|
||||||
|
vcpu: vps.vcpu,
|
||||||
|
ramGb: vps.ramGb,
|
||||||
|
diskGb: vps.diskGb,
|
||||||
|
diskType: vps.diskType,
|
||||||
|
virtualization: vps.virtualization,
|
||||||
|
bandwidthTb: vps.bandwidthTb,
|
||||||
|
sshPort: vps.sshPort,
|
||||||
|
rootUser: vps.rootUser,
|
||||||
|
purpose: vps.purpose,
|
||||||
|
environment: vps.environment,
|
||||||
|
project: vps.project,
|
||||||
|
projectId: null,
|
||||||
|
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
|
||||||
|
backupEnabled: vps.backupEnabled ? 1 : 0,
|
||||||
|
status: vps.status,
|
||||||
|
tariffType: vps.tariffType,
|
||||||
|
currency: vps.currency,
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
createdAt: vps.createdAt || new Date().toISOString().slice(0, 10),
|
||||||
|
paidUntil,
|
||||||
|
notes,
|
||||||
|
userOverrides: '[]',
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
vpsCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchVpsData && balanceInfo) {
|
||||||
|
db.update(schema.providerAccounts)
|
||||||
|
.set({
|
||||||
|
balanceApi: balanceInfo.balance,
|
||||||
|
balanceCurrency: balanceInfo.currency || 'RUB',
|
||||||
|
balanceUpdatedAt: new Date().toISOString(),
|
||||||
|
enoughmoneyto: '',
|
||||||
|
})
|
||||||
|
.where(eq(schema.providerAccounts.id, accountId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
let tariffsCount = 0
|
||||||
|
const newTariffs: { name: string; price: string; providerId: string }[] = []
|
||||||
|
if (fetchTariffs) {
|
||||||
|
const existingTariffIds = new Set(
|
||||||
|
db
|
||||||
|
.select({ id: schema.activeTariffs.id })
|
||||||
|
.from(schema.activeTariffs)
|
||||||
|
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.id),
|
||||||
|
)
|
||||||
|
const syncedAt = new Date().toISOString()
|
||||||
|
db.delete(schema.activeTariffs)
|
||||||
|
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
for (const t of tariffItems) {
|
||||||
|
const dcKey = t.datacenterKey ?? ''
|
||||||
|
const dcName = t.datacenterName ?? ''
|
||||||
|
const tariffId = dcKey
|
||||||
|
? `tariff-4vps-${accountId}-${t.externalId}-${dcKey}`
|
||||||
|
: `tariff-4vps-${accountId}-${t.externalId}`
|
||||||
|
if (!existingTariffIds.has(tariffId)) {
|
||||||
|
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
||||||
|
}
|
||||||
|
db.insert(schema.activeTariffs)
|
||||||
|
.values({
|
||||||
|
id: tariffId,
|
||||||
|
providerAccountId: accountId,
|
||||||
|
providerId,
|
||||||
|
externalId: t.externalId,
|
||||||
|
datacenterKey: dcKey,
|
||||||
|
datacenterName: dcName,
|
||||||
|
name: t.name || '',
|
||||||
|
desc: t.desc || '',
|
||||||
|
vcpu: t.vcpu || 0,
|
||||||
|
ramGb: t.ramGb || 0,
|
||||||
|
diskGb: t.diskGb || 0,
|
||||||
|
diskType: t.diskType || 'NVMe',
|
||||||
|
virtualization: t.virtualization || 'KVM',
|
||||||
|
channel: t.channel || '',
|
||||||
|
location: t.location || '',
|
||||||
|
country: t.country || '',
|
||||||
|
cpuModel: t.cpuModel || '',
|
||||||
|
orderAvailable: t.orderAvailable ? 1 : 0,
|
||||||
|
price: t.price || '',
|
||||||
|
syncedAt,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
tariffsCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fetchVpsData) {
|
||||||
|
syncSummary.tariffsOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vpsCount,
|
||||||
|
paymentsCount: 0,
|
||||||
|
tariffsCount,
|
||||||
|
newTariffs,
|
||||||
|
balance: balanceInfo,
|
||||||
|
syncSummary,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { syncFromBillmanager } from '../billmanager/sync.js'
|
||||||
|
import { testConnection as bmTestConnection, fetchDashboardInfo } from '../billmanager/operations.js'
|
||||||
|
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||||
|
|
||||||
|
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||||
|
|
||||||
|
export const billmanagerAdapter: ProviderAdapter = {
|
||||||
|
type: 'billmanager',
|
||||||
|
|
||||||
|
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||||
|
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
|
||||||
|
return { ok: result.ok, message: result.error }
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncAccount(
|
||||||
|
account: BillmanagerSyncAccount,
|
||||||
|
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result = await syncFromBillmanager(account, options)
|
||||||
|
return {
|
||||||
|
vpsCount: result.vpsCount,
|
||||||
|
paymentsCount: result.paymentsCount,
|
||||||
|
tariffsCount: result.tariffsCount,
|
||||||
|
balance: result.balance,
|
||||||
|
syncSummary: result.syncSummary,
|
||||||
|
newTariffs: result.newTariffs,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchBalance(account: BillmanagerSyncAccount) {
|
||||||
|
const info = await fetchDashboardInfo(account.apiBaseUrl, String(account.apiCredentials).trim(), {
|
||||||
|
fallbackCurrency: account.currency,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
balance: info.balance,
|
||||||
|
currency: info.currency || 'RUB',
|
||||||
|
enoughmoneyto: info.enoughmoneyto || '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { syncFromFourvps } from '../fourvps/sync.js'
|
||||||
|
import {
|
||||||
|
fetchUserBalance as fetchFourvpsBalance,
|
||||||
|
testConnection as fourvpsTestConnection,
|
||||||
|
} from '../fourvps/operations.js'
|
||||||
|
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||||
|
|
||||||
|
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||||
|
|
||||||
|
export const fourvpsAdapter: ProviderAdapter = {
|
||||||
|
type: '4vps',
|
||||||
|
|
||||||
|
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
||||||
|
const result = await fourvpsTestConnection(apiBaseUrl, apiCredentials)
|
||||||
|
return { ok: result.ok, message: result.error }
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncAccount(
|
||||||
|
account: FourvpsSyncAccount,
|
||||||
|
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result = await syncFromFourvps(account, options)
|
||||||
|
return {
|
||||||
|
vpsCount: result.vpsCount,
|
||||||
|
paymentsCount: result.paymentsCount,
|
||||||
|
tariffsCount: result.tariffsCount,
|
||||||
|
balance: result.balance,
|
||||||
|
syncSummary: result.syncSummary,
|
||||||
|
newTariffs: result.newTariffs,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchBalance(account: FourvpsSyncAccount) {
|
||||||
|
const cred =
|
||||||
|
account.panelId != null ? `${account.panelId}:${account.apiKey}` : account.apiKey
|
||||||
|
const info = await fetchFourvpsBalance(account.apiBaseUrl, cred, account.currency || 'RUB')
|
||||||
|
return {
|
||||||
|
balance: info.balance,
|
||||||
|
currency: info.currency || 'RUB',
|
||||||
|
enoughmoneyto: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,28 +1,16 @@
|
|||||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
import type { schema } from '@cfdm/db'
|
||||||
import { syncFromBillmanager } from '../billmanager/sync.js'
|
|
||||||
|
import { billmanagerAccountRowForSync } from '../billmanager/context.js'
|
||||||
|
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
|
||||||
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
|
||||||
import { testConnection as bmTestConnection } from '../billmanager/operations.js'
|
import type { FourvpsSyncAccount } from '../fourvps/context.js'
|
||||||
|
|
||||||
export const billmanagerAdapter: ProviderAdapter = {
|
import { billmanagerAdapter } from './billmanager-adapter.js'
|
||||||
type: 'billmanager',
|
import { fourvpsAdapter } from './fourvps-adapter.js'
|
||||||
|
import type { ProviderAdapter } from './types.js'
|
||||||
|
|
||||||
async testConnection(apiBaseUrl: string, apiCredentials: string) {
|
export { billmanagerAdapter } from './billmanager-adapter.js'
|
||||||
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
|
export { fourvpsAdapter } from './fourvps-adapter.js'
|
||||||
return { ok: result.ok, message: result.error }
|
|
||||||
},
|
|
||||||
|
|
||||||
async syncAccount(account: BillmanagerSyncAccount, options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }): Promise<SyncResult> {
|
|
||||||
const result = await syncFromBillmanager(account, options)
|
|
||||||
return {
|
|
||||||
vpsCount: result.vpsCount,
|
|
||||||
paymentsCount: result.paymentsCount,
|
|
||||||
tariffsCount: result.tariffsCount,
|
|
||||||
balance: result.balance,
|
|
||||||
syncSummary: result.syncSummary,
|
|
||||||
newTariffs: result.newTariffs,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const manualAdapter: ProviderAdapter = {
|
export const manualAdapter: ProviderAdapter = {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
@@ -43,6 +31,7 @@ export const manualAdapter: ProviderAdapter = {
|
|||||||
|
|
||||||
const adapters: Record<string, ProviderAdapter> = {
|
const adapters: Record<string, ProviderAdapter> = {
|
||||||
billmanager: billmanagerAdapter,
|
billmanager: billmanagerAdapter,
|
||||||
|
'4vps': fourvpsAdapter,
|
||||||
manual: manualAdapter,
|
manual: manualAdapter,
|
||||||
none: manualAdapter,
|
none: manualAdapter,
|
||||||
}
|
}
|
||||||
@@ -51,3 +40,33 @@ export function getProviderAdapter(apiType: string | null | undefined): Provider
|
|||||||
const key = (apiType || 'none').toLowerCase().trim()
|
const key = (apiType || 'none').toLowerCase().trim()
|
||||||
return adapters[key] ?? manualAdapter
|
return adapters[key] ?? manualAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||||
|
type ProviderRow = typeof schema.providers.$inferSelect
|
||||||
|
|
||||||
|
export type SyncReadyAccount = BillmanagerSyncAccount | FourvpsSyncAccount
|
||||||
|
|
||||||
|
export function resolveSyncAccount(
|
||||||
|
accountRow: AccountRow | null | undefined,
|
||||||
|
providerRow: ProviderRow | null | undefined,
|
||||||
|
): { apiType: string; account: SyncReadyAccount } | null {
|
||||||
|
if (!accountRow) return null
|
||||||
|
const apiType = String(providerRow?.apiType || accountRow.apiType || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
if (apiType === 'billmanager') {
|
||||||
|
const account = billmanagerAccountRowForSync(accountRow, providerRow)
|
||||||
|
return account ? { apiType, account } : null
|
||||||
|
}
|
||||||
|
if (apiType === '4vps') {
|
||||||
|
const account = fourvpsAccountRowForSync(accountRow, providerRow)
|
||||||
|
return account ? { apiType, account } : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYNC_SETUP_ERRORS: Record<string, string> = {
|
||||||
|
billmanager: 'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
|
||||||
|
'4vps': 'Укажите в настройках хостера тип API 4VPS и URL; в аккаунте — Panel ID и API Key',
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Generic account sync with sync_log recording
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { getDb, schema } from '@cfdm/db'
|
||||||
|
|
||||||
|
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||||
|
|
||||||
|
export interface RunAccountSyncResult extends SyncResult {
|
||||||
|
ok: true
|
||||||
|
logId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAccountSync(
|
||||||
|
adapter: ProviderAdapter,
|
||||||
|
account: unknown,
|
||||||
|
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
|
||||||
|
): Promise<RunAccountSyncResult> {
|
||||||
|
const db = getDb()
|
||||||
|
const accountRow = account as { id: string }
|
||||||
|
const logId = `sync-${accountRow.id}-${Date.now()}`
|
||||||
|
|
||||||
|
db.insert(schema.syncLog)
|
||||||
|
.values({
|
||||||
|
id: logId,
|
||||||
|
accountId: accountRow.id,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
status: 'running',
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adapter.syncAccount(account, opts)
|
||||||
|
const summaryPayload = {
|
||||||
|
...(result.syncSummary || {}),
|
||||||
|
vpsCount: result.vpsCount,
|
||||||
|
paymentsCount: result.paymentsCount,
|
||||||
|
tariffsCount: result.tariffsCount ?? 0,
|
||||||
|
}
|
||||||
|
db.update(schema.syncLog)
|
||||||
|
.set({
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
status: 'ok',
|
||||||
|
vpsCount: result.vpsCount,
|
||||||
|
paymentsCount: result.paymentsCount,
|
||||||
|
summary: JSON.stringify(summaryPayload),
|
||||||
|
})
|
||||||
|
.where(eq(schema.syncLog.id, logId))
|
||||||
|
.run()
|
||||||
|
return { ok: true, logId, ...result }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
db.update(schema.syncLog)
|
||||||
|
.set({
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
status: 'error',
|
||||||
|
error: message,
|
||||||
|
summary: JSON.stringify({ error: message }),
|
||||||
|
})
|
||||||
|
.where(eq(schema.syncLog.id, logId))
|
||||||
|
.run()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,4 +21,9 @@ export interface ProviderAdapter {
|
|||||||
account: unknown,
|
account: unknown,
|
||||||
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||||
): Promise<SyncResult>
|
): Promise<SyncResult>
|
||||||
|
fetchBalance?(account: unknown): Promise<{
|
||||||
|
balance?: number
|
||||||
|
currency?: string
|
||||||
|
enoughmoneyto?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { sql } from 'drizzle-orm'
|
|||||||
import { getDb, schema } from '@cfdm/db'
|
import { getDb, schema } from '@cfdm/db'
|
||||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||||
|
|
||||||
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
|
||||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
import { runAccountSync } from './providers/sync-job.js'
|
||||||
import { runVpsUptimeChecks } from './uptime-check.js'
|
import { runVpsUptimeChecks } from './uptime-check.js'
|
||||||
import { publishMany, publishNotification } from './notifications/engine.js'
|
import { publishMany, publishNotification } from './notifications/engine.js'
|
||||||
import {
|
import {
|
||||||
@@ -23,21 +23,29 @@ const SETTINGS_ID = 'settings-main'
|
|||||||
|
|
||||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||||
|
|
||||||
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
interface SyncableAccountEntry {
|
||||||
|
account: SyncReadyAccount
|
||||||
|
apiType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db
|
const rows = db
|
||||||
.all<AccountRow>(sql`
|
.all<AccountRow>(sql`
|
||||||
SELECT pa.* FROM provider_accounts pa
|
SELECT pa.* FROM provider_accounts pa
|
||||||
INNER JOIN providers p ON p.id = pa.providerId
|
INNER JOIN providers p ON p.id = pa.providerId
|
||||||
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
|
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps')
|
||||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||||
`)
|
`)
|
||||||
const providers = db.select().from(schema.providers).all()
|
const providers = db.select().from(schema.providers).all()
|
||||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||||
return rows
|
return rows
|
||||||
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
|
.map((a) => {
|
||||||
.filter((a): a is NonNullable<typeof a> => a != null)
|
const resolved = resolveSyncAccount(a, providerById.get(a.providerId))
|
||||||
|
return resolved ? { account: resolved.account, apiType: resolved.apiType } : null
|
||||||
|
})
|
||||||
|
.filter((e): e is SyncableAccountEntry => e != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runNotificationTick(): Promise<void> {
|
export async function runNotificationTick(): Promise<void> {
|
||||||
@@ -56,13 +64,14 @@ export async function runScheduledSync(): Promise<void> {
|
|||||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||||
if (!settings?.syncEnabled) return
|
if (!settings?.syncEnabled) return
|
||||||
|
|
||||||
const accounts = getBillmanagerAccounts()
|
const entries = getSyncableAccounts()
|
||||||
const digestLines: string[] = []
|
const digestLines: string[] = []
|
||||||
const lowBalanceLines: string[] = []
|
const lowBalanceLines: string[] = []
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const { account, apiType } of entries) {
|
||||||
try {
|
try {
|
||||||
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
const adapter = getProviderAdapter(apiType)
|
||||||
|
const result = await runAccountSync(adapter, account, { skipTariffs: true })
|
||||||
const s = result.syncSummary
|
const s = result.syncSummary
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||||
@@ -103,12 +112,13 @@ export async function runScheduledSyncTariffs(): Promise<void> {
|
|||||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||||
if (!settings?.syncEnabled) return
|
if (!settings?.syncEnabled) return
|
||||||
|
|
||||||
const accounts = getBillmanagerAccounts()
|
const entries = getSyncableAccounts()
|
||||||
const providers = getDb().select().from(schema.providers).all()
|
const providers = getDb().select().from(schema.providers).all()
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const { account, apiType } of entries) {
|
||||||
try {
|
try {
|
||||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
const adapter = getProviderAdapter(apiType)
|
||||||
|
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
|
||||||
const newTariffs = result.newTariffs || []
|
const newTariffs = result.newTariffs || []
|
||||||
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
||||||
const provider = providers.find((p) => p.id === account.providerId)
|
const provider = providers.find((p) => p.id === account.providerId)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildFourVpsCredentials,
|
||||||
|
parseFourVpsCredentials,
|
||||||
|
} from '@cfdm/shared/utils/api-credentials'
|
||||||
|
|
||||||
|
describe('parseFourVpsCredentials', () => {
|
||||||
|
it('parses panelId:apiKey', () => {
|
||||||
|
expect(parseFourVpsCredentials('1:secret-key')).toEqual({
|
||||||
|
panelId: 1,
|
||||||
|
apiKey: 'secret-key',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns apiKey only when no colon', () => {
|
||||||
|
expect(parseFourVpsCredentials('secret-only')).toEqual({
|
||||||
|
panelId: null,
|
||||||
|
apiKey: 'secret-only',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty', () => {
|
||||||
|
expect(parseFourVpsCredentials('')).toEqual({ panelId: null, apiKey: '' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildFourVpsCredentials', () => {
|
||||||
|
it('builds panelId:apiKey', () => {
|
||||||
|
expect(buildFourVpsCredentials('1', 'key')).toBe('1:key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns key only without panel', () => {
|
||||||
|
expect(buildFourVpsCredentials('', 'key')).toBe('key')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,7 @@ import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/sch
|
|||||||
import type { BillingMode, Provider } from '@/types/entities'
|
import type { BillingMode, Provider } from '@/types/entities'
|
||||||
import { billingModeLabel } from '@/lib/format'
|
import { billingModeLabel } from '@/lib/format'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
|
import { accountCredentialLabels } from '@/lib/provider-sync'
|
||||||
|
|
||||||
const EMPTY: ProviderAccountFormValues = {
|
const EMPTY: ProviderAccountFormValues = {
|
||||||
providerId: '',
|
providerId: '',
|
||||||
@@ -59,10 +60,11 @@ export function ProviderAccountEditSheet({
|
|||||||
onBalanceRefreshed,
|
onBalanceRefreshed,
|
||||||
}: ProviderAccountEditSheetProps) {
|
}: ProviderAccountEditSheetProps) {
|
||||||
const isEdit = Boolean(defaultValues.id)
|
const isEdit = Boolean(defaultValues.id)
|
||||||
|
const initialProvider = providers.find((p) => p.id === defaultValues.providerId)
|
||||||
|
|
||||||
const testMut = useMutation({
|
const testMut = useMutation({
|
||||||
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) =>
|
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string; apiType: string }) =>
|
||||||
api.testConnection(values.apiBaseUrl, values.apiCredentials),
|
api.testConnection(values.apiBaseUrl, values.apiCredentials, values.apiType),
|
||||||
onSuccess: () => toast.success('Подключение успешно'),
|
onSuccess: () => toast.success('Подключение успешно'),
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'),
|
||||||
})
|
})
|
||||||
@@ -81,7 +83,11 @@ export function ProviderAccountEditSheet({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
|
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
|
||||||
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
|
description={
|
||||||
|
initialProvider?.apiType === '4vps'
|
||||||
|
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
|
||||||
|
: 'API-креды хранятся на сервере и используются для синка с BILLmanager'
|
||||||
|
}
|
||||||
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
|
schema={providerAccountSchema as unknown as ZodType<ProviderAccountFormValues>}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
@@ -96,6 +102,7 @@ export function ProviderAccountEditSheet({
|
|||||||
const apiPassword = watch('apiPassword')?.trim() ?? ''
|
const apiPassword = watch('apiPassword')?.trim() ?? ''
|
||||||
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
||||||
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
||||||
|
const credLabels = accountCredentialLabels(provider?.apiType)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -111,13 +118,18 @@ export function ProviderAccountEditSheet({
|
|||||||
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
|
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||||
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
|
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Логин API" htmlFor="acc-login">
|
<FormField label={credLabels.loginLabel} htmlFor="acc-login">
|
||||||
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
|
<Input
|
||||||
|
id="acc-login"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={credLabels.loginPlaceholder || undefined}
|
||||||
|
{...register('apiLogin')}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
|
label={isEdit ? `${credLabels.passwordLabel} (необязательно)` : credLabels.passwordLabel}
|
||||||
htmlFor="acc-password"
|
htmlFor="acc-password"
|
||||||
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
|
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий ключ' : undefined}
|
||||||
>
|
>
|
||||||
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
|
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -128,7 +140,13 @@ export function ProviderAccountEditSheet({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canTest}
|
disabled={!canTest}
|
||||||
loading={testMut.isPending}
|
loading={testMut.isPending}
|
||||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
|
onClick={() =>
|
||||||
|
testMut.mutate({
|
||||||
|
apiBaseUrl,
|
||||||
|
apiCredentials,
|
||||||
|
apiType: provider?.apiType ?? 'billmanager',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PlugIcon data-icon="inline-start" />
|
<PlugIcon data-icon="inline-start" />
|
||||||
Проверить подключение
|
Проверить подключение
|
||||||
|
|||||||
@@ -68,12 +68,25 @@ export function ProviderEditSheet({
|
|||||||
onValueChange={(v) => setValue('apiType', (v ?? 'none') as ApiType, { shouldValidate: true })}
|
onValueChange={(v) => setValue('apiType', (v ?? 'none') as ApiType, { shouldValidate: true })}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'billmanager', label: 'BILLmanager' },
|
{ value: 'billmanager', label: 'BILLmanager' },
|
||||||
|
{ value: '4vps', label: '4VPS.SU' },
|
||||||
{ value: 'none', label: 'Нет' },
|
{ value: 'none', label: 'Нет' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
|
<FormField
|
||||||
<Input id="pr-apiurl" {...register('apiBaseUrl')} />
|
label="API URL"
|
||||||
|
htmlFor="pr-apiurl"
|
||||||
|
description={
|
||||||
|
watch('apiType') === '4vps'
|
||||||
|
? 'Базовый URL API, например https://4vps.su/api'
|
||||||
|
: 'Один URL на хостера для BILLmanager'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="pr-apiurl"
|
||||||
|
placeholder={watch('apiType') === '4vps' ? 'https://4vps.su/api' : undefined}
|
||||||
|
{...register('apiBaseUrl')}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<FormField label="Валюта" htmlFor="pr-cur">
|
<FormField label="Валюта" htmlFor="pr-cur">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
BalanceLedgerRow,
|
BalanceLedgerRow,
|
||||||
} from '@/types/entities'
|
} from '@/types/entities'
|
||||||
import { accountBalanceApi } from '@/lib/account'
|
import { accountBalanceApi } from '@/lib/account'
|
||||||
import { accountBillmanagerUiReady } from '@/lib/billmanager'
|
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
||||||
|
import { accountSyncUiReady } from '@/lib/provider-sync'
|
||||||
import {
|
import {
|
||||||
accountHasApiLedgerMismatch,
|
accountHasApiLedgerMismatch,
|
||||||
getStaleSyncAccountIds,
|
getStaleSyncAccountIds,
|
||||||
@@ -40,7 +41,7 @@ export function getAccountHealthFlags(
|
|||||||
const provider = ctx.providers.find((p) => p.id === account.providerId)
|
const provider = ctx.providers.find((p) => p.id === account.providerId)
|
||||||
const flags: AccountHealthFlag[] = []
|
const flags: AccountHealthFlag[] = []
|
||||||
|
|
||||||
if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) {
|
if (isSyncApiType(provider?.apiType) && !account.apiCredentialsSet) {
|
||||||
flags.push('no-creds')
|
flags.push('no-creds')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,5 +100,5 @@ export function countLowBalanceAccounts(
|
|||||||
|
|
||||||
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
|
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
|
||||||
const provider = providers.find((p) => p.id === account.providerId)
|
const provider = providers.find((p) => p.id === account.providerId)
|
||||||
return accountBillmanagerUiReady(account, provider)
|
return accountSyncUiReady(account, provider)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ export const api = {
|
|||||||
`/api/sync/${encodeURIComponent(accountId)}/balance`,
|
`/api/sync/${encodeURIComponent(accountId)}/balance`,
|
||||||
),
|
),
|
||||||
|
|
||||||
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
|
testConnection: (apiBaseUrl: string, apiCredentials: string, apiType?: string) =>
|
||||||
fetchApi('/api/sync/test-connection', {
|
fetchApi('/api/sync/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
body: JSON.stringify({ apiBaseUrl, apiCredentials, apiType }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
import type { ProviderAccount, Provider } from '@/types/entities'
|
export {
|
||||||
|
providerByIdMap,
|
||||||
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
syncableAccounts,
|
||||||
return new Map(providers.map((p) => [p.id, p]))
|
billmanagerSyncableAccounts,
|
||||||
}
|
accountSyncUiReady,
|
||||||
|
accountBillmanagerUiReady,
|
||||||
export function billmanagerSyncableAccounts(
|
accountUsesApiBalance,
|
||||||
providerAccounts: ProviderAccount[],
|
accountUsesBillmanagerBalanceApi,
|
||||||
providers: Provider[],
|
accountSelectLabel,
|
||||||
): ProviderAccount[] {
|
isSyncApiType,
|
||||||
const pmap = providerByIdMap(providers)
|
accountCredentialLabels,
|
||||||
return providerAccounts.filter((a) => {
|
} from './provider-sync'
|
||||||
const p = pmap.get(a.providerId)
|
|
||||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accountBillmanagerUiReady(
|
|
||||||
account: ProviderAccount,
|
|
||||||
provider?: Provider | null,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
provider?.apiType === 'billmanager' &&
|
|
||||||
Boolean((provider.apiBaseUrl || '').trim()) &&
|
|
||||||
Boolean(account.apiCredentialsSet)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accountUsesBillmanagerBalanceApi(
|
|
||||||
account: ProviderAccount,
|
|
||||||
provider?: Provider | null,
|
|
||||||
): boolean {
|
|
||||||
return provider?.apiType === 'billmanager' && account.balance_api != null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accountSelectLabel(
|
|
||||||
account: ProviderAccount,
|
|
||||||
providerById: Map<string, Provider>,
|
|
||||||
scopedProviderId?: string,
|
|
||||||
): string {
|
|
||||||
const name = account.name?.trim() || '—'
|
|
||||||
if (scopedProviderId) return name
|
|
||||||
const providerName = providerById.get(account.providerId)?.name ?? '—'
|
|
||||||
return `${providerName} / ${name}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
||||||
|
|
||||||
|
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||||
|
|
||||||
|
export { isSyncApiType }
|
||||||
|
|
||||||
|
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
||||||
|
return new Map(providers.map((p) => [p.id, p]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncableAccounts(
|
||||||
|
providerAccounts: ProviderAccount[],
|
||||||
|
providers: Provider[],
|
||||||
|
): ProviderAccount[] {
|
||||||
|
const pmap = providerByIdMap(providers)
|
||||||
|
return providerAccounts.filter((a) => {
|
||||||
|
const p = pmap.get(a.providerId)
|
||||||
|
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте syncableAccounts */
|
||||||
|
export const billmanagerSyncableAccounts = syncableAccounts
|
||||||
|
|
||||||
|
export function accountSyncUiReady(
|
||||||
|
account: ProviderAccount,
|
||||||
|
provider?: Provider | null,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isSyncApiType(provider?.apiType) &&
|
||||||
|
Boolean((provider?.apiBaseUrl || '').trim()) &&
|
||||||
|
Boolean(account.apiCredentialsSet)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте accountSyncUiReady */
|
||||||
|
export const accountBillmanagerUiReady = accountSyncUiReady
|
||||||
|
|
||||||
|
export function accountUsesApiBalance(
|
||||||
|
account: ProviderAccount,
|
||||||
|
provider?: Provider | null,
|
||||||
|
): boolean {
|
||||||
|
return isSyncApiType(provider?.apiType) && account.balance_api != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте accountUsesApiBalance */
|
||||||
|
export const accountUsesBillmanagerBalanceApi = accountUsesApiBalance
|
||||||
|
|
||||||
|
export function accountSelectLabel(
|
||||||
|
account: ProviderAccount,
|
||||||
|
providerById: Map<string, Provider>,
|
||||||
|
scopedProviderId?: string,
|
||||||
|
): string {
|
||||||
|
const name = account.name?.trim() || '—'
|
||||||
|
if (scopedProviderId) return name
|
||||||
|
const providerName = providerById.get(account.providerId)?.name ?? '—'
|
||||||
|
return `${providerName} / ${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountCredentialLabels(apiType?: string | null): {
|
||||||
|
loginLabel: string
|
||||||
|
passwordLabel: string
|
||||||
|
loginPlaceholder: string
|
||||||
|
} {
|
||||||
|
if (String(apiType).toLowerCase() === '4vps') {
|
||||||
|
return {
|
||||||
|
loginLabel: 'Panel ID',
|
||||||
|
passwordLabel: 'API Key',
|
||||||
|
loginPlaceholder: '1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
loginLabel: 'Логин API',
|
||||||
|
passwordLabel: 'Пароль API',
|
||||||
|
loginPlaceholder: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider'
|
||||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||||
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
|
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export const paymentTypeSchema = z.enum([
|
|||||||
'monthly_debit',
|
'monthly_debit',
|
||||||
])
|
])
|
||||||
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
|
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
|
||||||
export const apiTypeSchema = z.enum(['billmanager', 'none'])
|
export const apiTypeSchema = sharedApiTypeSchema
|
||||||
|
|
||||||
export const providerSchema = z.object({
|
export const providerSchema = z.object({
|
||||||
id: z.string().min(1).optional(),
|
id: z.string().min(1).optional(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields'
|
import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields'
|
||||||
|
|
||||||
|
import type { ApiType as SharedApiType } from '@cfdm/shared/contracts/provider'
|
||||||
|
|
||||||
export type VpsStatus = 'active' | 'paused' | 'archived'
|
export type VpsStatus = 'active' | 'paused' | 'archived'
|
||||||
export type TariffType = 'daily' | 'monthly'
|
export type TariffType = 'daily' | 'monthly'
|
||||||
export type BillingMode = 'daily' | 'monthly'
|
export type BillingMode = 'daily' | 'monthly'
|
||||||
@@ -9,7 +11,7 @@ export type PaymentType =
|
|||||||
| 'daily_debit'
|
| 'daily_debit'
|
||||||
| 'monthly_debit'
|
| 'monthly_debit'
|
||||||
export type LedgerDirection = 'credit' | 'debit'
|
export type LedgerDirection = 'credit' | 'debit'
|
||||||
export type ApiType = 'billmanager' | 'none'
|
export type ApiType = SharedApiType
|
||||||
|
|
||||||
export interface Provider {
|
export interface Provider {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -37,9 +37,41 @@ CREATE TABLE IF NOT EXISTS provider_accounts (
|
|||||||
CREATE TABLE IF NOT EXISTS vps (
|
CREATE TABLE IF NOT EXISTS vps (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
ip TEXT,
|
ip TEXT,
|
||||||
|
ipv6 TEXT,
|
||||||
|
additionalIps TEXT,
|
||||||
|
dns TEXT,
|
||||||
providerId TEXT,
|
providerId TEXT,
|
||||||
providerAccountId TEXT,
|
providerAccountId TEXT,
|
||||||
|
country TEXT,
|
||||||
|
city TEXT,
|
||||||
|
datacenter TEXT,
|
||||||
|
os TEXT,
|
||||||
|
vcpu INTEGER,
|
||||||
|
ramGb REAL,
|
||||||
|
diskGb INTEGER,
|
||||||
|
diskType TEXT,
|
||||||
|
virtualization TEXT,
|
||||||
|
bandwidthTb INTEGER,
|
||||||
|
sshPort INTEGER,
|
||||||
|
rootUser TEXT,
|
||||||
|
purpose TEXT,
|
||||||
|
environment TEXT,
|
||||||
|
project TEXT,
|
||||||
|
projectId TEXT,
|
||||||
|
monitoringEnabled INTEGER,
|
||||||
|
backupEnabled INTEGER,
|
||||||
status TEXT,
|
status TEXT,
|
||||||
|
tariffType TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
dailyRate REAL,
|
||||||
|
monthlyRate REAL,
|
||||||
|
createdAt TEXT,
|
||||||
|
paidUntil TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
userOverrides TEXT,
|
||||||
|
customData TEXT,
|
||||||
|
last_health_status TEXT,
|
||||||
|
last_health_checked_at TEXT,
|
||||||
FOREIGN KEY (providerId) REFERENCES providers(id),
|
FOREIGN KEY (providerId) REFERENCES providers(id),
|
||||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
|
||||||
);
|
);
|
||||||
@@ -75,6 +107,10 @@ CREATE TABLE IF NOT EXISTS sync_log (
|
|||||||
startedAt TEXT NOT NULL,
|
startedAt TEXT NOT NULL,
|
||||||
finishedAt TEXT,
|
finishedAt TEXT,
|
||||||
status TEXT,
|
status TEXT,
|
||||||
|
vpsCount INTEGER,
|
||||||
|
paymentsCount INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
summary TEXT,
|
||||||
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
|
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,6 +119,22 @@ CREATE TABLE IF NOT EXISTS active_tariffs (
|
|||||||
providerAccountId TEXT NOT NULL,
|
providerAccountId TEXT NOT NULL,
|
||||||
providerId TEXT NOT NULL,
|
providerId TEXT NOT NULL,
|
||||||
externalId TEXT NOT NULL,
|
externalId TEXT NOT NULL,
|
||||||
|
datacenterKey TEXT,
|
||||||
|
datacenterName TEXT,
|
||||||
|
name TEXT,
|
||||||
|
desc TEXT,
|
||||||
|
vcpu INTEGER,
|
||||||
|
ramGb REAL,
|
||||||
|
diskGb INTEGER,
|
||||||
|
diskType TEXT,
|
||||||
|
virtualization TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
location TEXT,
|
||||||
|
country TEXT,
|
||||||
|
cpuModel TEXT,
|
||||||
|
orderAvailable INTEGER,
|
||||||
|
price TEXT,
|
||||||
|
syncedAt TEXT,
|
||||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||||
FOREIGN KEY (providerId) REFERENCES providers(id)
|
FOREIGN KEY (providerId) REFERENCES providers(id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const API_TYPES = ['billmanager', '4vps', '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'] as const
|
||||||
|
export type SyncApiType = (typeof SYNC_API_TYPES)[number]
|
||||||
|
|
||||||
|
export function isSyncApiType(apiType?: string | null): apiType is SyncApiType {
|
||||||
|
const key = String(apiType || '').toLowerCase()
|
||||||
|
return (SYNC_API_TYPES as readonly string[]).includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
export const providerSchema = z.object({
|
export const providerSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -9,7 +21,7 @@ export const providerSchema = z.object({
|
|||||||
usdRate: z.string().optional().default(''),
|
usdRate: z.string().optional().default(''),
|
||||||
eurRate: z.string().optional().default(''),
|
eurRate: z.string().optional().default(''),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
apiType: z.string().optional().default(''),
|
apiType: apiTypeSchema,
|
||||||
apiBaseUrl: z.string().optional().default(''),
|
apiBaseUrl: z.string().optional().default(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,32 @@ export function buildApiCredentials(login: string, password: string): string {
|
|||||||
if (!l) return p
|
if (!l) return p
|
||||||
return p ? `${l}:${p}` : ''
|
return p ? `${l}:${p}` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FourVpsCredentials {
|
||||||
|
panelId: number | null
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбор 4VPS-кредов формата `panelId:apiKey` (panelId опционален). */
|
||||||
|
export function parseFourVpsCredentials(credentials: string | null | undefined): FourVpsCredentials {
|
||||||
|
const cred = String(credentials ?? '').trim()
|
||||||
|
if (!cred) return { panelId: null, apiKey: '' }
|
||||||
|
const idx = cred.indexOf(':')
|
||||||
|
if (idx <= 0) return { panelId: null, apiKey: cred }
|
||||||
|
const panelPart = cred.slice(0, idx).trim()
|
||||||
|
const apiKey = cred.slice(idx + 1)
|
||||||
|
const panelId = panelPart ? Number.parseInt(panelPart, 10) : null
|
||||||
|
return {
|
||||||
|
panelId: panelId != null && Number.isFinite(panelId) ? panelId : null,
|
||||||
|
apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Собрать 4VPS-креды: panelId + API key. */
|
||||||
|
export function buildFourVpsCredentials(panelId: string, apiKey: string): string {
|
||||||
|
const pid = panelId.trim()
|
||||||
|
const key = apiKey
|
||||||
|
if (!pid && !key) return ''
|
||||||
|
if (!pid) return key
|
||||||
|
return key ? `${pid}:${key}` : pid
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { accountBalanceApi } from './account-balance.js'
|
import { accountBalanceApi } from './account-balance.js'
|
||||||
|
import { isSyncApiType } from '../contracts/provider.js'
|
||||||
import { getPaidUntilDate, type PaidUntilContext } from './paid-until.js'
|
import { getPaidUntilDate, type PaidUntilContext } from './paid-until.js'
|
||||||
|
|
||||||
export const STALE_SYNC_HOURS = 48
|
export const STALE_SYNC_HOURS = 48
|
||||||
@@ -166,12 +167,12 @@ export function computeInventoryHealth(input: InventoryHealthInput): InventoryIs
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const bmAccounts = providerAccounts.filter((a) => {
|
const syncAccounts = providerAccounts.filter((a) => {
|
||||||
const p = providerById.get(a.providerId)
|
const p = providerById.get(a.providerId)
|
||||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||||
})
|
})
|
||||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
const staleAccounts = bmAccounts.filter((a) => {
|
const staleAccounts = syncAccounts.filter((a) => {
|
||||||
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||||
if (t == null) return true
|
if (t == null) return true
|
||||||
return now.getTime() - t > staleMs
|
return now.getTime() - t > staleMs
|
||||||
@@ -226,12 +227,12 @@ export function getStaleSyncAccountIds(
|
|||||||
now = new Date(),
|
now = new Date(),
|
||||||
): string[] {
|
): string[] {
|
||||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||||
const bmAccounts = providerAccounts.filter((a) => {
|
const syncAccounts = providerAccounts.filter((a) => {
|
||||||
const p = providerById.get(a.providerId)
|
const p = providerById.get(a.providerId)
|
||||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||||
})
|
})
|
||||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
return bmAccounts
|
return syncAccounts
|
||||||
.filter((a) => {
|
.filter((a) => {
|
||||||
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||||
if (t == null) return true
|
if (t == null) return true
|
||||||
|
|||||||
@@ -11,5 +11,6 @@
|
|||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"verbatimModuleSyntax": false
|
"verbatimModuleSyntax": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user