feat: BILLmanager API на уровне хостера, миграция со старых аккаунтов
Docker / build (push) Failing after 18s

Made-with: Cursor
This commit is contained in:
Denozordec
2026-03-21 00:52:49 +07:00
parent 2da1f6a6f0
commit 28d723983e
19 changed files with 445 additions and 97 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
/**
* Sync BILLmanager data into vps-tracker DB
* @param {object} account - provider_account with apiBaseUrl, apiCredentials
* @param {object} account - provider_account с apiCredentials и apiBaseUrl (URL с хостера или уже подставленный)
* @param {object} db - getDb() wrapper
* @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments }
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
+70
View File
@@ -4,6 +4,60 @@
import { randomUUID } from 'node:crypto'
/**
* Перенос apiType/apiBaseUrl с аккаунтов на хостера (idempotent).
* Вызывается из миграции и после импорта бэкапа / старого migrate API.
* @param {import('sql.js').Database} db
*/
function selectAllObjects(db, sql, params = []) {
const prepared = db.prepare(sql)
if (typeof prepared.all === 'function') {
return prepared.all(...params)
}
const stmt = prepared
stmt.bind(params)
const rows = []
while (stmt.step()) {
rows.push(stmt.getAsObject())
}
stmt.free()
return rows
}
export function consolidateProviderApiFromAccounts(db) {
const provRows = selectAllObjects(db, 'SELECT id, apiType, apiBaseUrl FROM providers')
for (const prov of provRows) {
const pid = prov.id
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) {
continue
}
const accRows = selectAllObjects(
db,
`SELECT apiBaseUrl FROM provider_accounts
WHERE providerId = ?
AND lower(trim(COALESCE(apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
ORDER BY id`,
[pid],
)
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] У хостера ${pid} у нескольких аккаунтов разный URL BILLmanager — в настройках хостера взят первый.`,
)
}
const apiBaseUrl = urls[0]
db.run(`UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?`, 'billmanager', apiBaseUrl, pid)
db.run(
`UPDATE provider_accounts SET apiType = '', apiBaseUrl = ''
WHERE providerId = ? AND lower(trim(COALESCE(apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0`,
pid,
)
}
}
export const MIGRATIONS = [
{
name: 'provider_accounts_api',
@@ -266,4 +320,20 @@ export const MIGRATIONS = [
}
},
},
{
name: 'providers_api_integration',
run(db) {
try {
db.exec('ALTER TABLE providers ADD COLUMN apiType TEXT')
} catch (e) {
if (!String(e.message || e).includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE providers ADD COLUMN apiBaseUrl TEXT')
} catch (e) {
if (!String(e.message || e).includes('duplicate column')) throw e
}
consolidateProviderApiFromAccounts(db)
},
},
]
+3 -1
View File
@@ -11,7 +11,9 @@ CREATE TABLE IF NOT EXISTS providers (
baseCurrency TEXT,
usdRate TEXT,
eurRate TEXT,
notes TEXT
notes TEXT,
apiType TEXT,
apiBaseUrl TEXT
);
CREATE TABLE IF NOT EXISTS provider_accounts (
+23 -3
View File
@@ -31,17 +31,37 @@ export function seed(db, seedDir) {
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 ?? ''],
'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
r.id,
r.name ?? '',
r.website ?? '',
r.contact ?? '',
r.baseCurrency ?? '',
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
],
)
}
const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json'))
for (const r of providerAccounts) {
const legacyType = r.apiType ?? ''
const legacyUrl = r.apiBaseUrl ?? ''
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 ?? ''],
[r.id, r.providerId ?? '', r.name ?? '', r.panelUrl ?? '', r.currency ?? '', r.billingMode ?? '', r.notes ?? '', '', '', r.apiCredentials ?? ''],
)
if (legacyType === 'billmanager' && String(legacyUrl).trim()) {
run(
`UPDATE providers SET apiType = 'billmanager', apiBaseUrl = ? WHERE id = ? AND length(trim(COALESCE(apiBaseUrl,''))) = 0`,
String(legacyUrl).trim(),
r.providerId ?? '',
)
}
}
const vpsList = loadJson(join(seedDir, 'vps.json'))
+6 -1
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import express from 'express'
import { readFileSync, existsSync } from 'node:fs'
import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js'
import { consolidateProviderApiFromAccounts } from '../db/migrations.js'
import { rowToVps } from './vps.js'
import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
@@ -111,7 +112,7 @@ function importJsonSnapshot(data) {
const providers = Array.isArray(data.providers) ? data.providers : []
for (const p of providers) {
run(
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.id ?? '',
p.name ?? '',
p.website ?? '',
@@ -120,6 +121,8 @@ function importJsonSnapshot(data) {
p.usdRate ?? '',
p.eurRate ?? '',
p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
)
}
@@ -159,6 +162,8 @@ function importJsonSnapshot(data) {
)
}
consolidateProviderApiFromAccounts(db)
const settingsList = Array.isArray(data.settings) ? data.settings : data.settings ? [data.settings] : []
for (const s of settingsList) {
let customFields = s.customFields
+16 -2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'
import { getDb, saveDb } from '../db.js'
import { consolidateProviderApiFromAccounts } from '../db/migrations.js'
const router = Router()
@@ -14,10 +15,22 @@ router.post('/', (req, res) => {
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 (?, ?, ?, ?, ?, ?, ?, ?)`
const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) 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 ?? '')
db.run(
sql,
p.id ?? '',
p.name ?? '',
p.website ?? '',
p.contact ?? '',
p.baseCurrency ?? '',
p.usdRate ?? '',
p.eurRate ?? '',
p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
)
}
}
if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) {
@@ -59,6 +72,7 @@ router.post('/', (req, res) => {
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)
}
}
consolidateProviderApiFromAccounts(db)
saveDb()
res.json({ ok: true })
+3 -8
View File
@@ -30,7 +30,7 @@ router.post('/', (req, res) => {
: null
db.prepare(`
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, '', '', ?, ?)
`).run(
id,
r.providerId ?? '',
@@ -39,8 +39,6 @@ router.post('/', (req, res) => {
r.currency ?? '',
r.billingMode ?? '',
r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
r.apiCredentials ?? '',
Number.isFinite(alertBelow) ? alertBelow : null,
)
@@ -58,8 +56,6 @@ router.put('/:id', (req, res) => {
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 || '')
let balanceAlertBelow = existing.balance_alert_below
if (r.balance_alert_below !== undefined) {
@@ -73,7 +69,8 @@ router.put('/:id', (req, res) => {
}
db.prepare(`
UPDATE provider_accounts SET
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?, apiType = ?, apiBaseUrl = ?, apiCredentials = ?, balance_alert_below = ?
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?,
apiType = '', apiBaseUrl = '', apiCredentials = ?, balance_alert_below = ?
WHERE id = ?
`).run(
r.providerId ?? existing.providerId ?? '',
@@ -82,8 +79,6 @@ router.put('/:id', (req, res) => {
r.currency ?? existing.currency ?? '',
r.billingMode ?? existing.billingMode ?? '',
r.notes ?? existing.notes ?? '',
apiType,
apiBaseUrl,
apiCredentials,
balanceAlertBelow,
id,
+13 -4
View File
@@ -19,8 +19,8 @@ router.post('/', (req, res) => {
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 (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.name ?? '',
@@ -30,6 +30,8 @@ router.post('/', (req, res) => {
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
)
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
res.status(201).json(row)
@@ -43,9 +45,15 @@ router.put('/:id', (req, res) => {
const db = getDb()
const { id } = req.params
const r = req.body
const existing = db.prepare('SELECT * FROM providers 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 || '')
db.prepare(`
UPDATE providers SET
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?,
apiType = ?, apiBaseUrl = ?
WHERE id = ?
`).run(
r.name ?? '',
@@ -55,10 +63,11 @@ router.put('/:id', (req, res) => {
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
apiType,
apiBaseUrl,
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 })
+23 -12
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { getDb } from '../db.js'
import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
import { runBillmanagerAccountSync } from '../sync-account-job.js'
import { billmanagerAccountRowForSync } from '../utils/billmanager-context.js'
const router = Router()
@@ -51,13 +52,19 @@ router.get('/:accountId/balance', async (req, res) => {
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' })
const provider = row.providerId
? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
: null
const syncRow = billmanagerAccountRowForSync(row, provider)
if (!syncRow) {
return res.status(400).json({
error:
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль 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 })
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, syncRow.apiCredentials.trim(), {
fallbackCurrency: row.currency,
})
db.run(
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
info.balance,
@@ -82,15 +89,19 @@ router.post('/:accountId', async (req, res) => {
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 provider = row.providerId
? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
: null
const syncRow = billmanagerAccountRowForSync(row, provider)
if (!syncRow) {
return res.status(400).json({
error:
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
})
}
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
const result = await runBillmanagerAccountSync(row, opts)
const result = await runBillmanagerAccountSync(syncRow, opts)
res.json({
ok: true,
+22 -6
View File
@@ -1,6 +1,7 @@
import { getDb } from './db.js'
import { runBillmanagerAccountSync } from './sync-account-job.js'
import { sendTelegramMessage } from './telegram.js'
import { billmanagerAccountRowForSync } from './utils/billmanager-context.js'
let syncIntervalId = null
let syncTariffsIntervalId = null
@@ -109,10 +110,18 @@ export async function runScheduledSync() {
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 != ''
const accountRows = db.prepare(`
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
`).all()
const providers = db.prepare('SELECT * FROM providers').all()
const providerById = new Map(providers.map((p) => [p.id, p]))
const accounts = accountRows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter(Boolean)
const digestLines = []
const lowBalanceLines = []
@@ -173,11 +182,18 @@ export async function runScheduledSyncTariffs() {
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 != ''
const accountRows = db.prepare(`
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
`).all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const providerById = new Map(providers.map((p) => [p.id, p]))
const accounts = accountRows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter(Boolean)
for (const account of accounts) {
try {
+29
View File
@@ -0,0 +1,29 @@
/**
* BILLmanager: URL и тип API задаются на хостере (providers), учётные данные — на аккаунте.
* Поддержка fallback на поля аккаунта для старых данных до миграции.
*/
/**
* @param {object|null|undefined} accountRow
* @param {object|null|undefined} providerRow
* @returns {{ apiType: string, apiBaseUrl: string }}
*/
export function resolveBillmanagerApi(accountRow, providerRow) {
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
return { apiType, apiBaseUrl }
}
/**
* Объект аккаунта с подставленным URL для syncFromBillmanager / balance.
* @param {object} accountRow
* @param {object|null|undefined} providerRow
* @returns {object|null} null если не готово к запросам API
*/
export function billmanagerAccountRowForSync(accountRow, providerRow) {
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 }
}