init commit

This commit is contained in:
Denozordec
2026-03-17 23:19:28 +07:00
commit 221edcda16
58 changed files with 11938 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
/**
* BILLmanager 6 API HTTP client
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
*/
/**
* @param {string} baseUrl - e.g. https://bill.example.com:1500/billmgr
* @param {string} authinfo - username:password
* @param {string} func - API function name (vds, payment, dedic)
* @param {Record<string, string>} [params] - additional query params
* @returns {Promise<any>}
*/
export async function billmanagerRequest(baseUrl, authinfo, func, params = {}) {
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()
if (data.error) {
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
}
return data
}
+9
View File
@@ -0,0 +1,9 @@
/**
* BILLmanager 6 API adapter
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/virtual-private-servers-vds
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment
*/
export { testConnection, fetchVds, fetchDashboardInfo, fetchPayments, fetchVdsOrderPricelist, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
export { syncFromBillmanager } from './sync.js'
+94
View File
@@ -0,0 +1,94 @@
/**
* BILLmanager API response → vps-tracker model mappers
*/
import { parsePricelist } from './parsers.js'
/** BILLmanager VDS status: 1=ordered, 2=active, 3=suspended, 4=deleted, 5=processing */
const VDS_STATUS_MAP = {
1: 'active',
2: 'active',
3: 'paused',
4: 'archived',
5: 'active',
}
/**
* @param {object} item - BILLmanager vds item
* @returns {object} vps-tracker vps shape
*/
export function mapVdsToVps(item, providerId, providerAccountId) {
const status = VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
// cost: "100.00 RUB / Месяц" — приоритет для ежемесячного платежа; item_cost — запасной
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}`,
}
}
/**
* @param {object} item - BILLmanager payment item
* @returns {object|null} vps-tracker payment shape or null if not credited
*/
export function mapPaymentToPayment(item, providerAccountId) {
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)
// status_orig / real_status = "4" (credited), item.status может быть "Зачислен"
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}`,
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
*/
import { billmanagerRequest } from './client.js'
import { extractList, elemToObject, parseTariffDesc, parseDatacenterName, extractTariflist } from './parsers.js'
/**
* @param {string} baseUrl
* @param {string} authinfo
* @returns {Promise<object[]>} list of vps objects (raw BILLmanager format)
*/
export async function fetchVds(baseUrl, authinfo) {
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
const elems = extractList(data, 'vds')
return elems.map((e) => elemToObject(e))
}
/**
* Получить текущий баланс аккаунта (dashboard.info)
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
* @param {string} authinfo - username:password
* @param {object} [opts] - { fallbackCurrency } — валюта аккаунта, если API не возвращает
* @returns {Promise<{ balance: number, currency: string, enoughmoneyto?: string, realbalance?: string }>}
*/
export async function fetchDashboardInfo(baseUrl, authinfo, opts = {}) {
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
dashboard: 'info',
sfrom: 'ajax',
})
const elems = extractList(data, 'dashboard') || (Array.isArray(data.elem) ? data.elem : [])
const item = elems.length > 0 ? elemToObject(elems[0]) : {}
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 || '',
}
}
/**
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
* @param {string} authinfo - username:password
* @param {object} [opts] - { createdatestart, createdateend } — опционально, не все API поддерживают
* @returns {Promise<object[]>} list of payment objects
*/
export async function fetchPayments(baseUrl, authinfo, opts = {}) {
const params = {}
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))
}
/**
* Получить список активных тарифов для заказа VDS (vds.order) для одного датацентра
* @param {string} baseUrl
* @param {string} authinfo
* @param {object} [opts] - { plid, period, datacenter }
* @returns {Promise<{ tariffItems: object[], slist: object }>}
*/
export async function fetchVdsOrderPricelist(baseUrl, authinfo, opts = {}) {
const params = {
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 slist = data?.list?.slist ?? data?.slist ?? {}
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 }
}
/**
* Получить тарифы по всем датацентрам — отдельный запрос на каждый ДЦ.
* @param {string} baseUrl
* @param {string} authinfo
* @returns {Promise<{ tariffItems: object[], slist: object }>}
*/
export async function fetchVdsOrderPricelistAllDatacenters(baseUrl, authinfo) {
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 = []
for (let i = 0; i < datacenters.length; i++) {
const dc = datacenters[i]
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 }
}
/**
* Test API connection — запрос списка VDS
* @param {string} baseUrl
* @param {string} authinfo - username:password
* @returns {Promise<{ ok: boolean, error?: string, vdsCount?: number }>}
*/
export async function testConnection(baseUrl, authinfo) {
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) {
return { ok: false, error: err.message || 'Ошибка подключения' }
}
}
+252
View File
@@ -0,0 +1,252 @@
/**
* BILLmanager API response parsers
*/
/**
* Parse BILLmanager JSON list response.
* bjson: { elem: [ {...}, {...} ] } или { data: { elem: [...] } }
* xml/json: { doc: { vds: { elem: [...] } } }
*/
export function extractList(data, key) {
if (Array.isArray(data.elem)) return data.elem
if (data.data?.elem) return Array.isArray(data.data.elem) ? data.data.elem : [data.data.elem]
const doc = data.doc || data
let list = doc[key]
if (!list) return []
if (Array.isArray(list)) return list
if (list.elem) {
const elems = Array.isArray(list.elem) ? list.elem : [list.elem]
return elems
}
return []
}
/**
* Extract flat object from BILLmanager elem (can be array of { $name, $t } or already flat object)
*/
export function elemToObject(elem) {
if (!elem) return {}
if (Array.isArray(elem)) {
const obj = {}
for (const e of elem) {
const name = e.$name || e.name
const val = e.$t ?? e.$ ?? e
if (name) obj[name] = typeof val === 'object' && val !== null ? (val.$t ?? val.$ ?? JSON.stringify(val)) : val
}
return obj
}
return typeof elem === 'object' ? elem : {}
}
/**
* Parse pricelist string: "KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)"
* @returns {{ vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string }}
*/
export function parsePricelist(pricelist) {
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 }
}
/**
* Парсит описание тарифа из vds.order.pricelist (desc)
* Поддерживает два формата: текстовый (Firstbyte) и HTML (Selectel и др.)
* @param {string} desc - HTML или текстовое описание тарифа
* @returns {{ name: string, vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string, channel: string, location: string, cpuModel: string }}
*/
export function parseTariffDesc(desc) {
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()
// Формат 1: "Процессор: 1 ядро", "Память: 1 GB", "Диск: 20 GB SSD", "Канал: 200Mb/s"
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'
// Формат 2 (HTML): "Публичная сеть: 250 Мбит/с", "Локация: Москва, Россия", "Процессор: Ryzen 7 5800X", "NVMe накопитель"
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 }
}
/**
* Парсит название датацентра для извлечения страны и локации
* @param {string} dcName - название ДЦ, напр. "1 Датацентр Россия, Москва", "[DE] Франкфурт", "Франция"
* @returns {{ country: string, location: string }}
*/
export function parseDatacenterName(dcName) {
const s = String(dcName || '').trim()
if (!s) return { country: '', location: '' }
const COUNTRY_CODE_MAP = {
DE: 'Германия',
FI: 'Финляндия',
RU: 'Россия',
FR: 'Франция',
GB: 'Великобритания',
NL: 'Нидерланды',
US: 'США',
SE: 'Швеция',
NO: 'Норвегия',
BE: 'Бельгия',
CH: 'Швейцария',
CZ: 'Чехия',
CA: 'Канада',
LV: 'Латвия',
LT: 'Литва',
EE: 'Эстония',
PL: 'Польша',
IT: 'Италия',
DK: 'Дания',
AU: 'Австралия',
ES: 'Испания',
SG: 'Сингапур',
}
// "[DE] Франкфурт | AMD EPYC" -> country: DE/Германия, location: Франкфурт
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 }
}
// "N Датацентр Страна, Город" или "Датацентр Страна, Город"
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 }
}
}
// "ММТС-9", "Adman", "Европа DC1", "Москва DC3" — по ключевым словам
if (/ММТС|Adman|Москва/i.test(s)) {
return { country: 'Россия', location: s }
}
if (/Европа/i.test(s)) {
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
}
// "США | Ryzen 9 9950X" — страна до |
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: '' }
}
/**
* Извлечь tariflist из ответа vds.order (поддержка разных форматов BILLmanager)
* @param {object} data - сырой ответ API
* @returns {Array}
*/
export function extractTariflist(data) {
if (!data) return []
const listNode = data.list ?? data.doc?.list ?? data.doc
if (!listNode) return []
// tariflist / tarifflist / pricelist — разные варианты названия
let list = listNode.tariflist ?? listNode.tarifflist ?? listNode.pricelist
if (Array.isArray(list)) return list
// elem-формат (как в vds, payment)
const elems = listNode.elem
if (elems) return Array.isArray(elems) ? elems : [elems]
return []
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Sync BILLmanager data into vps-tracker DB
*/
import { fetchVds, fetchPayments, fetchDashboardInfo, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
/**
* Sync BILLmanager data into vps-tracker DB
* @param {object} account - provider_account with apiBaseUrl, apiCredentials
* @param {object} db - getDb() wrapper
* @param {object} [opts] - { paymentDaysBack }
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
*/
export async function syncFromBillmanager(account, db, _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 [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
fetchVds(apiBaseUrl, authinfo),
fetchPayments(apiBaseUrl, authinfo, {}),
fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null),
fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message)
return { tariffItems: [], slist: {} }
}),
])
const { tariffItems = [], slist = {} } = tariffResult || {}
let vpsCount = 0
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
WHERE id=?`
const SYNC_UPDATE_FIELDS = ['country', 'city', 'datacenter', 'os', 'notes', 'status', 'tariffType', 'currency', 'dailyRate', 'monthlyRate', 'paidUntil']
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.prepare('SELECT * FROM vps WHERE providerAccountId = ? AND (ip = ? OR notes LIKE ?)').get(accountId, vps.ip, `%bm-${vps.externalId}%`)
if (existing) {
let userOverrides = []
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]
}
}
db.run(vpsUpdateSql,
merged.ip,
merged.ipv6,
merged.additionalIps,
merged.dns,
merged.country,
merged.city,
merged.datacenter,
merged.os,
merged.status,
merged.tariffType,
merged.currency,
merged.dailyRate,
merged.monthlyRate,
merged.paidUntil,
merged.notes,
existing.id,
)
} else {
db.run(vpsInsertSql,
id,
vps.ip,
vps.ipv6,
additionalIps,
vps.dns,
vps.providerId,
vps.providerAccountId,
vps.country,
vps.city,
vps.datacenter,
vps.os,
vps.vcpu,
vps.ramGb,
vps.diskGb,
vps.diskType,
vps.virtualization,
vps.bandwidthTb,
vps.sshPort,
vps.rootUser,
vps.purpose,
vps.environment,
vps.project,
vps.monitoringEnabled ? 1 : 0,
vps.backupEnabled ? 1 : 0,
vps.status,
vps.tariffType,
vps.currency,
dailyRate,
monthlyRate,
vps.createdAt,
paidUntil,
notes,
)
}
vpsCount++
}
let paymentsCount = 0
const existingPayments = new Set(
db.prepare('SELECT note FROM payments WHERE providerAccountId = ?').all(accountId).map((r) => r.note),
)
const paymentInsertSql = `INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
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 id = `pay-bm-${accountId}-${payment.externalId}`
db.run(paymentInsertSql, id, payment.type, payment.date, payment.amount, payment.currency, payment.providerAccountId, payment.vpsId, note)
existingPayments.add(note)
paymentsCount++
}
if (dashboardInfo) {
db.run(
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
dashboardInfo.balance,
dashboardInfo.currency || 'RUB',
new Date().toISOString(),
dashboardInfo.enoughmoneyto || '',
accountId,
)
}
let tariffsCount = 0
const syncedAt = new Date().toISOString()
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}`
db.run(tariffInsertSql,
id,
accountId,
providerId,
t.externalId,
dcKey,
dcName,
t.name || '',
t.desc || '',
t.vcpu || 0,
t.ramGb || 0,
t.diskGb || 0,
t.diskType || 'SSD',
t.virtualization || 'KVM',
t.channel || '',
t.location || '',
t.country || '',
t.cpuModel || '',
t.orderAvailable ? 1 : 0,
t.price || '',
syncedAt,
)
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.run(
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
accountId,
datacenters,
periods,
syncedAt,
)
}
return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo }
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Re-export from db module
*/
export { initDb, getDb, saveDb } from './db/index.js'
+109
View File
@@ -0,0 +1,109 @@
/**
* Database initialization and access
*/
import initSqlJs from 'sql.js'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { SCHEMA } from './schema.js'
import { MIGRATIONS } from './migrations.js'
import { seed, isDbEmpty } from './seed.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
// db/ is in server/, so .. = server, .. again = project root
const DB_PATH = join(__dirname, '..', '..', 'data', 'vps-tracker.db')
const SEED_DIR = join(__dirname, '..', '..', 'public', 'data')
let dbInstance = null
export async function initDb() {
const dataDir = join(__dirname, '..', '..', 'data')
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true })
}
const SQL = await initSqlJs()
let db
if (existsSync(DB_PATH)) {
const fileBuffer = readFileSync(DB_PATH)
db = new SQL.Database(fileBuffer)
} else {
db = new SQL.Database()
}
db.exec(SCHEMA)
for (const m of MIGRATIONS) {
try {
m.run(db)
} catch (err) {
console.warn(`Migration ${m.name} failed:`, err.message)
}
}
if (isDbEmpty(db)) {
seed(db, SEED_DIR)
const data = db.export()
writeFileSync(DB_PATH, Buffer.from(data))
}
dbInstance = db
return db
}
export function getDb() {
if (!dbInstance) throw new Error('Database not initialized')
return createDbWrapper(dbInstance)
}
function createDbWrapper(db) {
return {
/** One-off run: prepare, bind, step, free. Use for statements that run once. */
run(sql, ...params) {
const stmt = db.prepare(sql)
stmt.bind(params)
stmt.step()
const changes = db.getRowsModified()
stmt.free()
saveDb()
return { changes }
},
prepare(sql) {
const stmt = db.prepare(sql)
return {
all(...params) {
stmt.bind(params)
const rows = []
while (stmt.step()) {
rows.push(stmt.getAsObject())
}
stmt.free()
return rows
},
get(...params) {
stmt.bind(params)
const row = stmt.step() ? stmt.getAsObject() : null
stmt.free()
return row
},
run(...params) {
stmt.bind(params)
stmt.step()
const changes = db.getRowsModified()
stmt.free()
saveDb()
return { changes }
},
}
},
}
}
export function saveDb() {
if (!dbInstance) return
const data = dbInstance.export()
writeFileSync(DB_PATH, Buffer.from(data))
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Database migrations — add columns to existing tables
*/
export const MIGRATIONS = [
{
name: 'provider_accounts_api',
run(db) {
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiType TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiBaseUrl TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiCredentials TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'settings_sync',
run(db) {
try {
db.exec('ALTER TABLE settings ADD COLUMN syncEnabled INTEGER')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN syncIntervalMinutes INTEGER')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'vps_paidUntil',
run(db) {
try {
db.exec('ALTER TABLE vps ADD COLUMN paidUntil TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'provider_accounts_balance_api',
run(db) {
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_api REAL')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_currency TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_updated_at TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE provider_accounts ADD COLUMN enoughmoneyto TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'vps_userOverrides',
run(db) {
try {
db.exec('ALTER TABLE vps ADD COLUMN userOverrides TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'active_tariffs_location_cpu',
run(db) {
try {
db.exec('ALTER TABLE active_tariffs ADD COLUMN location TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE active_tariffs ADD COLUMN cpuModel TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'active_tariffs_country_datacenter',
run(db) {
try {
db.exec('ALTER TABLE active_tariffs ADD COLUMN country TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE active_tariffs ADD COLUMN datacenterKey TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE active_tariffs ADD COLUMN datacenterName TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
]
+150
View File
@@ -0,0 +1,150 @@
/**
* SQLite schema for vps-tracker
*/
export const SCHEMA = `
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
website TEXT,
contact TEXT,
baseCurrency TEXT,
usdRate TEXT,
eurRate TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS provider_accounts (
id TEXT PRIMARY KEY,
providerId TEXT NOT NULL,
name TEXT NOT NULL,
panelUrl TEXT,
currency TEXT,
billingMode TEXT,
notes TEXT,
apiType TEXT,
apiBaseUrl TEXT,
apiCredentials TEXT,
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
ip TEXT,
ipv6 TEXT,
additionalIps TEXT,
dns TEXT,
providerId TEXT,
providerAccountId TEXT,
country TEXT,
city TEXT,
datacenter TEXT,
os TEXT,
vcpu INTEGER,
ramGb INTEGER,
diskGb INTEGER,
diskType TEXT,
virtualization TEXT,
bandwidthTb INTEGER,
sshPort INTEGER,
rootUser TEXT,
purpose TEXT,
environment TEXT,
project TEXT,
monitoringEnabled INTEGER,
backupEnabled INTEGER,
status TEXT,
tariffType TEXT,
currency TEXT,
dailyRate REAL,
monthlyRate REAL,
createdAt TEXT,
paidUntil TEXT,
notes TEXT,
userOverrides TEXT,
FOREIGN KEY (providerId) REFERENCES providers(id),
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
providerAccountId TEXT,
vpsId TEXT,
note TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (vpsId) REFERENCES vps(id)
);
CREATE TABLE IF NOT EXISTS balance_ledger (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
direction TEXT,
providerAccountId TEXT,
vpsId TEXT,
note TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (vpsId) REFERENCES vps(id)
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
baseCurrency TEXT,
ratesUrl TEXT,
autoConvert INTEGER,
ratesUpdatedAt TEXT,
syncEnabled INTEGER,
syncIntervalMinutes INTEGER
);
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
accountId TEXT NOT NULL,
startedAt TEXT NOT NULL,
finishedAt TEXT,
status TEXT,
vpsCount INTEGER,
paymentsCount INTEGER,
error TEXT,
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS active_tariffs (
id TEXT PRIMARY KEY,
providerAccountId TEXT NOT NULL,
providerId 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 (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS tariff_sync_options (
providerAccountId TEXT PRIMARY KEY,
datacenters TEXT,
periods TEXT,
syncedAt TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
`
+124
View File
@@ -0,0 +1,124 @@
/**
* Seed database with initial data from public/data/*.json
*/
import { readFileSync, existsSync } from 'fs'
import { join } from 'path'
/**
* @param {string} path - path to JSON file
* @returns {Array}
*/
function loadJson(path) {
if (!existsSync(path)) return []
const raw = readFileSync(path, 'utf-8')
try {
const data = JSON.parse(raw)
return Array.isArray(data) ? data : [data]
} catch {
return []
}
}
/**
* @param {object} db - raw sql.js database (not wrapper)
* @param {string} seedDir - path to public/data
*/
export function seed(db, seedDir) {
const providers = loadJson(join(seedDir, 'providers.json'))
if (providers.length === 0) return
const run = db.run.bind(db)
for (const r of providers) {
run(
'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[r.id, r.name ?? '', r.website ?? '', r.contact ?? '', r.baseCurrency ?? '', r.usdRate ?? '', r.eurRate ?? '', r.notes ?? ''],
)
}
const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json'))
for (const r of providerAccounts) {
run(
'INSERT OR IGNORE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[r.id, r.providerId ?? '', r.name ?? '', r.panelUrl ?? '', r.currency ?? '', r.billingMode ?? '', r.notes ?? '', r.apiType ?? '', r.apiBaseUrl ?? '', r.apiCredentials ?? ''],
)
}
const vpsList = loadJson(join(seedDir, 'vps.json'))
for (const r of vpsList) {
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
run(
`INSERT OR IGNORE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
r.id,
r.ip ?? '',
r.ipv6 ?? '',
additionalIps,
r.dns ?? '',
r.providerId ?? '',
r.providerAccountId ?? '',
r.country ?? '',
r.city ?? '',
r.datacenter ?? '',
r.os ?? '',
r.vcpu ?? 0,
r.ramGb ?? 0,
r.diskGb ?? 0,
r.diskType ?? '',
r.virtualization ?? '',
r.bandwidthTb ?? 0,
r.sshPort ?? 22,
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
r.project ?? '',
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
r.tariffType ?? '',
r.currency ?? '',
dailyRate,
monthlyRate,
r.createdAt ?? '',
r.paidUntil ?? '',
r.notes ?? '',
],
)
}
const payments = loadJson(join(seedDir, 'payments.json'))
for (const r of payments) {
run(
'INSERT OR IGNORE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[r.id, r.type ?? '', r.date ?? '', Number(r.amount) || 0, r.currency ?? '', r.providerAccountId ?? '', r.vpsId ?? '', r.note ?? ''],
)
}
const ledger = loadJson(join(seedDir, 'balance-ledger.json'))
for (const r of ledger) {
run(
'INSERT OR IGNORE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[r.id, r.type ?? '', r.date ?? '', Number(r.amount) || 0, r.currency ?? '', r.direction ?? '', r.providerAccountId ?? '', r.vpsId ?? '', r.note ?? ''],
)
}
const settingsList = loadJson(join(seedDir, 'settings.json'))
for (const r of settingsList) {
run(
'INSERT OR IGNORE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)',
[r.id ?? 'settings-main', r.baseCurrency ?? 'RUB', r.ratesUrl ?? '', r.autoConvert !== false ? 1 : 0, r.ratesUpdatedAt ?? '', r.syncEnabled ? 1 : 0, r.syncIntervalMinutes ?? 60],
)
}
}
/**
* @param {object} db - raw sql.js database
* @returns {boolean}
*/
export function isDbEmpty(db) {
const result = db.exec('SELECT COUNT(*) as c FROM providers')
if (!result.length || !result[0].values.length) return true
return result[0].values[0][0] === 0
}
+39
View File
@@ -0,0 +1,39 @@
import express from 'express'
import cors from 'cors'
import { initDb } from './db.js'
import dataRouter from './routes/data.js'
import migrateRouter from './routes/migrate.js'
import vpsRouter from './routes/vps.js'
import providersRouter from './routes/providers.js'
import providerAccountsRouter from './routes/provider-accounts.js'
import paymentsRouter from './routes/payments.js'
import balanceLedgerRouter from './routes/balance-ledger.js'
import settingsRouter from './routes/settings.js'
import syncRouter from './routes/sync.js'
const app = express()
const PORT = process.env.PORT || 3001
app.use(cors())
app.use(express.json())
;(async () => {
await initDb()
app.use('/api/data', dataRouter)
app.use('/api/migrate', migrateRouter)
app.use('/api/vps', vpsRouter)
app.use('/api/providers', providersRouter)
app.use('/api/provider-accounts', providerAccountsRouter)
app.use('/api/payments', paymentsRouter)
app.use('/api/balance-ledger', balanceLedgerRouter)
app.use('/api/settings', settingsRouter)
app.use('/api/sync', syncRouter)
const { startScheduler } = await import('./sync-scheduler.js')
startScheduler()
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`)
})
})()
+82
View File
@@ -0,0 +1,82 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `ledger-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.direction ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
)
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
res.status(201).json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
db.prepare(`
UPDATE balance_ledger SET
type = ?, date = ?, amount = ?, currency = ?, direction = ?, providerAccountId = ?, vpsId = ?, note = ?
WHERE id = ?
`).run(
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.direction ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
id,
)
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM balance_ledger WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+36
View File
@@ -0,0 +1,36 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { rowToVps } from './vps.js'
import { rowToSettings } from './settings.js'
import { sanitizeAccount, rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
const settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
res.json({
vps: vps.map(rowToVps),
providers,
providerAccounts: providerAccounts.map(sanitizeAccount),
payments,
balanceLedger,
settings: settingsRows.map(rowToSettings),
activeTariffs: activeTariffs.map(rowToActiveTariff),
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
})
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express'
import { getDb, saveDb } from '../db.js'
const router = Router()
router.post('/', (req, res) => {
try {
const db = getDb()
const data = req.body
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: 'Invalid payload' })
}
const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
if (Array.isArray(data.providers) && data.providers.length > 0) {
const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.providers) {
const p = typeof r === 'object' ? r : {}
db.run(sql, p.id ?? '', p.name ?? '', p.website ?? '', p.contact ?? '', p.baseCurrency ?? '', p.usdRate ?? '', p.eurRate ?? '', p.notes ?? '')
}
}
if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) {
const sql = `INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.providerAccounts) {
const acc = typeof r === 'object' ? r : {}
db.run(sql, acc.id ?? '', acc.providerId ?? '', acc.name ?? '', acc.panelUrl ?? '', acc.currency ?? '', acc.billingMode ?? '', acc.notes ?? '', acc.apiType ?? '', acc.apiBaseUrl ?? '', acc.apiCredentials ?? '')
}
}
if (Array.isArray(data.vps) && data.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, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.vps) {
const v = typeof r === 'object' ? r : {}
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) : (v.userOverrides ?? '')
db.run(sql, 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.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(data.payments) && data.payments.length > 0) {
const sql = `INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.payments) {
const pm = typeof r === 'object' ? r : {}
db.run(sql, pm.id ?? '', pm.type ?? '', pm.date ?? '', Number(pm.amount) || 0, pm.currency ?? '', pm.providerAccountId ?? '', pm.vpsId ?? '', pm.note ?? '')
}
}
if (Array.isArray(data.balanceLedger) && data.balanceLedger.length > 0) {
const sql = `INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.balanceLedger) {
const bl = typeof r === 'object' ? r : {}
db.run(sql, bl.id ?? '', bl.type ?? '', bl.date ?? '', Number(bl.amount) || 0, bl.currency ?? '', bl.direction ?? '', bl.providerAccountId ?? '', bl.vpsId ?? '', bl.note ?? '')
}
}
if (settingsList.length > 0) {
const sql = `INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`
for (const r of settingsList) {
const s = typeof r === 'object' ? r : {}
db.run(sql, s.id ?? 'settings-main', s.baseCurrency ?? 'RUB', s.ratesUrl ?? '', s.autoConvert !== false ? 1 : 0, s.ratesUpdatedAt ?? '', s.syncEnabled ? 1 : 0, s.syncIntervalMinutes ?? 60)
}
}
saveDb()
res.json({ ok: true })
} catch (err) {
console.error('Migrate error:', err)
res.status(500).json({ error: err.message || 'Migration failed' })
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `pay-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
)
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
res.status(201).json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
db.prepare(`
UPDATE payments SET
type = ?, date = ?, amount = ?, currency = ?, providerAccountId = ?, vpsId = ?, note = ?
WHERE id = ?
`).run(
r.type ?? '',
r.date ?? '',
Number(r.amount) || 0,
r.currency ?? '',
r.providerAccountId ?? '',
r.vpsId ?? '',
r.note ?? '',
id,
)
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM payments WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+94
View File
@@ -0,0 +1,94 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
function sanitizeAccount(row) {
if (!row) return row
const { apiCredentials, ...rest } = row
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
}
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
res.json(rows.map(sanitizeAccount))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `account-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.providerId ?? '',
r.name ?? '',
r.panelUrl ?? '',
r.currency ?? '',
r.billingMode ?? '',
r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
r.apiCredentials ?? '',
)
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
res.status(201).json(sanitizeAccount(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' })
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
const apiBaseUrl = r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
db.prepare(`
UPDATE provider_accounts SET
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?, apiType = ?, apiBaseUrl = ?, apiCredentials = ?
WHERE id = ?
`).run(
r.providerId ?? existing.providerId ?? '',
r.name ?? existing.name ?? '',
r.panelUrl ?? existing.panelUrl ?? '',
r.currency ?? existing.currency ?? '',
r.billingMode ?? existing.billingMode ?? '',
r.notes ?? existing.notes ?? '',
apiType,
apiBaseUrl,
apiCredentials,
id,
)
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
res.json(sanitizeAccount(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM provider_accounts WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM providers ORDER BY name').all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(`
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.name ?? '',
r.website ?? '',
r.contact ?? '',
r.baseCurrency ?? '',
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
)
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
res.status(201).json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
db.prepare(`
UPDATE providers SET
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?
WHERE id = ?
`).run(
r.name ?? '',
r.website ?? '',
r.contact ?? '',
r.baseCurrency ?? '',
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
id,
)
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(row)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM providers WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+97
View File
@@ -0,0 +1,97 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { startScheduler } from '../sync-scheduler.js'
const router = Router()
export function rowToSettings(row) {
if (!row) return null
return {
...row,
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
}
}
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM settings ORDER BY id').all()
res.json(rows.map(rowToSettings))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0)
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
if (existing) {
db.prepare(`
UPDATE settings SET
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?
WHERE id = ?
`).run(
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
r.ratesUrl ?? existing.ratesUrl ?? '',
r.autoConvert !== undefined ? (r.autoConvert !== false ? 1 : 0) : (existing.autoConvert ? 1 : 0),
r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '',
syncEnabled,
syncIntervalMinutes,
id,
)
} else {
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
r.ratesUrl ?? '',
r.autoConvert !== false ? 1 : 0,
r.ratesUpdatedAt ?? '',
syncEnabled,
syncIntervalMinutes,
)
}
startScheduler()
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
res.json(rowToSettings(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id ?? 'settings-main'
const syncEnabled = r.syncEnabled ? 1 : 0
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
r.ratesUrl ?? 'https://www.cbr-xml-daily.ru/latest.js',
r.autoConvert !== false ? 1 : 0,
r.ratesUpdatedAt ?? '',
syncEnabled,
syncIntervalMinutes,
)
startScheduler()
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
res.status(201).json(rowToSettings(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+116
View File
@@ -0,0 +1,116 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { syncFromBillmanager, fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
const router = Router()
router.post('/test-connection', async (req, res) => {
try {
const { apiBaseUrl, apiCredentials } = req.body || {}
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
return res.status(400).json({ ok: false, error: 'Укажите URL и учётные данные' })
}
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
res.json(result)
} catch (err) {
res.status(500).json({ ok: false, error: err.message || 'Ошибка проверки' })
}
})
router.get('/status', (req, res) => {
try {
const db = getDb()
const rows = db.prepare(`
SELECT accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error
FROM sync_log ORDER BY startedAt DESC LIMIT 50
`).all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.get('/:accountId/balance', async (req, res) => {
try {
const db = getDb()
const { accountId } = req.params
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
if (!row) {
return res.status(404).json({ error: 'Account not found' })
}
if (row.apiType !== 'billmanager') {
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' })
}
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) {
return res.status(400).json({ error: 'API URL and credentials are required' })
}
const info = await fetchDashboardInfo(row.apiBaseUrl, row.apiCredentials.trim(), { fallbackCurrency: row.currency })
db.run(
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
info.balance,
info.currency || 'RUB',
new Date().toISOString(),
info.enoughmoneyto || '',
accountId,
)
res.json({ ok: true, balance: info })
} catch (err) {
console.error('Balance fetch error:', err)
res.status(500).json({ ok: false, error: err.message || 'Failed to fetch balance' })
}
})
router.post('/:accountId', async (req, res) => {
try {
const db = getDb()
const { accountId } = req.params
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
if (!row) {
return res.status(404).json({ error: 'Account not found' })
}
if (row.apiType !== 'billmanager') {
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' })
}
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) {
return res.status(400).json({ error: 'API URL and credentials are required' })
}
const logId = `sync-${accountId}-${Date.now()}`
db.prepare(`
INSERT INTO sync_log (id, accountId, startedAt, status)
VALUES (?, ?, ?, ?)
`).run(logId, accountId, new Date().toISOString(), 'running')
const result = await syncFromBillmanager(row, db)
db.prepare(`
UPDATE sync_log SET finishedAt=?, status=?, vpsCount=?, paymentsCount=?
WHERE id=?
`).run(new Date().toISOString(), 'ok', result.vpsCount, result.paymentsCount, logId)
res.json({
ok: true,
synced: {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount ?? 0,
},
})
} catch (err) {
console.error('Sync error:', err)
const { accountId } = req.params
const db = getDb()
const logRows = db.prepare('SELECT id FROM sync_log WHERE accountId=? AND status=? ORDER BY startedAt DESC LIMIT 1').all(accountId, 'running')
if (logRows.length > 0) {
db.prepare('UPDATE sync_log SET finishedAt=?, status=?, error=? WHERE id=?').run(
new Date().toISOString(),
'error',
err.message || 'Unknown error',
logRows[0].id,
)
}
res.status(500).json({ ok: false, error: err.message || 'Sync failed' })
}
})
export default router
+197
View File
@@ -0,0 +1,197 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
export function rowToVps(row) {
if (!row) return null
let additionalIps = []
try {
additionalIps = row.additionalIps ? JSON.parse(row.additionalIps) : []
} catch {
additionalIps = []
}
let userOverrides = []
try {
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
} catch {
userOverrides = []
}
return {
...row,
additionalIps,
userOverrides,
monitoringEnabled: Boolean(row.monitoringEnabled),
backupEnabled: Boolean(row.backupEnabled),
dailyRate: row.dailyRate != null ? row.dailyRate : '',
monthlyRate: row.monthlyRate != null ? row.monthlyRate : '',
}
}
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
res.json(rows.map(rowToVps))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const r = req.body
const id = r.id || `vps-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
db.prepare(`
INSERT INTO vps (
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter,
os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser,
purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType,
currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.ip ?? '',
r.ipv6 ?? '',
additionalIps,
r.dns ?? '',
r.providerId ?? '',
r.providerAccountId ?? '',
r.country ?? '',
r.city ?? '',
r.datacenter ?? '',
r.os ?? '',
r.vcpu ?? 0,
r.ramGb ?? 0,
r.diskGb ?? 0,
r.diskType ?? '',
r.virtualization ?? '',
r.bandwidthTb ?? 0,
r.sshPort ?? 22,
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
r.project ?? '',
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
r.tariffType ?? '',
r.currency ?? '',
dailyRate,
monthlyRate,
r.createdAt ?? new Date().toISOString().slice(0, 10),
r.paidUntil ?? '',
r.notes ?? '',
r.userOverrides ? (Array.isArray(r.userOverrides) ? JSON.stringify(r.userOverrides) : r.userOverrides) : '[]',
)
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
res.status(201).json(rowToVps(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
const USER_OVERRIDABLE_FIELDS = ['country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort', 'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled']
router.put('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' })
let userOverrides = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
if (r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)) {
userOverrides = []
} else {
for (const f of USER_OVERRIDABLE_FIELDS) {
const newVal = r[f]
const oldVal = existing[f]
const changed = String(newVal ?? '') !== String(oldVal ?? '')
if (changed && !userOverrides.includes(f)) {
userOverrides.push(f)
}
}
}
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
db.prepare(`
UPDATE vps SET
ip = ?, ipv6 = ?, additionalIps = ?, dns = ?, providerId = ?, providerAccountId = ?,
country = ?, city = ?, datacenter = ?, os = ?, vcpu = ?, ramGb = ?, diskGb = ?, diskType = ?,
virtualization = ?, bandwidthTb = ?, sshPort = ?, rootUser = ?, purpose = ?, environment = ?,
project = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
currency = ?, dailyRate = ?, monthlyRate = ?, createdAt = ?, paidUntil = ?, notes = ?,
userOverrides = ?
WHERE id = ?
`).run(
r.ip ?? '',
r.ipv6 ?? '',
additionalIps,
r.dns ?? '',
r.providerId ?? '',
r.providerAccountId ?? '',
r.country ?? '',
r.city ?? '',
r.datacenter ?? '',
r.os ?? '',
r.vcpu ?? 0,
r.ramGb ?? 0,
r.diskGb ?? 0,
r.diskType ?? '',
r.virtualization ?? '',
r.bandwidthTb ?? 0,
r.sshPort ?? 22,
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
r.project ?? '',
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
r.tariffType ?? '',
r.currency ?? '',
dailyRate,
monthlyRate,
r.createdAt ?? '',
r.paidUntil ?? '',
r.notes ?? '',
userOverridesJson,
id,
)
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(rowToVps(row))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.delete('/:id', (req, res) => {
try {
const db = getDb()
const { id } = req.params
const result = db.prepare('DELETE FROM vps WHERE id = ?').run(id)
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
res.status(204).send()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+38
View File
@@ -0,0 +1,38 @@
import { getDb } from './db.js'
import { syncFromBillmanager } from './adapters/billmanager/index.js'
let syncIntervalId = null
export function runScheduledSync() {
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return
const accounts = db.prepare(`
SELECT * FROM provider_accounts
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
`).all()
for (const account of accounts) {
syncFromBillmanager(account, db).catch((err) => {
console.warn(`Sync failed for account ${account.id}:`, err.message)
})
}
} catch (err) {
console.warn('Scheduled sync error:', err.message)
}
}
export function startScheduler() {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000)
console.log(`Scheduled sync enabled: every ${interval} min`)
} catch {
// ignore
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Map database rows to API response format
*/
/**
* @param {object} row - provider_account row
* @returns {object} sanitized (apiCredentials hidden)
*/
export function sanitizeAccount(row) {
if (!row) return row
const { apiCredentials, ...rest } = row
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
}
/**
* @param {object} row - active_tariffs row
* @returns {object|null}
*/
export function rowToActiveTariff(row) {
if (!row) return null
return {
...row,
orderAvailable: Boolean(row.orderAvailable),
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
}
}
/**
* @param {object} row - tariff_sync_options row
* @returns {object|null}
*/
export function rowToTariffSyncOptions(row) {
if (!row) return null
let datacenters = []
let periods = []
try {
datacenters = row.datacenters ? JSON.parse(row.datacenters) : []
} catch {
/* ignore parse error */
}
try {
periods = row.periods ? JSON.parse(row.periods) : []
} catch {
/* ignore parse error */
}
return {
...row,
datacenters: Array.isArray(datacenters) ? datacenters : [],
periods: Array.isArray(periods) ? periods : [],
}
}