diff --git a/AGENTS.md b/AGENTS.md index a029311..58d5a5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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** | Движения по балансу | diff --git a/server/adapters/billmanager/sync.js b/server/adapters/billmanager/sync.js index ddeb2a7..80e2194 100644 --- a/server/adapters/billmanager/sync.js +++ b/server/adapters/billmanager/sync.js @@ -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 }} diff --git a/server/db/migrations.js b/server/db/migrations.js index 35aa0ad..97efb2a 100644 --- a/server/db/migrations.js +++ b/server/db/migrations.js @@ -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) + }, + }, ] diff --git a/server/db/schema.js b/server/db/schema.js index 3aa19d5..8a08e0a 100644 --- a/server/db/schema.js +++ b/server/db/schema.js @@ -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 ( diff --git a/server/db/seed.js b/server/db/seed.js index 70764d5..fbaff86 100644 --- a/server/db/seed.js +++ b/server/db/seed.js @@ -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')) diff --git a/server/routes/backup.js b/server/routes/backup.js index 938931c..95b1f52 100644 --- a/server/routes/backup.js +++ b/server/routes/backup.js @@ -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 diff --git a/server/routes/migrate.js b/server/routes/migrate.js index 9012c33..5c37fb2 100644 --- a/server/routes/migrate.js +++ b/server/routes/migrate.js @@ -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 }) diff --git a/server/routes/provider-accounts.js b/server/routes/provider-accounts.js index e83b551..d1a5070 100644 --- a/server/routes/provider-accounts.js +++ b/server/routes/provider-accounts.js @@ -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, diff --git a/server/routes/providers.js b/server/routes/providers.js index eaa5883..854c983 100644 --- a/server/routes/providers.js +++ b/server/routes/providers.js @@ -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 }) diff --git a/server/routes/sync.js b/server/routes/sync.js index 4f0bb89..47522b6 100644 --- a/server/routes/sync.js +++ b/server/routes/sync.js @@ -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, diff --git a/server/sync-scheduler.js b/server/sync-scheduler.js index 970f7b6..bd949d6 100644 --- a/server/sync-scheduler.js +++ b/server/sync-scheduler.js @@ -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 { diff --git a/server/utils/billmanager-context.js b/server/utils/billmanager-context.js new file mode 100644 index 0000000..e3e83d5 --- /dev/null +++ b/server/utils/billmanager-context.js @@ -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 } +} diff --git a/src/lib/billmanager-ui.js b/src/lib/billmanager-ui.js new file mode 100644 index 0000000..0c209e2 --- /dev/null +++ b/src/lib/billmanager-ui.js @@ -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 +} diff --git a/src/lib/inventory-health.js b/src/lib/inventory-health.js index 4a3abdc..48ae7c3 100644 --- a/src/lib/inventory-health.js +++ b/src/lib/inventory-health.js @@ -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) => { diff --git a/src/pages/AccountsPage.jsx b/src/pages/AccountsPage.jsx index 9378289..576c3a8 100644 --- a/src/pages/AccountsPage.jsx +++ b/src/pages/AccountsPage.jsx @@ -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 }) {
{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 }) {