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
+2 -2
View File
@@ -37,8 +37,8 @@ vps-tracker/
| Сущность | Описание |
|----------|----------|
| **providers** | Хостинг-провайдеры (Selectel, Firstbyte и т.д.) |
| **provider_accounts** | Аккаунты у провайдера, могут иметь apiType=billmanager для синка |
| **providers** | Хостинг-провайдеры; для BILLmanager: **apiType**, **apiBaseUrl** (один URL на хостера) |
| **provider_accounts** | Аккаунты у провайдера; **apiCredentials** (логин:пароль API) для синка с BILLmanager |
| **vps** | Виртуальные серверы (ip, ram, disk, tariffType, paidUntil) |
| **payments** | Платежи (пополнение баланса, оплата VPS) |
| **balance_ledger** | Движения по балансу |
+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 }
}
+29
View File
@@ -0,0 +1,29 @@
/**
* BILLmanager: URL на хостере, учётные данные на аккаунте.
*/
export function providerByIdMap(providers) {
return new Map(providers.map((p) => [p.id, p]))
}
/** Аккаунты, для которых можно вызывать синк (есть URL у хостера и креды у аккаунта). */
export function billmanagerSyncableAccounts(providerAccounts, providers) {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
}
/** Показывать кнопки баланса/синка и брать баланс из API. */
export function accountBillmanagerUiReady(account, provider) {
return (
provider?.apiType === 'billmanager' &&
(provider.apiBaseUrl || '').trim() &&
account.apiCredentialsSet
)
}
export function accountUsesBillmanagerBalanceApi(account, provider) {
return provider?.apiType === 'billmanager' && account.balance_api != null
}
+14 -8
View File
@@ -53,13 +53,15 @@ export function lastOkSyncFinishedAt(accountId, syncLog) {
* @param {{
* vps: object[],
* providerAccounts: object[],
* providers: object[],
* payments: object[],
* balanceLedger: object[],
* syncLog?: object[],
* }} input
*/
export function computeInventoryHealth(input) {
const { vps, providerAccounts, payments, balanceLedger, syncLog = [] } = input
const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
const providerById = new Map(providers.map((p) => [p.id, p]))
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
@@ -109,9 +111,10 @@ export function computeInventoryHealth(input) {
})
}
const bmAccounts = providerAccounts.filter(
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet,
)
const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = bmAccounts.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog)
@@ -144,12 +147,15 @@ export function computeInventoryHealth(input) {
/**
* @param {object[]} providerAccounts
* @param {object[]} providers
* @param {object[]} syncLog
*/
export function getStaleSyncAccountIds(providerAccounts, syncLog, now = new Date()) {
const bmAccounts = providerAccounts.filter(
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet,
)
export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) {
const providerById = new Map(providers.map((p) => [p.id, p]))
const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return bmAccounts
.filter((a) => {
+46 -42
View File
@@ -12,6 +12,11 @@ import { ConvertedAmount } from '../components/ConvertedAmount'
import { syncAccount, testApiConnection, fetchAccountBalance, fetchSyncStatus } from '../lib/api'
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
import { getBalanceMismatchAccountIds, getStaleSyncAccountIds } from '../lib/inventory-health'
import {
billmanagerSyncableAccounts,
accountBillmanagerUiReady,
accountUsesBillmanagerBalanceApi,
} from '../lib/billmanager-ui'
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
const emptyForm = {
@@ -21,8 +26,6 @@ const emptyForm = {
currency: 'USD',
billingMode: 'monthly',
notes: '',
apiType: '',
apiBaseUrl: '',
apiLogin: '',
apiPassword: '',
balance_alert_below: '',
@@ -51,8 +54,8 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}, [])
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
[db.providerAccounts],
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
const [testConnectionResult, setTestConnectionResult] = useState(null)
@@ -60,13 +63,13 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
const highlightAccountIds = useMemo(() => {
if (accountsHealth === 'stale-sync') {
return new Set(getStaleSyncAccountIds(db.providerAccounts, syncLog))
return new Set(getStaleSyncAccountIds(db.providerAccounts, db.providers, syncLog))
}
if (accountsHealth === 'balance-mismatch') {
return new Set(getBalanceMismatchAccountIds(db.providerAccounts, db.balanceLedger))
}
return null
}, [accountsHealth, db.providerAccounts, db.balanceLedger, syncLog])
}, [accountsHealth, db.providerAccounts, db.providers, db.balanceLedger, syncLog])
const balances = useMemo(() => {
return db.providerAccounts.map((account) => {
@@ -84,7 +87,8 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
const getBalance = (accountId) => balances.find((item) => item.accountId === accountId)?.balance || 0
const getDisplayBalance = (account) => {
if (account.apiType === 'billmanager' && account.balance_api != null) {
const provider = db.providers.find((p) => p.id === account.providerId)
if (accountUsesBillmanagerBalanceApi(account, provider)) {
return account.balance_api
}
return getBalance(account.id)
@@ -116,10 +120,11 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
currency: form.currency,
billingMode: form.billingMode,
notes: form.notes,
apiType: form.apiType || '',
apiBaseUrl: form.apiType === 'billmanager' ? form.apiBaseUrl : '',
}
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
const selectedProvider = db.providers.find((p) => p.id === form.providerId)
const bmReady =
selectedProvider?.apiType === 'billmanager' && (selectedProvider.apiBaseUrl || '').trim()
if (bmReady && form.apiLogin && form.apiPassword) {
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
}
{
@@ -146,14 +151,19 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}
const onTestConnection = async () => {
if (!form.apiBaseUrl?.trim() || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
setTestConnectionResult({ ok: false, error: 'Заполните URL, логин и пароль' })
const p = db.providers.find((x) => x.id === form.providerId)
const baseUrl = p?.apiType === 'billmanager' ? (p.apiBaseUrl || '').trim() : ''
if (!baseUrl || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
setTestConnectionResult({
ok: false,
error: 'В настройках хостера укажите URL API; здесь — логин и пароль',
})
return
}
setTestConnectionLoading(true)
setTestConnectionResult(null)
try {
const result = await testApiConnection(form.apiBaseUrl, `${form.apiLogin}:${form.apiPassword}`)
const result = await testApiConnection(baseUrl, `${form.apiLogin}:${form.apiPassword}`)
setTestConnectionResult(result)
} catch (err) {
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
@@ -170,8 +180,6 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
currency: account.currency || 'USD',
billingMode: account.billingMode || 'monthly',
notes: account.notes || '',
apiType: account.apiType || '',
apiBaseUrl: account.apiBaseUrl || '',
apiLogin: '',
apiPassword: '',
balance_alert_below:
@@ -185,7 +193,12 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}
const editingAccount = editingId ? db.providerAccounts.find((a) => a.id === editingId) : null
const canTestConnection = form.apiType === 'billmanager' && form.apiBaseUrl?.trim() && form.apiLogin?.trim() && form.apiPassword?.trim()
const formProvider = db.providers.find((p) => p.id === form.providerId)
const formBmUrl =
formProvider?.apiType === 'billmanager' ? (formProvider.apiBaseUrl || '').trim() : ''
const canTestConnection = Boolean(
formBmUrl && form.apiLogin?.trim() && form.apiPassword?.trim(),
)
const onSync = async (accountId) => {
setSyncLoadingId(accountId)
@@ -333,6 +346,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
<tbody>
{db.providerAccounts.map((account) => {
const provider = db.providers.find((item) => item.id === account.providerId)
const bmUi = accountBillmanagerUiReady(account, provider)
const linkedVps = db.vps.filter((item) => item.providerAccountId === account.id)
const rowWarn = highlightAccountIds?.has(account.id)
return (
@@ -374,7 +388,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
</td>
<td className="text-end">
<div className="table-actions d-flex gap-1 flex-wrap justify-content-end">
{account.apiType === 'billmanager' && account.apiBaseUrl ? (
{bmUi ? (
<>
<button
type="button"
@@ -511,32 +525,22 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
</div>
<div className="col-12">
<hr className="my-2" />
<h6 className="text-secondary mb-2">Интеграция API</h6>
</div>
<div className="col-12">
<label className="form-label">Тип API</label>
<select autoComplete="off"
className="form-select"
value={form.apiType}
onChange={(e) => setForm((prev) => ({ ...prev, apiType: e.target.value, apiBaseUrl: '', apiLogin: '', apiPassword: '' }))}
>
<option value=""> Не использовать </option>
<option value="billmanager">BILLmanager</option>
</select>
</div>
{form.apiType === 'billmanager' ? (
<>
<div className="col-12">
<label className="form-label">URL API BILLmanager</label>
<input {...noBrowserSuggestProps}
className="form-control"
placeholder="https://bill.example.com:1500/billmgr"
value={form.apiBaseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, apiBaseUrl: e.target.value }))}
/>
<h6 className="text-secondary mb-2">Учётные данные API BILLmanager</h6>
{formProvider?.apiType === 'billmanager' && (formProvider.apiBaseUrl || '').trim() ? (
<div className="text-secondary small mb-2">
URL: <code className="user-select-all">{formProvider.apiBaseUrl}</code>
</div>
) : (
<div className="alert alert-secondary py-2 small mb-2">
В карточке хостера выберите тип API BILLmanager и укажите URL один на все аккаунты этого
хостера.
</div>
)}
</div>
{formProvider?.apiType === 'billmanager' && (formProvider.apiBaseUrl || '').trim() ? (
<>
<div className="col-12 col-sm-6">
<label className="form-label">Логин</label>
<label className="form-label">Логин API</label>
<input {...noBrowserSuggestProps}
className="form-control"
placeholder="admin"
@@ -545,7 +549,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Пароль</label>
<label className="form-label">Пароль API</label>
<input {...passwordCredentialInputProps}
type="password"
className="form-control"
+2 -1
View File
@@ -204,11 +204,12 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
computeInventoryHealth({
vps,
providerAccounts,
providers,
payments,
balanceLedger,
syncLog: syncLogRows,
}),
[vps, providerAccounts, payments, balanceLedger, syncLogRows],
[vps, providerAccounts, providers, payments, balanceLedger, syncLogRows],
)
const recentSyncFeed = useMemo(() => {
+137 -2
View File
@@ -3,7 +3,9 @@ import { UiModal } from '../components/UiModal'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
import { faviconUrlFromWebsite } from '../lib/utils'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
import { testApiConnection } from '../lib/api'
import { IconPlugConnected } from '@tabler/icons-react'
const emptyForm = {
name: '',
@@ -13,12 +15,18 @@ const emptyForm = {
usdRate: '',
eurRate: '',
notes: '',
apiType: '',
apiBaseUrl: '',
}
export function ProvidersPage({ db, actions }) {
const [form, setForm] = useState(emptyForm)
const [editingId, setEditingId] = useState(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [apiProbeLogin, setApiProbeLogin] = useState('')
const [apiProbePassword, setApiProbePassword] = useState('')
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
const [testConnectionResult, setTestConnectionResult] = useState(null)
const onSubmit = (event) => {
event.preventDefault()
@@ -32,6 +40,9 @@ export function ProvidersPage({ db, actions }) {
}
setForm(emptyForm)
setEditingId(null)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(false)
}
@@ -44,11 +55,39 @@ export function ProvidersPage({ db, actions }) {
usdRate: provider.usdRate || '',
eurRate: provider.eurRate || '',
notes: provider.notes || '',
apiType: provider.apiType || '',
apiBaseUrl: provider.apiBaseUrl || '',
})
setEditingId(provider.id)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(true)
}
const canTestApi =
form.apiType === 'billmanager' &&
form.apiBaseUrl?.trim() &&
apiProbeLogin?.trim() &&
apiProbePassword?.trim()
const onTestConnection = async () => {
if (!canTestApi) {
setTestConnectionResult({ ok: false, error: 'Заполните URL API и тестовый логин/пароль аккаунта' })
return
}
setTestConnectionLoading(true)
setTestConnectionResult(null)
try {
const result = await testApiConnection(form.apiBaseUrl, `${apiProbeLogin}:${apiProbePassword}`)
setTestConnectionResult(result)
} catch (err) {
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
} finally {
setTestConnectionLoading(false)
}
}
return (
<>
<PageHeader pretitle="Справочники" title="Хостеры" />
@@ -64,6 +103,9 @@ export function ProvidersPage({ db, actions }) {
onClick={() => {
setForm(emptyForm)
setEditingId(null)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(true)
}}
>
@@ -79,6 +121,7 @@ export function ProvidersPage({ db, actions }) {
<th>Сайт</th>
<th>Валюта / курсы</th>
<th>Контакт</th>
<th>API</th>
<th />
</tr>
</thead>
@@ -107,6 +150,13 @@ export function ProvidersPage({ db, actions }) {
</div>
</td>
<td>{provider.contact || '-'}</td>
<td>
{provider.apiType === 'billmanager' && (provider.apiBaseUrl || '').trim() ? (
<span className="text-secondary small">BILLmanager</span>
) : (
'—'
)}
</td>
<td className="text-end">
<div className="table-actions">
<button
@@ -128,7 +178,7 @@ export function ProvidersPage({ db, actions }) {
</tr>
))}
{db.providers.length === 0 ? (
<EmptyState message="Пока нет хостеров" colSpan={5} />
<EmptyState message="Пока нет хостеров" colSpan={6} />
) : null}
</tbody>
</table>
@@ -144,6 +194,9 @@ export function ProvidersPage({ db, actions }) {
setIsModalOpen(false)
setEditingId(null)
setForm(emptyForm)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
}}
size="modal-md"
>
@@ -218,6 +271,88 @@ export function ProvidersPage({ db, actions }) {
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h6 className="text-secondary mb-2">Интеграция API (один URL на хостера)</h6>
<div className="text-secondary small mb-2">
Логин и пароль задаются в карточке каждого аккаунта этого хостера.
</div>
</div>
<div className="col-12">
<label className="form-label">Тип API</label>
<select
autoComplete="off"
className="form-select"
value={form.apiType}
onChange={(e) =>
setForm((prev) => ({
...prev,
apiType: e.target.value,
apiBaseUrl: e.target.value === 'billmanager' ? prev.apiBaseUrl : '',
}))
}
>
<option value=""> Не использовать </option>
<option value="billmanager">BILLmanager</option>
</select>
</div>
{form.apiType === 'billmanager' ? (
<>
<div className="col-12">
<label className="form-label">URL API BILLmanager</label>
<input
{...noBrowserSuggestProps}
className="form-control"
placeholder="https://bill.example.com:1500/billmgr"
value={form.apiBaseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, apiBaseUrl: e.target.value }))}
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Тест: логин аккаунта</label>
<input
{...noBrowserSuggestProps}
className="form-control"
placeholder="не сохраняется"
value={apiProbeLogin}
onChange={(e) => setApiProbeLogin(e.target.value)}
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Тест: пароль</label>
<input
{...passwordCredentialInputProps}
type="password"
className="form-control"
placeholder="не сохраняется"
value={apiProbePassword}
onChange={(e) => setApiProbePassword(e.target.value)}
/>
</div>
<div className="col-12 d-flex align-items-center gap-2 flex-wrap">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={onTestConnection}
disabled={!canTestApi || testConnectionLoading}
>
{testConnectionLoading ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<IconPlugConnected size={14} className="me-1" />
)}
Проверить соединение
</button>
{testConnectionResult ? (
<span className={testConnectionResult.ok ? 'text-success small' : 'text-danger small'}>
{testConnectionResult.ok
? `Соединение успешно${testConnectionResult.vdsCount != null ? `, VDS: ${testConnectionResult.vdsCount}` : ''}`
: testConnectionResult.error}
</span>
) : null}
</div>
</>
) : null}
<div className="col-12 d-flex gap-2 justify-content-end">
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
Отмена
+3 -2
View File
@@ -12,6 +12,7 @@ import { syncAccount } from '../lib/api'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
const SORT_COLUMNS = ['name', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'channel', 'country', 'location', 'price']
@@ -61,8 +62,8 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
const baseCurrency = (settings?.[0]?.baseCurrency || 'RUB').toUpperCase()
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
[db.providerAccounts],
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
const filteredAndSortedTariffs = useMemo(() => {
+3 -2
View File
@@ -27,6 +27,7 @@ import { PageHeader } from '../components/PageHeader'
import { ProjectSuggestInput } from '../components/ProjectSuggestInput'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { getPaidUntilDate as computePaidUntilForHealth } from '../lib/paid-until'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
const emptyForm = {
ip: '',
@@ -140,8 +141,8 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
[db.providerAccounts],
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
const providerById = useMemo(