feat(api): завершение миграции на Fastify — sync, scheduler, backup
Docker / build (push) Has been cancelled

Портированы BILLmanager sync/test/balance, планировщик и Telegram,
импорт/экспорт бэкапов и migrate API; Docker по умолчанию на fastify.
Добавлен .understand-anything/ в gitignore, исправлен path codegraph в MCP.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-27 23:22:59 +07:00
co-authored by Cursor
parent 836c8a2936
commit 0de0538218
24 changed files with 2192 additions and 46 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"serve",
"--mcp",
"--path",
"C:\\Users\\shats\\Dev\\cloudflare-domain-manager"
"C:\\Users\\shats\\Dev\\vps-tracker"
]
}
}
+3
View File
@@ -18,6 +18,9 @@ dist-ssr
coverage
*.tsbuildinfo
# Understand Anything (knowledge graph)
.understand-anything/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+3 -3
View File
@@ -37,9 +37,9 @@ RUN apk add --no-cache nodejs
WORKDIR /app
ENV NODE_ENV=production \
PORT=3001 \
RUNTIME=express
RUNTIME=fastify
# Single layer for app code + pruned prod node_modules (symlinks resolved by COPY)
COPY --from=build /app ./
EXPOSE 3001
# RUNTIME=express (default, legacy) | RUNTIME=fastify (new stack)
CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"fastify\" ]; then node apps/api/dist/index.js; else node apps/api/index.js; fi"]
# RUNTIME=express — legacy fallback (apps/api/index.js)
CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"express\" ]; then node apps/api/index.js; else node apps/api/dist/index.js; fi"]
+4
View File
@@ -19,6 +19,8 @@ import { syncRoutes } from './routes/sync.js'
import { projectsRoutes } from './routes/projects.js'
import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
import { startScheduler } from './services/scheduler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -49,6 +51,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(projectsRoutes)
await app.register(backupRoutes)
await app.register(ratesProxyRoutes)
await app.register(migrateRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
@@ -72,6 +75,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
async function start() {
const port = Number(process.env.PORT ?? 3001)
const app = await buildApp()
startScheduler()
try {
await app.listen({ port, host: '0.0.0.0' })
} catch (err) {
+40 -7
View File
@@ -1,13 +1,32 @@
import type { FastifyPluginAsync } from 'fastify'
import { desc } from 'drizzle-orm'
import { existsSync, readFileSync } from 'node:fs'
import { getDbPath } from '@cfdm/db'
import { getDb, getDbPath, reloadDatabaseFromBuffer, schema } from '@cfdm/db'
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
import { restartScheduler } from '../services/scheduler.js'
const BACKUP_VERSION = 1
export const backupRoutes: FastifyPluginAsync = async (app) => {
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => {
done(null, body)
})
app.get('/api/backup/json', async (_req, reply) => {
const snapshot = { backupVersion: BACKUP_VERSION, exportedAt: new Date().toISOString(), ...getSnapshot() }
const syncLog = getDb()
.select()
.from(schema.syncLog)
.orderBy(desc(schema.syncLog.startedAt))
.limit(500)
.all()
const snapshot = {
backupVersion: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
...getSnapshot(),
syncLog,
}
reply.header('Content-Type', 'application/json; charset=utf-8')
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
return reply.send(JSON.stringify(snapshot, null, 2))
@@ -29,16 +48,30 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
if (!payload || typeof payload !== 'object') {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
}
// TODO: implement JSON snapshot import via repositories
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'JSON import pending migration' } })
try {
importJsonSnapshot(payload as BackupPayload)
restartScheduler()
return { ok: true }
} catch (err) {
req.log.error(err)
const message = err instanceof Error ? err.message : 'Импорт не удался'
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
}
})
app.post('/api/backup/database', async (req, reply) => {
const buf = req.body as Buffer
if (!buf || !buf.length) {
if (!Buffer.isBuffer(buf) || !buf.length) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
}
// TODO: implement DB restore via better-sqlite3 backup API
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'DB restore pending migration' } })
try {
reloadDatabaseFromBuffer(buf)
restartScheduler()
return { ok: true }
} catch (err) {
req.log.error(err)
const message = err instanceof Error ? err.message : 'Восстановление не удалось'
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
}
})
}
+177
View File
@@ -0,0 +1,177 @@
import type { FastifyPluginAsync } from 'fastify'
import { consolidateAllProviderApiSources, getSqlite } from '@cfdm/db'
export const migrateRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/migrate', async (req, reply) => {
const data = req.body
if (!data || typeof data !== 'object') {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Invalid payload' } })
}
const payload = data as Record<string, unknown>
const sqlite = getSqlite()
try {
if (Array.isArray(payload.providers) && payload.providers.length > 0) {
const stmt = sqlite.prepare(
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
for (const raw of payload.providers) {
const p = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
stmt.run(
p.id ?? '',
p.name ?? '',
p.website ?? '',
p.contact ?? '',
p.baseCurrency ?? '',
p.usdRate ?? '',
p.eurRate ?? '',
p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
)
}
}
if (Array.isArray(payload.providerAccounts) && payload.providerAccounts.length > 0) {
const stmt = sqlite.prepare(
`INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
for (const raw of payload.providerAccounts) {
const acc = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
stmt.run(
acc.id ?? '',
acc.providerId ?? '',
acc.name ?? '',
acc.panelUrl ?? '',
acc.currency ?? '',
acc.billingMode ?? '',
acc.notes ?? '',
acc.apiType ?? '',
acc.apiBaseUrl ?? '',
acc.apiCredentials ?? '',
)
}
}
if (Array.isArray(payload.vps) && payload.vps.length > 0) {
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const stmt = sqlite.prepare(sql)
for (const raw of payload.vps) {
const v = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
const userOverrides = Array.isArray(v.userOverrides)
? JSON.stringify(v.userOverrides)
: String(v.userOverrides ?? '[]')
stmt.run(
v.id ?? '',
v.ip ?? '',
v.ipv6 ?? '',
additionalIps,
v.dns ?? '',
v.providerId ?? '',
v.providerAccountId ?? '',
v.country ?? '',
v.city ?? '',
v.datacenter ?? '',
v.os ?? '',
v.vcpu ?? 0,
v.ramGb ?? 0,
v.diskGb ?? 0,
v.diskType ?? '',
v.virtualization ?? '',
v.bandwidthTb ?? 0,
v.sshPort ?? 22,
v.rootUser ?? '',
v.purpose ?? '',
v.environment ?? '',
v.project ?? '',
v.projectId ?? null,
v.monitoringEnabled ? 1 : 0,
v.backupEnabled ? 1 : 0,
v.status ?? 'active',
v.tariffType ?? '',
v.currency ?? '',
dailyRate,
monthlyRate,
v.createdAt ?? '',
v.paidUntil ?? '',
v.notes ?? '',
userOverrides,
)
}
}
if (Array.isArray(payload.payments) && payload.payments.length > 0) {
const stmt = sqlite.prepare(
`INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
for (const raw of payload.payments) {
const pm = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
stmt.run(
pm.id ?? '',
pm.type ?? '',
pm.date ?? '',
Number(pm.amount) || 0,
pm.currency ?? '',
pm.providerAccountId ?? '',
pm.vpsId ?? '',
pm.note ?? '',
)
}
}
if (Array.isArray(payload.balanceLedger) && payload.balanceLedger.length > 0) {
const stmt = sqlite.prepare(
`INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
for (const raw of payload.balanceLedger) {
const bl = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
stmt.run(
bl.id ?? '',
bl.type ?? '',
bl.date ?? '',
Number(bl.amount) || 0,
bl.currency ?? '',
bl.direction ?? '',
bl.providerAccountId ?? '',
bl.vpsId ?? '',
bl.note ?? '',
)
}
}
const settingsList = Array.isArray(payload.settings)
? payload.settings
: payload.settings
? [payload.settings]
: []
if (settingsList.length > 0) {
const stmt = sqlite.prepare(
`INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
for (const raw of settingsList) {
const s = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
stmt.run(
s.id ?? 'settings-main',
s.baseCurrency ?? 'RUB',
s.ratesUrl ?? '',
s.autoConvert !== false ? 1 : 0,
s.ratesUpdatedAt ?? '',
s.syncEnabled ? 1 : 0,
s.syncIntervalMinutes ?? 60,
)
}
}
consolidateAllProviderApiSources(sqlite)
return { ok: true }
} catch (err) {
req.log.error(err)
const message = err instanceof Error ? err.message : 'Migration failed'
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
}
})
}
+22 -3
View File
@@ -2,6 +2,9 @@ import type { FastifyPluginAsync } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { settingsSchema } from '@cfdm/shared/contracts/settings'
import { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
@@ -11,7 +14,9 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const id = (req.body as { id?: string })?.id ?? 'settings-main'
return reply.code(201).send(settingsRepository.upsert(id, parsed.data))
const result = settingsRepository.upsert(id, parsed.data)
restartScheduler()
return reply.code(201).send(result)
})
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
@@ -19,8 +24,22 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return settingsRepository.upsert(req.params.id, parsed.data)
const result = settingsRepository.upsert(req.params.id, parsed.data)
restartScheduler()
return result
})
app.post('/api/settings/telegram/test', async () => ({ ok: true }))
app.post('/api/settings/telegram/test', async () => {
const settings = settingsRepository.getRow('settings-main')
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
}
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
'✅ VPS Tracker: тестовое сообщение',
settings.telegramMessageThreadId,
)
return { ok: true }
})
}
+79 -28
View File
@@ -1,9 +1,16 @@
import type { FastifyPluginAsync } from 'fastify'
import { desc } from 'drizzle-orm'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providersRepository } from '@cfdm/db/repositories/providers'
import {
billmanagerAccountRowForSync,
fetchDashboardInfo,
runBillmanagerAccountSync,
testConnection,
} from '../services/billmanager/index.js'
interface SyncLogRow {
id: string
accountId: string
@@ -38,6 +45,9 @@ function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
}
}
const BILLMANAGER_SETUP_ERROR =
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API'
export const syncRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/sync/status', async () => {
const rows = getDb()
@@ -49,32 +59,71 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
return rows.map(mapSyncLog)
})
app.post<{ Params: { accountId: string } }>('/api/sync/:accountId', async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
// TODO: port billmanager sync job
return reply.code(501).send({
accountId: req.params.accountId,
provider: provider?.name ?? null,
status: 'pending-migration',
note: 'Sync job port pending migration from Express adapters',
})
})
app.post<{ Params: { accountId: string }; Body: { onlyTariffs?: boolean } }>(
'/api/sync/:accountId',
async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
const syncRow = billmanagerAccountRowForSync(account, provider)
if (!syncRow) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
}
const onlyTariffs = Boolean(req.body?.onlyTariffs)
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
try {
const result = await runBillmanagerAccountSync(syncRow, opts)
return {
ok: true,
synced: {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount ?? 0,
},
}
} catch (err) {
req.log.error(err)
const message = err instanceof Error ? err.message : 'Sync failed'
return reply.code(500).send({ ok: false, error: message })
}
},
)
app.get<{ Params: { accountId: string } }>('/api/sync/:accountId/balance', async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
// TODO: port fetchDashboardInfo
return reply.code(501).send({
accountId: req.params.accountId,
status: 'pending-migration',
note: 'Balance fetch pending migration from Express adapters',
})
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
const syncRow = billmanagerAccountRowForSync(account, provider)
if (!syncRow) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
}
try {
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), {
fallbackCurrency: account.currency,
})
getDb()
.update(schema.providerAccounts)
.set({
balanceApi: info.balance,
balanceCurrency: info.currency || 'RUB',
balanceUpdatedAt: new Date().toISOString(),
enoughmoneyto: info.enoughmoneyto || '',
})
.where(eq(schema.providerAccounts.id, req.params.accountId))
.run()
return { ok: true, balance: info }
} catch (err) {
req.log.error(err)
const message = err instanceof Error ? err.message : 'Failed to fetch balance'
return reply.code(500).send({ ok: false, error: message })
}
})
app.post('/api/sync/test-connection', async (req, reply) => {
@@ -83,13 +132,15 @@ export const syncRoutes: FastifyPluginAsync = async (app) => {
apiCredentials?: string
}
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Укажите URL и учётные данные' } })
return reply.code(400).send({ ok: false, error: 'Укажите URL и учётные данные' })
}
try {
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
return result
} catch (err) {
req.log.error(err)
const message = err instanceof Error ? err.message : 'Ошибка проверки'
return reply.code(500).send({ ok: false, error: message })
}
// TODO: port testConnection
return reply.code(501).send({
ok: false,
status: 'pending-migration',
note: 'Connection test pending migration from Express adapters',
})
})
}
+280
View File
@@ -0,0 +1,280 @@
import { getDb, getSqlite, schema, consolidateAllProviderApiSources } from '@cfdm/db'
export interface BackupPayload {
providers?: unknown[]
serverProjects?: unknown[]
providerAccounts?: unknown[]
settings?: unknown[] | unknown
vps?: unknown[]
payments?: unknown[]
balanceLedger?: unknown[]
activeTariffs?: unknown[]
tariffSyncOptions?: unknown[]
syncLog?: unknown[]
}
function asObject(v: unknown): Record<string, unknown> {
return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
}
export function importJsonSnapshot(data: BackupPayload): void {
const db = getDb()
const sqlite = getSqlite()
sqlite.exec('BEGIN')
try {
db.delete(schema.syncLog).run()
db.delete(schema.tariffSyncOptions).run()
db.delete(schema.activeTariffs).run()
db.delete(schema.balanceLedger).run()
db.delete(schema.payments).run()
db.delete(schema.vps).run()
db.delete(schema.providerAccounts).run()
db.delete(schema.serverProjects).run()
db.delete(schema.providers).run()
db.delete(schema.settings).run()
for (const raw of Array.isArray(data.providers) ? data.providers : []) {
const p = asObject(raw)
db.insert(schema.providers)
.values({
id: String(p.id ?? ''),
name: String(p.name ?? ''),
website: String(p.website ?? ''),
contact: String(p.contact ?? ''),
baseCurrency: String(p.baseCurrency ?? ''),
usdRate: String(p.usdRate ?? ''),
eurRate: String(p.eurRate ?? ''),
notes: String(p.notes ?? ''),
apiType: String(p.apiType ?? ''),
apiBaseUrl: String(p.apiBaseUrl ?? ''),
})
.run()
}
for (const raw of Array.isArray(data.serverProjects) ? data.serverProjects : []) {
const sp = asObject(raw)
db.insert(schema.serverProjects)
.values({
id: String(sp.id ?? ''),
name: String(sp.name ?? ''),
color: sp.color != null ? String(sp.color) : null,
sortOrder: Number(sp.sortOrder) || 0,
notes: sp.notes != null ? String(sp.notes) : null,
createdAt: sp.createdAt != null ? String(sp.createdAt) : null,
})
.run()
}
for (const raw of Array.isArray(data.providerAccounts) ? data.providerAccounts : []) {
const acc = asObject(raw)
const alertRaw = acc.balance_alert_below ?? acc.balanceAlertBelow
const alertBelow =
alertRaw != null && alertRaw !== '' && Number.isFinite(Number(alertRaw)) ? Number(alertRaw) : null
db.insert(schema.providerAccounts)
.values({
id: String(acc.id ?? ''),
providerId: String(acc.providerId ?? ''),
name: String(acc.name ?? ''),
panelUrl: String(acc.panelUrl ?? ''),
currency: String(acc.currency ?? ''),
billingMode: String(acc.billingMode ?? ''),
notes: String(acc.notes ?? ''),
apiType: String(acc.apiType ?? ''),
apiBaseUrl: String(acc.apiBaseUrl ?? ''),
apiCredentials: String(acc.apiCredentials ?? ''),
balanceApi: acc.balance_api != null ? Number(acc.balance_api) : acc.balanceApi != null ? Number(acc.balanceApi) : null,
balanceCurrency:
acc.balance_currency != null ? String(acc.balance_currency) : acc.balanceCurrency != null ? String(acc.balanceCurrency) : null,
balanceUpdatedAt:
acc.balance_updated_at != null ? String(acc.balance_updated_at) : acc.balanceUpdatedAt != null ? String(acc.balanceUpdatedAt) : null,
enoughmoneyto: acc.enoughmoneyto != null ? String(acc.enoughmoneyto) : null,
balanceAlertBelow: alertBelow,
})
.run()
}
consolidateAllProviderApiSources(sqlite)
const settingsList = Array.isArray(data.settings)
? data.settings
: data.settings
? [data.settings]
: []
for (const raw of settingsList) {
const s = asObject(raw)
let customFields = s.customFields
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
db.insert(schema.settings)
.values({
id: String(s.id ?? 'settings-main'),
baseCurrency: String(s.baseCurrency ?? 'RUB'),
ratesUrl: String(s.ratesUrl ?? ''),
autoConvert: s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
ratesUpdatedAt: String(s.ratesUpdatedAt ?? ''),
syncEnabled: s.syncEnabled ? 1 : 0,
syncIntervalMinutes: Number(s.syncIntervalMinutes) || 60,
syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440,
telegramBotToken: String(s.telegramBotToken ?? ''),
telegramChatId: String(s.telegramChatId ?? ''),
telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''),
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0,
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0,
customFields: customFields != null ? String(customFields) : null,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
})
.run()
}
for (const raw of Array.isArray(data.vps) ? data.vps : []) {
const v = asObject(raw)
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
const userOverrides = Array.isArray(v.userOverrides)
? JSON.stringify(v.userOverrides)
: String(v.userOverrides ?? '[]')
db.insert(schema.vps)
.values({
id: String(v.id ?? ''),
ip: String(v.ip ?? ''),
ipv6: String(v.ipv6 ?? ''),
additionalIps,
dns: String(v.dns ?? ''),
providerId: String(v.providerId ?? ''),
providerAccountId: String(v.providerAccountId ?? ''),
country: String(v.country ?? ''),
city: String(v.city ?? ''),
datacenter: String(v.datacenter ?? ''),
os: String(v.os ?? ''),
vcpu: Number(v.vcpu) || 0,
ramGb: Number(v.ramGb) || 0,
diskGb: Number(v.diskGb) || 0,
diskType: String(v.diskType ?? ''),
virtualization: String(v.virtualization ?? ''),
bandwidthTb: Number(v.bandwidthTb) || 0,
sshPort: Number(v.sshPort) || 22,
rootUser: String(v.rootUser ?? ''),
purpose: String(v.purpose ?? ''),
environment: String(v.environment ?? ''),
project: String(v.project ?? ''),
projectId: v.projectId != null && v.projectId !== '' ? String(v.projectId) : null,
monitoringEnabled: v.monitoringEnabled ? 1 : 0,
backupEnabled: v.backupEnabled ? 1 : 0,
status: String(v.status ?? 'active'),
tariffType: String(v.tariffType ?? ''),
currency: String(v.currency ?? ''),
dailyRate,
monthlyRate,
createdAt: String(v.createdAt ?? ''),
paidUntil: String(v.paidUntil ?? ''),
notes: String(v.notes ?? ''),
userOverrides,
})
.run()
}
for (const raw of Array.isArray(data.payments) ? data.payments : []) {
const pm = asObject(raw)
db.insert(schema.payments)
.values({
id: String(pm.id ?? ''),
type: String(pm.type ?? ''),
date: String(pm.date ?? ''),
amount: Number(pm.amount) || 0,
currency: String(pm.currency ?? ''),
providerAccountId: String(pm.providerAccountId ?? ''),
vpsId: pm.vpsId != null ? String(pm.vpsId) : null,
note: String(pm.note ?? ''),
})
.run()
}
for (const raw of Array.isArray(data.balanceLedger) ? data.balanceLedger : []) {
const bl = asObject(raw)
db.insert(schema.balanceLedger)
.values({
id: String(bl.id ?? ''),
type: String(bl.type ?? ''),
date: String(bl.date ?? ''),
amount: Number(bl.amount) || 0,
currency: String(bl.currency ?? ''),
direction: String(bl.direction ?? ''),
providerAccountId: String(bl.providerAccountId ?? ''),
vpsId: bl.vpsId != null ? String(bl.vpsId) : null,
note: String(bl.note ?? ''),
})
.run()
}
for (const raw of Array.isArray(data.activeTariffs) ? data.activeTariffs : []) {
const t = asObject(raw)
db.insert(schema.activeTariffs)
.values({
id: String(t.id ?? ''),
providerAccountId: String(t.providerAccountId ?? ''),
providerId: String(t.providerId ?? ''),
externalId: String(t.externalId ?? ''),
datacenterKey: String(t.datacenterKey ?? ''),
datacenterName: String(t.datacenterName ?? ''),
name: String(t.name ?? ''),
desc: String(t.desc ?? ''),
vcpu: Number(t.vcpu) || 0,
ramGb: Number(t.ramGb) || 0,
diskGb: Number(t.diskGb) || 0,
diskType: String(t.diskType ?? ''),
virtualization: String(t.virtualization ?? ''),
channel: String(t.channel ?? ''),
location: String(t.location ?? ''),
country: String(t.country ?? ''),
cpuModel: String(t.cpuModel ?? ''),
orderAvailable: t.orderAvailable ? 1 : 0,
price: String(t.price ?? ''),
syncedAt: String(t.syncedAt ?? ''),
})
.run()
}
for (const raw of Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []) {
const o = asObject(raw)
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
db.insert(schema.tariffSyncOptions)
.values({
providerAccountId: String(o.providerAccountId ?? ''),
datacenters: dcs,
periods: pers,
syncedAt: String(o.syncedAt ?? ''),
})
.run()
}
for (const raw of Array.isArray(data.syncLog) ? data.syncLog : []) {
const log = asObject(raw)
db.insert(schema.syncLog)
.values({
id: String(log.id ?? ''),
accountId: String(log.accountId ?? ''),
startedAt: String(log.startedAt ?? ''),
finishedAt: log.finishedAt != null ? String(log.finishedAt) : null,
status: log.status != null ? String(log.status) : null,
vpsCount: log.vpsCount != null ? Number(log.vpsCount) : null,
paymentsCount: log.paymentsCount != null ? Number(log.paymentsCount) : null,
error: log.error != null ? String(log.error) : null,
summary:
typeof log.summary === 'string'
? log.summary
: log.summary
? JSON.stringify(log.summary)
: null,
})
.run()
}
sqlite.exec('COMMIT')
} catch (err) {
sqlite.exec('ROLLBACK')
throw err
}
}
@@ -0,0 +1,32 @@
/**
* BILLmanager 6 API HTTP client
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
*/
interface BillmanagerErrorResponse {
error?: { msg?: string; $t?: string }
}
export async function billmanagerRequest(
baseUrl: string,
authinfo: string,
func: string,
params: Record<string, string | number | undefined | null> = {},
): Promise<Record<string, unknown>> {
const url = new URL(baseUrl)
url.searchParams.set('authinfo', authinfo)
url.searchParams.set('out', 'bjson')
url.searchParams.set('func', func)
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') url.searchParams.set(k, String(v))
}
const res = await fetch(url.toString(), { method: 'GET' })
if (!res.ok) {
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
}
const data = (await res.json()) as BillmanagerErrorResponse & Record<string, unknown>
if (data.error) {
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
}
return data
}
@@ -0,0 +1,29 @@
import type { schema } from '@cfdm/db'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type ProviderRow = typeof schema.providers.$inferSelect
export interface BillmanagerSyncAccount extends AccountRow {
apiType: 'billmanager'
apiBaseUrl: string
}
export function resolveBillmanagerApi(
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 billmanagerAccountRowForSync(
accountRow: AccountRow | null | undefined,
providerRow: ProviderRow | null | undefined,
): BillmanagerSyncAccount | null {
if (!accountRow) return null
const { apiType, apiBaseUrl } = resolveBillmanagerApi(accountRow, providerRow)
const cred = String(accountRow.apiCredentials || '').trim()
if (apiType !== 'billmanager' || !apiBaseUrl || !cred) return null
return { ...accountRow, apiType: 'billmanager', apiBaseUrl }
}
@@ -0,0 +1,22 @@
/**
* BILLmanager 6 API adapter
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
*/
export {
testConnection,
fetchVds,
fetchDashboardInfo,
fetchPayments,
fetchVdsOrderPricelist,
fetchVdsOrderPricelistAllDatacenters,
} from './operations.js'
export { syncFromBillmanager } from './sync.js'
export { runBillmanagerAccountSync } from './sync-job.js'
export { billmanagerAccountRowForSync, resolveBillmanagerApi } from './context.js'
export type { BillmanagerSyncAccount } from './context.js'
export type {
SyncFromBillmanagerOptions,
SyncFromBillmanagerResult,
SyncSummary,
} from './sync.js'
@@ -0,0 +1,139 @@
/**
* BILLmanager API response → vps-tracker model mappers
*/
import { parsePricelist } from './parsers.js'
const VDS_STATUS_MAP: Record<number, string> = {
1: 'active',
2: 'active',
3: 'paused',
4: 'archived',
5: '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: 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: null
note: string
}
export function mapVdsToVps(
item: Record<string, string>,
providerId: string,
providerAccountId: string,
): MappedVps {
const status =
VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
const createdate = item.createdate || ''
const expiredate = item.real_expiredate || item.expiredate || ''
const ip = (item.ip || '').trim()
const domain = (item.domain || '').trim()
const datacenter = (item.datacentername || item.datacenter || '').trim()
const ostempl = (item.ostempl || '').trim()
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
const pricelist = item.pricelist || item.tariff || item.plan || ''
const parsed = parsePricelist(pricelist)
return {
externalId: String(item.id || ''),
ip: ip || domain || `bm-${item.id}`,
dns: domain || ip || '',
ipv6: '',
additionalIps: [],
providerId,
providerAccountId,
country: '',
city: '',
datacenter,
os: ostempl,
vcpu: parsed.vcpu || 0,
ramGb: parsed.ramGb || 0,
diskGb: parsed.diskGb || 0,
diskType: parsed.diskType || 'NVMe',
virtualization: parsed.virtualization || 'KVM',
bandwidthTb: 0,
sshPort: 22,
rootUser: 'root',
purpose: '',
environment: 'prod',
project: '',
monitoringEnabled: false,
backupEnabled: false,
status,
tariffType: 'monthly',
currency: currency || 'RUB',
dailyRate: null,
monthlyRate: cost || null,
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
notes: `bm-${item.id}`,
}
}
export function mapPaymentToPayment(
item: Record<string, string>,
providerAccountId: string,
): MappedPayment | null {
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
const createDate = item.create_date || item.createdate || ''
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
if (statusNum !== 4) return null
return {
externalId: String(item.id || ''),
type: 'provider_balance_topup',
date: dateStr,
amount,
currency:
(String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] ||
'USD',
providerAccountId,
vpsId: null,
note: `BILLmanager #${item.number || item.id}`,
}
}
@@ -0,0 +1,193 @@
/**
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
*/
import { billmanagerRequest } from './client.js'
import {
elemToObject,
extractList,
extractTariflist,
parseDatacenterName,
parseTariffDesc,
} from './parsers.js'
export interface DashboardInfo {
balance: number
currency: string
enoughmoneyto: string
realbalance: string
}
export interface TariffItem {
externalId: string
name: string
desc: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
channel: string
location: string
cpuModel: string
orderAvailable: boolean
price: string
datacenterKey?: string
datacenterName?: string
country?: string
}
export async function fetchVds(baseUrl: string, authinfo: string): Promise<Record<string, string>[]> {
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
const elems = extractList(data, 'vds')
return elems.map((e) => elemToObject(e))
}
export async function fetchDashboardInfo(
baseUrl: string,
authinfo: string,
opts: { fallbackCurrency?: string | null } = {},
): Promise<DashboardInfo> {
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
dashboard: 'info',
sfrom: 'ajax',
})
const elems =
extractList(data, 'dashboard') ||
(Array.isArray(data.elem) ? (data.elem as unknown[]) : [])
const item = elems.length > 0 ? elemToObject(elems[0] as never) : {}
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
if (!currency) {
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
else if (balanceStr.includes('£')) currency = 'GBP'
else currency = opts.fallbackCurrency || 'RUB'
}
return {
balance: amount,
currency: currency || opts.fallbackCurrency || 'RUB',
enoughmoneyto: item.enoughmoneyto || '',
realbalance: item.realbalance || '',
}
}
export async function fetchPayments(
baseUrl: string,
authinfo: string,
opts: {
createdatestart?: string
createdateend?: string
createdate?: string
filter?: string
status?: string | number
} = {},
): Promise<Record<string, string>[]> {
const params: Record<string, string> = {}
if (opts.createdatestart) params.createdatestart = opts.createdatestart
if (opts.createdateend) params.createdateend = opts.createdateend
if (opts.createdate === 'other') params.createdate = 'other'
if (opts.filter === 'on') params.filter = 'on'
if (opts.status != null) params.status = String(opts.status)
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
const elems = extractList(data, 'payment')
return elems.map((e) => elemToObject(e))
}
export async function fetchVdsOrderPricelist(
baseUrl: string,
authinfo: string,
opts: { plid?: string; period?: string; datacenter?: string } = {},
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
const params: Record<string, string> = {
plid: opts.plid || '',
sfrom: 'ajax',
}
if (opts.period) params.period = opts.period
if (opts.datacenter) params.datacenter = opts.datacenter
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
const tariflist = extractTariflist(data)
const listNode = (data.list as Record<string, unknown>) ?? (data.doc as Record<string, unknown>)?.list
const slist = ((listNode as Record<string, unknown>)?.slist ?? data.slist ?? {}) as Record<string, unknown>
if (tariflist.length === 0) return { tariffItems: [], slist }
const tariffItems = tariflist.map((rawItem) => {
const item = elemToObject(rawItem)
const parsed = parseTariffDesc(item.desc || '')
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
return {
externalId: String(item.pricelist || ''),
name,
desc: descClean,
vcpu: parsed.vcpu,
ramGb: parsed.ramGb,
diskGb: parsed.diskGb,
diskType: parsed.diskType,
virtualization: parsed.virtualization,
channel: parsed.channel,
location: parsed.location,
cpuModel: parsed.cpuModel,
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
price: item.price || '',
}
})
return { tariffItems, slist }
}
export async function fetchVdsOrderPricelistAllDatacenters(
baseUrl: string,
authinfo: string,
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
const slist = initial.slist || {}
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
if (datacenters.length === 0) {
return { tariffItems: initial.tariffItems, slist }
}
const allTariffItems: TariffItem[] = []
for (let i = 0; i < datacenters.length; i++) {
const dc = datacenters[i] as Record<string, unknown>
const dcKey = String(dc.k ?? dc.key ?? '')
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
const { country, location } = parseDatacenterName(dcName)
const result =
i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
for (const t of result.tariffItems) {
allTariffItems.push({
...t,
datacenterKey: dcKey,
datacenterName: dcName,
country,
location: location || dcName,
})
}
}
return { tariffItems: allTariffItems, slist }
}
export async function testConnection(
baseUrl: string,
authinfo: string,
): Promise<{ ok: boolean; error?: string; vdsCount?: number }> {
if (!baseUrl?.trim() || !authinfo?.trim()) {
return { ok: false, error: 'Укажите URL и учётные данные' }
}
try {
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
return { ok: true, vdsCount: items?.length ?? 0 }
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка подключения'
return { ok: false, error: message }
}
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { mapVdsToVps } from './mappers.js'
import { parsePricelist, parseTariffDesc } from './parsers.js'
describe('parsePricelist', () => {
it('parses CPU/RAM/disk from pricelist string', () => {
expect(parsePricelist('KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)')).toEqual({
vcpu: 1,
ramGb: 1,
diskGb: 7,
diskType: 'SSD',
virtualization: 'KVM',
})
})
})
describe('parseTariffDesc', () => {
it('parses Selectel-style HTML description', () => {
const result = parseTariffDesc('Start<br/>Процессор: 2 ядра; Память: 4 GB; Диск: 40 GB NVMe')
expect(result.vcpu).toBe(2)
expect(result.ramGb).toBe(4)
expect(result.diskGb).toBe(40)
expect(result.diskType).toBe('NVMe')
})
})
describe('mapVdsToVps', () => {
it('maps active VDS with monthly cost', () => {
const vps = mapVdsToVps(
{
id: '42',
ip: '203.0.113.10',
domain: 'vps.example.com',
item_status: '2',
cost: '500.00 RUB / Месяц',
pricelist: 'KVM (2 CPU/2048 MB RAM/20 GB NVMe)',
createdate: '2024-01-15',
expiredate: '2025-01-15',
currency_str: 'RUB',
},
'prov-1',
'acc-1',
)
expect(vps.externalId).toBe('42')
expect(vps.ip).toBe('203.0.113.10')
expect(vps.status).toBe('active')
expect(vps.monthlyRate).toBe(500)
expect(vps.vcpu).toBe(2)
})
})
@@ -0,0 +1,244 @@
/**
* BILLmanager API response parsers
*/
type BillmanagerElem = Record<string, unknown> | Array<Record<string, unknown>>
export function extractList(data: Record<string, unknown>, key: string): BillmanagerElem[] {
if (Array.isArray(data.elem)) return data.elem as BillmanagerElem[]
const dataNode = data.data as Record<string, unknown> | undefined
if (dataNode?.elem) {
return Array.isArray(dataNode.elem) ? (dataNode.elem as BillmanagerElem[]) : [dataNode.elem as BillmanagerElem]
}
const doc = (data.doc as Record<string, unknown>) || data
let list = doc[key] as Record<string, unknown> | BillmanagerElem[] | undefined
if (!list) return []
if (Array.isArray(list)) return list
if (list.elem) {
return Array.isArray(list.elem) ? (list.elem as BillmanagerElem[]) : [list.elem as BillmanagerElem]
}
return []
}
export function elemToObject(elem: BillmanagerElem | null | undefined): Record<string, string> {
if (!elem) return {}
if (Array.isArray(elem)) {
const obj: Record<string, string> = {}
for (const e of elem) {
const name = (e.$name || e.name) as string | undefined
const val = e.$t ?? e.$
if (name) {
obj[name] =
typeof val === 'object' && val !== null
? String((val as Record<string, unknown>).$t ?? (val as Record<string, unknown>).$ ?? JSON.stringify(val))
: String(val ?? '')
}
}
return obj
}
return Object.fromEntries(
Object.entries(elem).map(([k, v]) => [k, v == null ? '' : String(v)]),
)
}
export function parsePricelist(pricelist: unknown): {
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
} {
const s = String(pricelist || '')
let vcpu = 0
let ramGb = 0
let diskGb = 0
let diskType = 'NVMe'
let virtualization = 'KVM'
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
else {
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
}
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
if (diskMatch) {
diskGb = parseInt(diskMatch[1], 10) || 0
diskType = diskMatch[2] || 'NVMe'
}
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
return { vcpu, ramGb, diskGb, diskType, virtualization }
}
export function parseTariffDesc(desc: unknown): {
name: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
channel: string
location: string
cpuModel: string
} {
const s = String(desc || '')
let name = ''
let vcpu = 0
let ramGb = 0
let diskGb = 0
let diskType = 'SSD'
let virtualization = 'KVM'
let channel = ''
let location = ''
let cpuModel = ''
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
name = firstLine.replace(/<[^>]+>/g, '').trim()
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
if (ramMbMatch) ramGb = Math.max(0.5, Math.round(((parseInt(ramMbMatch[1], 10) || 0) / 1024) * 10) / 10)
else {
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
}
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
if (diskMatch) {
diskGb = parseInt(diskMatch[1], 10) || 0
diskType = diskMatch[2] || 'SSD'
}
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
if (channelMatch) channel = channelMatch[1]
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
if (!channel) {
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
if (netMatch) channel = `${netMatch[1]} Мбит/с`
}
if (!location) {
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
}
if (!cpuModel) {
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
}
if (!diskType || diskType === 'SSD') {
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
}
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
}
export function parseDatacenterName(dcName: unknown): { country: string; location: string } {
const s = String(dcName || '').trim()
if (!s) return { country: '', location: '' }
const COUNTRY_CODE_MAP: Record<string, string> = {
DE: 'Германия',
FI: 'Финляндия',
RU: 'Россия',
FR: 'Франция',
GB: 'Великобритания',
NL: 'Нидерланды',
US: 'США',
SE: 'Швеция',
NO: 'Норвегия',
BE: 'Бельгия',
CH: 'Швейцария',
CZ: 'Чехия',
CA: 'Канада',
LV: 'Латвия',
LT: 'Литва',
EE: 'Эстония',
PL: 'Польша',
IT: 'Италия',
DK: 'Дания',
AU: 'Австралия',
ES: 'Испания',
SG: 'Сингапур',
}
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
if (codeMatch) {
const code = codeMatch[1]
const loc = codeMatch[2].trim()
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
}
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
if (dcMatch) {
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
}
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
if (commaMatch) {
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
}
const countryOnly = [
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
]
for (const c of countryOnly) {
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
return { country: c, location: rest }
}
}
if (/ММТС|Adman|Москва/i.test(s)) {
return { country: 'Россия', location: s }
}
if (/Европа/i.test(s)) {
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
}
const pipeMatch = s.match(/^([^|]+)\s*\|/)
if (pipeMatch) {
const part = pipeMatch[1].trim()
for (const c of countryOnly) {
if (part.includes(c)) return { country: c, location: '' }
}
return { country: part, location: '' }
}
return { country: s, location: '' }
}
export function extractTariflist(data: Record<string, unknown>): BillmanagerElem[] {
if (!data) return []
const doc = data.doc as Record<string, unknown> | undefined
const listNode = (data.list as Record<string, unknown>) ?? doc?.list ?? doc
if (!listNode || typeof listNode !== 'object') return []
let list = (listNode as Record<string, unknown>).tariflist
?? (listNode as Record<string, unknown>).tarifflist
?? (listNode as Record<string, unknown>).pricelist
if (Array.isArray(list)) return list as BillmanagerElem[]
const elems = (listNode as Record<string, unknown>).elem
if (elems) return Array.isArray(elems) ? (elems as BillmanagerElem[]) : [elems as BillmanagerElem]
return []
}
@@ -0,0 +1,64 @@
/**
* Запуск синхронизации BILLmanager с записью в sync_log
*/
import { eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import type { BillmanagerSyncAccount } from './context.js'
import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js'
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
ok: true
logId: string
}
export async function runBillmanagerAccountSync(
account: BillmanagerSyncAccount,
opts: SyncFromBillmanagerOptions = {},
): Promise<RunBillmanagerAccountSyncResult> {
const db = getDb()
const logId = `sync-${account.id}-${Date.now()}`
db.insert(schema.syncLog)
.values({
id: logId,
accountId: account.id,
startedAt: new Date().toISOString(),
status: 'running',
})
.run()
try {
const result = await syncFromBillmanager(account, opts)
const summaryPayload = {
...(result.syncSummary || {}),
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount ?? 0,
}
db.update(schema.syncLog)
.set({
finishedAt: new Date().toISOString(),
status: 'ok',
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
summary: JSON.stringify(summaryPayload),
})
.where(eq(schema.syncLog.id, logId))
.run()
return { ok: true, logId, ...result }
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
db.update(schema.syncLog)
.set({
finishedAt: new Date().toISOString(),
status: 'error',
error: message,
summary: JSON.stringify({ error: message }),
})
.where(eq(schema.syncLog.id, logId))
.run()
throw err
}
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Sync BILLmanager data into vps-tracker DB (Drizzle / @cfdm/db)
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import type { BillmanagerSyncAccount } from './context.js'
import { mapPaymentToPayment, mapVdsToVps } from './mappers.js'
import {
fetchDashboardInfo,
fetchPayments,
fetchVds,
fetchVdsOrderPricelistAllDatacenters,
type DashboardInfo,
type TariffItem,
} from './operations.js'
export interface SyncFromBillmanagerOptions {
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 SyncFromBillmanagerResult {
vpsCount: number
paymentsCount: number
tariffsCount: number
newTariffs: { name: string; price: string; providerId: string }[]
balance: DashboardInfo | 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 syncFromBillmanager(
account: BillmanagerSyncAccount,
opts: SyncFromBillmanagerOptions = {},
): Promise<SyncFromBillmanagerResult> {
const { skipTariffs = false, skipVpsPayments = false } = opts
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
throw new Error('API URL and credentials are required')
}
const authinfo = apiCredentials.trim()
const db = getDb()
const fetchVpsPayments = !skipVpsPayments
const fetchTariffs = !skipTariffs
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
fetchVpsPayments
? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null)
: null,
fetchTariffs
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err instanceof Error ? err.message : err)
return { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> }
})
: { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> },
])
const { tariffItems = [], slist = {} } = tariffResult || {}
let vpsCount = 0
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
if (fetchVpsPayments) {
for (const item of vdsItems) {
const vps = mapVdsToVps(item, providerId, accountId)
const id = `vps-bm-${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 ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
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, `%bm-${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,
paidUntil,
notes,
userOverrides: '[]',
})
.run()
}
vpsCount++
}
}
let paymentsCount = 0
if (fetchVpsPayments) {
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 paymentItems) {
const payment = mapPaymentToPayment(item, accountId)
if (!payment || payment.amount <= 0) continue
const note = payment.note
if (existingPayments.has(note)) continue
const payId = `pay-bm-${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 (fetchVpsPayments && dashboardInfo) {
db.update(schema.providerAccounts)
.set({
balanceApi: dashboardInfo.balance,
balanceCurrency: dashboardInfo.currency || 'RUB',
balanceUpdatedAt: new Date().toISOString(),
enoughmoneyto: dashboardInfo.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-bm-${accountId}-${t.externalId}-${dcKey}`
: `tariff-bm-${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 || 'SSD',
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 (Object.keys(slist).length > 0) {
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
db.insert(schema.tariffSyncOptions)
.values({
providerAccountId: accountId,
datacenters,
periods,
syncedAt,
})
.onConflictDoUpdate({
target: schema.tariffSyncOptions.providerAccountId,
set: { datacenters, periods, syncedAt },
})
.run()
}
}
if (!fetchVpsPayments) {
syncSummary.tariffsOnly = true
}
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
}
+283
View File
@@ -0,0 +1,283 @@
import { and, desc, eq, sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { billmanagerAccountRowForSync } from './billmanager/context.js'
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
import { sendTelegramMessage } from './telegram.js'
let syncIntervalId: ReturnType<typeof setInterval> | null = null
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
const UPCOMING_DAYS = 7
const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type VpsRow = typeof schema.vps.$inferSelect
type PaymentRow = typeof schema.payments.$inferSelect
type LedgerRow = typeof schema.balanceLedger.$inferSelect
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
const db = getDb()
const rows = db
.all<AccountRow>(sql`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`)
const providers = db.select().from(schema.providers).all()
const providerById = new Map(providers.map((p) => [p.id, p]))
return rows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter((a): a is NonNullable<typeof a> => a != null)
}
function getAccountBalance(
accountId: string,
providerAccounts: AccountRow[],
balanceLedger: LedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
return Number(account.balanceApi)
}
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = rows
.filter((row) => row.direction === 'credit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = rows
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits
}
function getPaidUntilDate(
vps: VpsRow,
providerAccounts: AccountRow[],
payments: PaymentRow[],
balanceLedger: LedgerRow[],
now: Date,
): Date | null {
if (vps.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
let paidUntilFromApi: Date | null = null
if (vps.paidUntil) {
const d = new Date(vps.paidUntil)
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
}
const isPaidUntilNextDay =
paidUntilFromApi &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
const dailyRate = Number(vps.dailyRate || 0)
const monthlyRate = Number(vps.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger)
const activeInAccount = getDb()
.select({ id: schema.vps.id })
.from(schema.vps)
.where(
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
)
.all().length
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
const directPayments = payments
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
const paidUntil = new Date(now)
paidUntil.setDate(paidUntil.getDate() + coveredDays)
return paidUntil
}
async function sendPaymentExpiryNotifications(): Promise<void> {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (
!settings?.notifyPaymentExpiryEnabled ||
!settings.telegramBotToken?.trim() ||
!settings.telegramChatId?.trim()
) {
return
}
const db = getDb()
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const providerAccounts = db.select().from(schema.providerAccounts).all()
const payments = db.select().from(schema.payments).all()
const balanceLedger = db.select().from(schema.balanceLedger).all()
const providers = db.select().from(schema.providers).all()
const now = new Date()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
if (upcoming.length === 0) return
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
const dateStr = paidUntil.toLocaleDateString('ru-RU')
return `${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
text,
settings.telegramMessageThreadId,
)
}
export async function runScheduledSync(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
const accounts = getBillmanagerAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
const token = settings.telegramBotToken?.trim()
const chatId = settings.telegramChatId?.trim()
const canTg = Boolean(token && chatId)
for (const account of accounts) {
try {
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
const s = result.syncSummary
const parts: string[] = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
canTg &&
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
await sendTelegramMessage(token!, chatId!, `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`, settings.telegramMessageThreadId)
}
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
await sendTelegramMessage(token!, chatId!, `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`, settings.telegramMessageThreadId)
}
if (settings.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications()
}
} catch (err) {
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
}
}
export async function runScheduledSyncTariffs(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
const accounts = getBillmanagerAccounts()
const providers = getDb().select().from(schema.providers).all()
for (const account of accounts) {
try {
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (
newTariffs.length > 0 &&
settings.notifyNewTariffsEnabled &&
settings.telegramBotToken?.trim() &&
settings.telegramChatId?.trim()
) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const lines = newTariffs.slice(0, 15).map((t) => `${t.name || '—'}${t.price || '—'}`)
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
settings.telegramMessageThreadId,
)
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
}
}
} catch (err) {
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
}
}
export function startScheduler(): void {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
console.log(
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`,
)
} catch {
// ignore
}
}
export function stopScheduler(): void {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
}
export function restartScheduler(): void {
stopScheduler()
startScheduler()
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Telegram Bot API — отправка уведомлений
*/
export async function sendTelegramMessage(
token: string,
chatIds: string | string[],
text: string,
messageThreadId?: string | number | null,
): Promise<void> {
if (!token?.trim() || !text?.trim()) return
const ids = Array.isArray(chatIds)
? chatIds
: String(chatIds || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) return
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
const payload: Record<string, unknown> = {
text,
parse_mode: 'HTML',
disable_web_page_preview: true,
}
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
for (const chatId of ids) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, chat_id: chatId }),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!data.ok) {
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
}
}
}
+1 -2
View File
@@ -7,8 +7,7 @@ services:
- "3001:3001"
environment:
PORT: "3001"
# RUNTIME: "express" (по умолчанию, legacy) | "fastify" (новый стек)
RUNTIME: "express"
RUNTIME: "fastify"
volumes:
- ./data:/app/data
+35
View File
@@ -0,0 +1,35 @@
import Database from 'better-sqlite3'
import { join } from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const dbPath = join(root, 'data', 'vps-tracker.db')
const db = new Database(dbPath, { readonly: true })
console.log('path:', dbPath)
console.log('integrity_check:', db.pragma('integrity_check', { simple: true }))
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all()
console.log('tables:', tables.map((t) => t.name).join(', '))
for (const t of [
'providers',
'provider_accounts',
'vps',
'payments',
'settings',
'sync_log',
'active_tariffs',
]) {
try {
const r = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get()
console.log(`${t}:`, r.c)
} catch (e) {
console.log(`${t}: ERROR`, e.message)
}
}
db.close()
+23 -2
View File
@@ -1,6 +1,6 @@
import Database from 'better-sqlite3'
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { existsSync, mkdirSync } from 'node:fs'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import * as schema from './schema/index.js'
@@ -17,6 +17,11 @@ export function getDbPath(): string {
export function getDb(): Db {
if (_db) return _db
openDatabase()
return _db!
}
function openDatabase(): void {
const dbPath = getDbPath()
const dir = dirname(dbPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
@@ -24,7 +29,11 @@ export function getDb(): Db {
_sqlite.pragma('journal_mode = WAL')
_sqlite.pragma('foreign_keys = ON')
_db = drizzle(_sqlite, { schema })
return _db
}
export function getSqlite(): Database.Database {
getDb()
return _sqlite!
}
export function closeDb(): void {
@@ -35,4 +44,16 @@ export function closeDb(): void {
}
}
/** Заменить файл SQLite и переоткрыть соединение. */
export function reloadDatabaseFromBuffer(buffer: Buffer): void {
closeDb()
writeFileSync(getDbPath(), buffer)
openDatabase()
}
export { schema }
export {
consolidateAllProviderApiSources,
consolidateProviderApiFromAccounts,
heuristicBillmanagerProviderApi,
} from './maintenance/consolidate-api.js'
@@ -0,0 +1,83 @@
import type Database from 'better-sqlite3'
function tryBillmgrUrl(raw: unknown): string {
const t = String(raw || '').trim()
if (!t || !/^https?:\/\//i.test(t) || !/billmgr/i.test(t)) return ''
return t.replace(/\/+$/, '')
}
export function consolidateProviderApiFromAccounts(sqlite: Database.Database): void {
const provRows = sqlite.prepare('SELECT id, apiType, apiBaseUrl FROM providers').all() as {
id: string
apiType: string | null
apiBaseUrl: string | null
}[]
const accStmt = sqlite.prepare(
`SELECT apiBaseUrl FROM provider_accounts
WHERE providerId = ?
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
AND (
lower(trim(COALESCE(apiType, ''))) = 'billmanager'
OR instr(lower(trim(COALESCE(apiBaseUrl, ''))), 'billmgr') > 0
)
ORDER BY id`,
)
const updateProv = sqlite.prepare('UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?')
const clearAcc = sqlite.prepare(
`UPDATE provider_accounts SET apiType = '', apiBaseUrl = ''
WHERE providerId = ?
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
AND (
lower(trim(COALESCE(apiType, ''))) = 'billmanager'
OR instr(lower(trim(COALESCE(apiBaseUrl, ''))), 'billmgr') > 0
)`,
)
for (const prov of provRows) {
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) continue
const accRows = accStmt.all(prov.id) as { apiBaseUrl: string | null }[]
if (!accRows.length) continue
const urls = [...new Set(accRows.map((r) => String(r.apiBaseUrl || '').trim()).filter(Boolean))]
if (urls.length > 1) {
console.warn(
`[vps-tracker] У хостера ${prov.id} у нескольких аккаунтов разный URL BILLmanager — в настройках хостера взят первый.`,
)
}
updateProv.run('billmanager', urls[0], prov.id)
clearAcc.run(prov.id)
}
}
export function heuristicBillmanagerProviderApi(sqlite: Database.Database): void {
const provRows = sqlite.prepare('SELECT id, website, apiType, apiBaseUrl FROM providers').all() as {
id: string
website: string | null
apiType: string | null
apiBaseUrl: string | null
}[]
const accStmt = sqlite.prepare(
`SELECT panelUrl FROM provider_accounts
WHERE providerId = ? AND length(trim(COALESCE(panelUrl, ''))) > 0
ORDER BY id`,
)
const updateProv = sqlite.prepare('UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?')
for (const prov of provRows) {
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) continue
let url = tryBillmgrUrl(prov.website)
if (!url) {
const accRows = accStmt.all(prov.id) as { panelUrl: string | null }[]
for (const row of accRows) {
url = tryBillmgrUrl(row.panelUrl)
if (url) break
}
}
if (url) updateProv.run('billmanager', url, prov.id)
}
}
export function consolidateAllProviderApiSources(sqlite: Database.Database): void {
consolidateProviderApiFromAccounts(sqlite)
heuristicBillmanagerProviderApi(sqlite)
}