feat(api): завершение миграции на Fastify — sync, scheduler, backup
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Портированы BILLmanager sync/test/balance, планировщик и Telegram, импорт/экспорт бэкапов и migrate API; Docker по умолчанию на fastify. Добавлен .understand-anything/ в gitignore, исправлен path codegraph в MCP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { getDb, getSqlite, schema, consolidateAllProviderApiSources } from '@cfdm/db'
|
||||
|
||||
export interface BackupPayload {
|
||||
providers?: unknown[]
|
||||
serverProjects?: unknown[]
|
||||
providerAccounts?: unknown[]
|
||||
settings?: unknown[] | unknown
|
||||
vps?: unknown[]
|
||||
payments?: unknown[]
|
||||
balanceLedger?: unknown[]
|
||||
activeTariffs?: unknown[]
|
||||
tariffSyncOptions?: unknown[]
|
||||
syncLog?: unknown[]
|
||||
}
|
||||
|
||||
function asObject(v: unknown): Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
export function importJsonSnapshot(data: BackupPayload): void {
|
||||
const db = getDb()
|
||||
const sqlite = getSqlite()
|
||||
|
||||
sqlite.exec('BEGIN')
|
||||
try {
|
||||
db.delete(schema.syncLog).run()
|
||||
db.delete(schema.tariffSyncOptions).run()
|
||||
db.delete(schema.activeTariffs).run()
|
||||
db.delete(schema.balanceLedger).run()
|
||||
db.delete(schema.payments).run()
|
||||
db.delete(schema.vps).run()
|
||||
db.delete(schema.providerAccounts).run()
|
||||
db.delete(schema.serverProjects).run()
|
||||
db.delete(schema.providers).run()
|
||||
db.delete(schema.settings).run()
|
||||
|
||||
for (const raw of Array.isArray(data.providers) ? data.providers : []) {
|
||||
const p = asObject(raw)
|
||||
db.insert(schema.providers)
|
||||
.values({
|
||||
id: String(p.id ?? ''),
|
||||
name: String(p.name ?? ''),
|
||||
website: String(p.website ?? ''),
|
||||
contact: String(p.contact ?? ''),
|
||||
baseCurrency: String(p.baseCurrency ?? ''),
|
||||
usdRate: String(p.usdRate ?? ''),
|
||||
eurRate: String(p.eurRate ?? ''),
|
||||
notes: String(p.notes ?? ''),
|
||||
apiType: String(p.apiType ?? ''),
|
||||
apiBaseUrl: String(p.apiBaseUrl ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.serverProjects) ? data.serverProjects : []) {
|
||||
const sp = asObject(raw)
|
||||
db.insert(schema.serverProjects)
|
||||
.values({
|
||||
id: String(sp.id ?? ''),
|
||||
name: String(sp.name ?? ''),
|
||||
color: sp.color != null ? String(sp.color) : null,
|
||||
sortOrder: Number(sp.sortOrder) || 0,
|
||||
notes: sp.notes != null ? String(sp.notes) : null,
|
||||
createdAt: sp.createdAt != null ? String(sp.createdAt) : null,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.providerAccounts) ? data.providerAccounts : []) {
|
||||
const acc = asObject(raw)
|
||||
const alertRaw = acc.balance_alert_below ?? acc.balanceAlertBelow
|
||||
const alertBelow =
|
||||
alertRaw != null && alertRaw !== '' && Number.isFinite(Number(alertRaw)) ? Number(alertRaw) : null
|
||||
db.insert(schema.providerAccounts)
|
||||
.values({
|
||||
id: String(acc.id ?? ''),
|
||||
providerId: String(acc.providerId ?? ''),
|
||||
name: String(acc.name ?? ''),
|
||||
panelUrl: String(acc.panelUrl ?? ''),
|
||||
currency: String(acc.currency ?? ''),
|
||||
billingMode: String(acc.billingMode ?? ''),
|
||||
notes: String(acc.notes ?? ''),
|
||||
apiType: String(acc.apiType ?? ''),
|
||||
apiBaseUrl: String(acc.apiBaseUrl ?? ''),
|
||||
apiCredentials: String(acc.apiCredentials ?? ''),
|
||||
balanceApi: acc.balance_api != null ? Number(acc.balance_api) : acc.balanceApi != null ? Number(acc.balanceApi) : null,
|
||||
balanceCurrency:
|
||||
acc.balance_currency != null ? String(acc.balance_currency) : acc.balanceCurrency != null ? String(acc.balanceCurrency) : null,
|
||||
balanceUpdatedAt:
|
||||
acc.balance_updated_at != null ? String(acc.balance_updated_at) : acc.balanceUpdatedAt != null ? String(acc.balanceUpdatedAt) : null,
|
||||
enoughmoneyto: acc.enoughmoneyto != null ? String(acc.enoughmoneyto) : null,
|
||||
balanceAlertBelow: alertBelow,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
consolidateAllProviderApiSources(sqlite)
|
||||
|
||||
const settingsList = Array.isArray(data.settings)
|
||||
? data.settings
|
||||
: data.settings
|
||||
? [data.settings]
|
||||
: []
|
||||
for (const raw of settingsList) {
|
||||
const s = asObject(raw)
|
||||
let customFields = s.customFields
|
||||
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
|
||||
db.insert(schema.settings)
|
||||
.values({
|
||||
id: String(s.id ?? 'settings-main'),
|
||||
baseCurrency: String(s.baseCurrency ?? 'RUB'),
|
||||
ratesUrl: String(s.ratesUrl ?? ''),
|
||||
autoConvert: s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
|
||||
ratesUpdatedAt: String(s.ratesUpdatedAt ?? ''),
|
||||
syncEnabled: s.syncEnabled ? 1 : 0,
|
||||
syncIntervalMinutes: Number(s.syncIntervalMinutes) || 60,
|
||||
syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440,
|
||||
telegramBotToken: String(s.telegramBotToken ?? ''),
|
||||
telegramChatId: String(s.telegramChatId ?? ''),
|
||||
telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''),
|
||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0,
|
||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0,
|
||||
customFields: customFields != null ? String(customFields) : null,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
|
||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.vps) ? data.vps : []) {
|
||||
const v = asObject(raw)
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides)
|
||||
? JSON.stringify(v.userOverrides)
|
||||
: String(v.userOverrides ?? '[]')
|
||||
db.insert(schema.vps)
|
||||
.values({
|
||||
id: String(v.id ?? ''),
|
||||
ip: String(v.ip ?? ''),
|
||||
ipv6: String(v.ipv6 ?? ''),
|
||||
additionalIps,
|
||||
dns: String(v.dns ?? ''),
|
||||
providerId: String(v.providerId ?? ''),
|
||||
providerAccountId: String(v.providerAccountId ?? ''),
|
||||
country: String(v.country ?? ''),
|
||||
city: String(v.city ?? ''),
|
||||
datacenter: String(v.datacenter ?? ''),
|
||||
os: String(v.os ?? ''),
|
||||
vcpu: Number(v.vcpu) || 0,
|
||||
ramGb: Number(v.ramGb) || 0,
|
||||
diskGb: Number(v.diskGb) || 0,
|
||||
diskType: String(v.diskType ?? ''),
|
||||
virtualization: String(v.virtualization ?? ''),
|
||||
bandwidthTb: Number(v.bandwidthTb) || 0,
|
||||
sshPort: Number(v.sshPort) || 22,
|
||||
rootUser: String(v.rootUser ?? ''),
|
||||
purpose: String(v.purpose ?? ''),
|
||||
environment: String(v.environment ?? ''),
|
||||
project: String(v.project ?? ''),
|
||||
projectId: v.projectId != null && v.projectId !== '' ? String(v.projectId) : null,
|
||||
monitoringEnabled: v.monitoringEnabled ? 1 : 0,
|
||||
backupEnabled: v.backupEnabled ? 1 : 0,
|
||||
status: String(v.status ?? 'active'),
|
||||
tariffType: String(v.tariffType ?? ''),
|
||||
currency: String(v.currency ?? ''),
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: String(v.createdAt ?? ''),
|
||||
paidUntil: String(v.paidUntil ?? ''),
|
||||
notes: String(v.notes ?? ''),
|
||||
userOverrides,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.payments) ? data.payments : []) {
|
||||
const pm = asObject(raw)
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
id: String(pm.id ?? ''),
|
||||
type: String(pm.type ?? ''),
|
||||
date: String(pm.date ?? ''),
|
||||
amount: Number(pm.amount) || 0,
|
||||
currency: String(pm.currency ?? ''),
|
||||
providerAccountId: String(pm.providerAccountId ?? ''),
|
||||
vpsId: pm.vpsId != null ? String(pm.vpsId) : null,
|
||||
note: String(pm.note ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.balanceLedger) ? data.balanceLedger : []) {
|
||||
const bl = asObject(raw)
|
||||
db.insert(schema.balanceLedger)
|
||||
.values({
|
||||
id: String(bl.id ?? ''),
|
||||
type: String(bl.type ?? ''),
|
||||
date: String(bl.date ?? ''),
|
||||
amount: Number(bl.amount) || 0,
|
||||
currency: String(bl.currency ?? ''),
|
||||
direction: String(bl.direction ?? ''),
|
||||
providerAccountId: String(bl.providerAccountId ?? ''),
|
||||
vpsId: bl.vpsId != null ? String(bl.vpsId) : null,
|
||||
note: String(bl.note ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.activeTariffs) ? data.activeTariffs : []) {
|
||||
const t = asObject(raw)
|
||||
db.insert(schema.activeTariffs)
|
||||
.values({
|
||||
id: String(t.id ?? ''),
|
||||
providerAccountId: String(t.providerAccountId ?? ''),
|
||||
providerId: String(t.providerId ?? ''),
|
||||
externalId: String(t.externalId ?? ''),
|
||||
datacenterKey: String(t.datacenterKey ?? ''),
|
||||
datacenterName: String(t.datacenterName ?? ''),
|
||||
name: String(t.name ?? ''),
|
||||
desc: String(t.desc ?? ''),
|
||||
vcpu: Number(t.vcpu) || 0,
|
||||
ramGb: Number(t.ramGb) || 0,
|
||||
diskGb: Number(t.diskGb) || 0,
|
||||
diskType: String(t.diskType ?? ''),
|
||||
virtualization: String(t.virtualization ?? ''),
|
||||
channel: String(t.channel ?? ''),
|
||||
location: String(t.location ?? ''),
|
||||
country: String(t.country ?? ''),
|
||||
cpuModel: String(t.cpuModel ?? ''),
|
||||
orderAvailable: t.orderAvailable ? 1 : 0,
|
||||
price: String(t.price ?? ''),
|
||||
syncedAt: String(t.syncedAt ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []) {
|
||||
const o = asObject(raw)
|
||||
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
|
||||
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
|
||||
db.insert(schema.tariffSyncOptions)
|
||||
.values({
|
||||
providerAccountId: String(o.providerAccountId ?? ''),
|
||||
datacenters: dcs,
|
||||
periods: pers,
|
||||
syncedAt: String(o.syncedAt ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.syncLog) ? data.syncLog : []) {
|
||||
const log = asObject(raw)
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: String(log.id ?? ''),
|
||||
accountId: String(log.accountId ?? ''),
|
||||
startedAt: String(log.startedAt ?? ''),
|
||||
finishedAt: log.finishedAt != null ? String(log.finishedAt) : null,
|
||||
status: log.status != null ? String(log.status) : null,
|
||||
vpsCount: log.vpsCount != null ? Number(log.vpsCount) : null,
|
||||
paymentsCount: log.paymentsCount != null ? Number(log.paymentsCount) : null,
|
||||
error: log.error != null ? String(log.error) : null,
|
||||
summary:
|
||||
typeof log.summary === 'string'
|
||||
? log.summary
|
||||
: log.summary
|
||||
? JSON.stringify(log.summary)
|
||||
: null,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
sqlite.exec('COMMIT')
|
||||
} catch (err) {
|
||||
sqlite.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* BILLmanager 6 API HTTP client
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
interface BillmanagerErrorResponse {
|
||||
error?: { msg?: string; $t?: string }
|
||||
}
|
||||
|
||||
export async function billmanagerRequest(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
func: string,
|
||||
params: Record<string, string | number | undefined | null> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('authinfo', authinfo)
|
||||
url.searchParams.set('out', 'bjson')
|
||||
url.searchParams.set('func', func)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') url.searchParams.set(k, String(v))
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as BillmanagerErrorResponse & Record<string, unknown>
|
||||
if (data.error) {
|
||||
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface BillmanagerSyncAccount extends AccountRow {
|
||||
apiType: 'billmanager'
|
||||
apiBaseUrl: string
|
||||
}
|
||||
|
||||
export function resolveBillmanagerApi(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): { apiType: string; apiBaseUrl: string } {
|
||||
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
|
||||
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||
return { apiType, apiBaseUrl }
|
||||
}
|
||||
|
||||
export function billmanagerAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): BillmanagerSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveBillmanagerApi(accountRow, providerRow)
|
||||
const cred = String(accountRow.apiCredentials || '').trim()
|
||||
if (apiType !== 'billmanager' || !apiBaseUrl || !cred) return null
|
||||
return { ...accountRow, apiType: 'billmanager', apiBaseUrl }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* BILLmanager 6 API adapter
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
export {
|
||||
testConnection,
|
||||
fetchVds,
|
||||
fetchDashboardInfo,
|
||||
fetchPayments,
|
||||
fetchVdsOrderPricelist,
|
||||
fetchVdsOrderPricelistAllDatacenters,
|
||||
} from './operations.js'
|
||||
export { syncFromBillmanager } from './sync.js'
|
||||
export { runBillmanagerAccountSync } from './sync-job.js'
|
||||
export { billmanagerAccountRowForSync, resolveBillmanagerApi } from './context.js'
|
||||
export type { BillmanagerSyncAccount } from './context.js'
|
||||
export type {
|
||||
SyncFromBillmanagerOptions,
|
||||
SyncFromBillmanagerResult,
|
||||
SyncSummary,
|
||||
} from './sync.js'
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* BILLmanager API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { parsePricelist } from './parsers.js'
|
||||
|
||||
const VDS_STATUS_MAP: Record<number, string> = {
|
||||
1: 'active',
|
||||
2: 'active',
|
||||
3: 'paused',
|
||||
4: 'archived',
|
||||
5: 'active',
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
externalId: string
|
||||
ip: string
|
||||
dns: string
|
||||
ipv6: string
|
||||
additionalIps: string[]
|
||||
providerId: string
|
||||
providerAccountId: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
os: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
bandwidthTb: number
|
||||
sshPort: number
|
||||
rootUser: string
|
||||
purpose: string
|
||||
environment: string
|
||||
project: string
|
||||
monitoringEnabled: boolean
|
||||
backupEnabled: boolean
|
||||
status: string
|
||||
tariffType: string
|
||||
currency: string
|
||||
dailyRate: null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface MappedPayment {
|
||||
externalId: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId: null
|
||||
note: string
|
||||
}
|
||||
|
||||
export function mapVdsToVps(
|
||||
item: Record<string, string>,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
): MappedVps {
|
||||
const status =
|
||||
VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
|
||||
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
|
||||
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
|
||||
const createdate = item.createdate || ''
|
||||
const expiredate = item.real_expiredate || item.expiredate || ''
|
||||
const ip = (item.ip || '').trim()
|
||||
const domain = (item.domain || '').trim()
|
||||
const datacenter = (item.datacentername || item.datacenter || '').trim()
|
||||
const ostempl = (item.ostempl || '').trim()
|
||||
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
|
||||
|
||||
const pricelist = item.pricelist || item.tariff || item.plan || ''
|
||||
const parsed = parsePricelist(pricelist)
|
||||
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
ip: ip || domain || `bm-${item.id}`,
|
||||
dns: domain || ip || '',
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: '',
|
||||
city: '',
|
||||
datacenter,
|
||||
os: ostempl,
|
||||
vcpu: parsed.vcpu || 0,
|
||||
ramGb: parsed.ramGb || 0,
|
||||
diskGb: parsed.diskGb || 0,
|
||||
diskType: parsed.diskType || 'NVMe',
|
||||
virtualization: parsed.virtualization || 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: 'prod',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'monthly',
|
||||
currency: currency || 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: cost || null,
|
||||
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
|
||||
notes: `bm-${item.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPaymentToPayment(
|
||||
item: Record<string, string>,
|
||||
providerAccountId: string,
|
||||
): MappedPayment | null {
|
||||
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
|
||||
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
|
||||
const createDate = item.create_date || item.createdate || ''
|
||||
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
|
||||
if (statusNum !== 4) return null
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
type: 'provider_balance_topup',
|
||||
date: dateStr,
|
||||
amount,
|
||||
currency:
|
||||
(String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] ||
|
||||
'USD',
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: `BILLmanager #${item.number || item.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
|
||||
*/
|
||||
|
||||
import { billmanagerRequest } from './client.js'
|
||||
import {
|
||||
elemToObject,
|
||||
extractList,
|
||||
extractTariflist,
|
||||
parseDatacenterName,
|
||||
parseTariffDesc,
|
||||
} from './parsers.js'
|
||||
|
||||
export interface DashboardInfo {
|
||||
balance: number
|
||||
currency: string
|
||||
enoughmoneyto: string
|
||||
realbalance: string
|
||||
}
|
||||
|
||||
export interface TariffItem {
|
||||
externalId: string
|
||||
name: string
|
||||
desc: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
cpuModel: string
|
||||
orderAvailable: boolean
|
||||
price: string
|
||||
datacenterKey?: string
|
||||
datacenterName?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export async function fetchVds(baseUrl: string, authinfo: string): Promise<Record<string, string>[]> {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
|
||||
const elems = extractList(data, 'vds')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
export async function fetchDashboardInfo(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: { fallbackCurrency?: string | null } = {},
|
||||
): Promise<DashboardInfo> {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
|
||||
dashboard: 'info',
|
||||
sfrom: 'ajax',
|
||||
})
|
||||
const elems =
|
||||
extractList(data, 'dashboard') ||
|
||||
(Array.isArray(data.elem) ? (data.elem as unknown[]) : [])
|
||||
const item = elems.length > 0 ? elemToObject(elems[0] as never) : {}
|
||||
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
|
||||
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
|
||||
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
|
||||
if (!currency) {
|
||||
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
|
||||
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
|
||||
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
|
||||
else if (balanceStr.includes('£')) currency = 'GBP'
|
||||
else currency = opts.fallbackCurrency || 'RUB'
|
||||
}
|
||||
return {
|
||||
balance: amount,
|
||||
currency: currency || opts.fallbackCurrency || 'RUB',
|
||||
enoughmoneyto: item.enoughmoneyto || '',
|
||||
realbalance: item.realbalance || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPayments(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: {
|
||||
createdatestart?: string
|
||||
createdateend?: string
|
||||
createdate?: string
|
||||
filter?: string
|
||||
status?: string | number
|
||||
} = {},
|
||||
): Promise<Record<string, string>[]> {
|
||||
const params: Record<string, string> = {}
|
||||
if (opts.createdatestart) params.createdatestart = opts.createdatestart
|
||||
if (opts.createdateend) params.createdateend = opts.createdateend
|
||||
if (opts.createdate === 'other') params.createdate = 'other'
|
||||
if (opts.filter === 'on') params.filter = 'on'
|
||||
if (opts.status != null) params.status = String(opts.status)
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
|
||||
const elems = extractList(data, 'payment')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
export async function fetchVdsOrderPricelist(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: { plid?: string; period?: string; datacenter?: string } = {},
|
||||
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
|
||||
const params: Record<string, string> = {
|
||||
plid: opts.plid || '',
|
||||
sfrom: 'ajax',
|
||||
}
|
||||
if (opts.period) params.period = opts.period
|
||||
if (opts.datacenter) params.datacenter = opts.datacenter
|
||||
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
|
||||
|
||||
const tariflist = extractTariflist(data)
|
||||
const listNode = (data.list as Record<string, unknown>) ?? (data.doc as Record<string, unknown>)?.list
|
||||
const slist = ((listNode as Record<string, unknown>)?.slist ?? data.slist ?? {}) as Record<string, unknown>
|
||||
if (tariflist.length === 0) return { tariffItems: [], slist }
|
||||
|
||||
const tariffItems = tariflist.map((rawItem) => {
|
||||
const item = elemToObject(rawItem)
|
||||
const parsed = parseTariffDesc(item.desc || '')
|
||||
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
|
||||
return {
|
||||
externalId: String(item.pricelist || ''),
|
||||
name,
|
||||
desc: descClean,
|
||||
vcpu: parsed.vcpu,
|
||||
ramGb: parsed.ramGb,
|
||||
diskGb: parsed.diskGb,
|
||||
diskType: parsed.diskType,
|
||||
virtualization: parsed.virtualization,
|
||||
channel: parsed.channel,
|
||||
location: parsed.location,
|
||||
cpuModel: parsed.cpuModel,
|
||||
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
|
||||
price: item.price || '',
|
||||
}
|
||||
})
|
||||
|
||||
return { tariffItems, slist }
|
||||
}
|
||||
|
||||
export async function fetchVdsOrderPricelistAllDatacenters(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
|
||||
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
|
||||
const slist = initial.slist || {}
|
||||
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
|
||||
|
||||
if (datacenters.length === 0) {
|
||||
return { tariffItems: initial.tariffItems, slist }
|
||||
}
|
||||
|
||||
const allTariffItems: TariffItem[] = []
|
||||
|
||||
for (let i = 0; i < datacenters.length; i++) {
|
||||
const dc = datacenters[i] as Record<string, unknown>
|
||||
const dcKey = String(dc.k ?? dc.key ?? '')
|
||||
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
|
||||
const { country, location } = parseDatacenterName(dcName)
|
||||
|
||||
const result =
|
||||
i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
|
||||
for (const t of result.tariffItems) {
|
||||
allTariffItems.push({
|
||||
...t,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
country,
|
||||
location: location || dcName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tariffItems: allTariffItems, slist }
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
): Promise<{ ok: boolean; error?: string; vdsCount?: number }> {
|
||||
if (!baseUrl?.trim() || !authinfo?.trim()) {
|
||||
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||
}
|
||||
try {
|
||||
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
|
||||
return { ok: true, vdsCount: items?.length ?? 0 }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapVdsToVps } from './mappers.js'
|
||||
import { parsePricelist, parseTariffDesc } from './parsers.js'
|
||||
|
||||
describe('parsePricelist', () => {
|
||||
it('parses CPU/RAM/disk from pricelist string', () => {
|
||||
expect(parsePricelist('KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)')).toEqual({
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 7,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTariffDesc', () => {
|
||||
it('parses Selectel-style HTML description', () => {
|
||||
const result = parseTariffDesc('Start<br/>Процессор: 2 ядра; Память: 4 GB; Диск: 40 GB NVMe')
|
||||
expect(result.vcpu).toBe(2)
|
||||
expect(result.ramGb).toBe(4)
|
||||
expect(result.diskGb).toBe(40)
|
||||
expect(result.diskType).toBe('NVMe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapVdsToVps', () => {
|
||||
it('maps active VDS with monthly cost', () => {
|
||||
const vps = mapVdsToVps(
|
||||
{
|
||||
id: '42',
|
||||
ip: '203.0.113.10',
|
||||
domain: 'vps.example.com',
|
||||
item_status: '2',
|
||||
cost: '500.00 RUB / Месяц',
|
||||
pricelist: 'KVM (2 CPU/2048 MB RAM/20 GB NVMe)',
|
||||
createdate: '2024-01-15',
|
||||
expiredate: '2025-01-15',
|
||||
currency_str: 'RUB',
|
||||
},
|
||||
'prov-1',
|
||||
'acc-1',
|
||||
)
|
||||
expect(vps.externalId).toBe('42')
|
||||
expect(vps.ip).toBe('203.0.113.10')
|
||||
expect(vps.status).toBe('active')
|
||||
expect(vps.monthlyRate).toBe(500)
|
||||
expect(vps.vcpu).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* BILLmanager API response parsers
|
||||
*/
|
||||
|
||||
type BillmanagerElem = Record<string, unknown> | Array<Record<string, unknown>>
|
||||
|
||||
export function extractList(data: Record<string, unknown>, key: string): BillmanagerElem[] {
|
||||
if (Array.isArray(data.elem)) return data.elem as BillmanagerElem[]
|
||||
const dataNode = data.data as Record<string, unknown> | undefined
|
||||
if (dataNode?.elem) {
|
||||
return Array.isArray(dataNode.elem) ? (dataNode.elem as BillmanagerElem[]) : [dataNode.elem as BillmanagerElem]
|
||||
}
|
||||
const doc = (data.doc as Record<string, unknown>) || data
|
||||
let list = doc[key] as Record<string, unknown> | BillmanagerElem[] | undefined
|
||||
if (!list) return []
|
||||
if (Array.isArray(list)) return list
|
||||
if (list.elem) {
|
||||
return Array.isArray(list.elem) ? (list.elem as BillmanagerElem[]) : [list.elem as BillmanagerElem]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function elemToObject(elem: BillmanagerElem | null | undefined): Record<string, string> {
|
||||
if (!elem) return {}
|
||||
if (Array.isArray(elem)) {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const e of elem) {
|
||||
const name = (e.$name || e.name) as string | undefined
|
||||
const val = e.$t ?? e.$
|
||||
if (name) {
|
||||
obj[name] =
|
||||
typeof val === 'object' && val !== null
|
||||
? String((val as Record<string, unknown>).$t ?? (val as Record<string, unknown>).$ ?? JSON.stringify(val))
|
||||
: String(val ?? '')
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(elem).map(([k, v]) => [k, v == null ? '' : String(v)]),
|
||||
)
|
||||
}
|
||||
|
||||
export function parsePricelist(pricelist: unknown): {
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
} {
|
||||
const s = String(pricelist || '')
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'NVMe'
|
||||
let virtualization = 'KVM'
|
||||
|
||||
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
|
||||
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
|
||||
else {
|
||||
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'NVMe'
|
||||
}
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
return { vcpu, ramGb, diskGb, diskType, virtualization }
|
||||
}
|
||||
|
||||
export function parseTariffDesc(desc: unknown): {
|
||||
name: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
cpuModel: string
|
||||
} {
|
||||
const s = String(desc || '')
|
||||
let name = ''
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'SSD'
|
||||
let virtualization = 'KVM'
|
||||
let channel = ''
|
||||
let location = ''
|
||||
let cpuModel = ''
|
||||
|
||||
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
|
||||
name = firstLine.replace(/<[^>]+>/g, '').trim()
|
||||
|
||||
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
|
||||
if (ramMbMatch) ramGb = Math.max(0.5, Math.round(((parseInt(ramMbMatch[1], 10) || 0) / 1024) * 10) / 10)
|
||||
else {
|
||||
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'SSD'
|
||||
}
|
||||
|
||||
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
|
||||
if (channelMatch) channel = channelMatch[1]
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
if (!channel) {
|
||||
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
|
||||
if (netMatch) channel = `${netMatch[1]} Мбит/с`
|
||||
}
|
||||
if (!location) {
|
||||
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
|
||||
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!cpuModel) {
|
||||
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
|
||||
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!diskType || diskType === 'SSD') {
|
||||
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
|
||||
}
|
||||
|
||||
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
|
||||
}
|
||||
|
||||
export function parseDatacenterName(dcName: unknown): { country: string; location: string } {
|
||||
const s = String(dcName || '').trim()
|
||||
if (!s) return { country: '', location: '' }
|
||||
|
||||
const COUNTRY_CODE_MAP: Record<string, string> = {
|
||||
DE: 'Германия',
|
||||
FI: 'Финляндия',
|
||||
RU: 'Россия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
US: 'США',
|
||||
SE: 'Швеция',
|
||||
NO: 'Норвегия',
|
||||
BE: 'Бельгия',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
CA: 'Канада',
|
||||
LV: 'Латвия',
|
||||
LT: 'Литва',
|
||||
EE: 'Эстония',
|
||||
PL: 'Польша',
|
||||
IT: 'Италия',
|
||||
DK: 'Дания',
|
||||
AU: 'Австралия',
|
||||
ES: 'Испания',
|
||||
SG: 'Сингапур',
|
||||
}
|
||||
|
||||
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
|
||||
if (codeMatch) {
|
||||
const code = codeMatch[1]
|
||||
const loc = codeMatch[2].trim()
|
||||
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
|
||||
}
|
||||
|
||||
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
|
||||
if (dcMatch) {
|
||||
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
|
||||
}
|
||||
|
||||
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
|
||||
if (commaMatch) {
|
||||
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
|
||||
}
|
||||
|
||||
const countryOnly = [
|
||||
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
|
||||
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
|
||||
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
|
||||
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
|
||||
]
|
||||
for (const c of countryOnly) {
|
||||
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
|
||||
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
|
||||
return { country: c, location: rest }
|
||||
}
|
||||
}
|
||||
|
||||
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||
return { country: 'Россия', location: s }
|
||||
}
|
||||
if (/Европа/i.test(s)) {
|
||||
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||
}
|
||||
|
||||
const pipeMatch = s.match(/^([^|]+)\s*\|/)
|
||||
if (pipeMatch) {
|
||||
const part = pipeMatch[1].trim()
|
||||
for (const c of countryOnly) {
|
||||
if (part.includes(c)) return { country: c, location: '' }
|
||||
}
|
||||
return { country: part, location: '' }
|
||||
}
|
||||
|
||||
return { country: s, location: '' }
|
||||
}
|
||||
|
||||
export function extractTariflist(data: Record<string, unknown>): BillmanagerElem[] {
|
||||
if (!data) return []
|
||||
const doc = data.doc as Record<string, unknown> | undefined
|
||||
const listNode = (data.list as Record<string, unknown>) ?? doc?.list ?? doc
|
||||
if (!listNode || typeof listNode !== 'object') return []
|
||||
|
||||
let list = (listNode as Record<string, unknown>).tariflist
|
||||
?? (listNode as Record<string, unknown>).tarifflist
|
||||
?? (listNode as Record<string, unknown>).pricelist
|
||||
if (Array.isArray(list)) return list as BillmanagerElem[]
|
||||
|
||||
const elems = (listNode as Record<string, unknown>).elem
|
||||
if (elems) return Array.isArray(elems) ? (elems as BillmanagerElem[]) : [elems as BillmanagerElem]
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Запуск синхронизации BILLmanager с записью в sync_log
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { BillmanagerSyncAccount } from './context.js'
|
||||
import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js'
|
||||
|
||||
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
|
||||
ok: true
|
||||
logId: string
|
||||
}
|
||||
|
||||
export async function runBillmanagerAccountSync(
|
||||
account: BillmanagerSyncAccount,
|
||||
opts: SyncFromBillmanagerOptions = {},
|
||||
): Promise<RunBillmanagerAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const logId = `sync-${account.id}-${Date.now()}`
|
||||
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: logId,
|
||||
accountId: account.id,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
})
|
||||
.run()
|
||||
|
||||
try {
|
||||
const result = await syncFromBillmanager(account, opts)
|
||||
const summaryPayload = {
|
||||
...(result.syncSummary || {}),
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
}
|
||||
db.update(schema.syncLog)
|
||||
.set({
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: 'ok',
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
summary: JSON.stringify(summaryPayload),
|
||||
})
|
||||
.where(eq(schema.syncLog.id, logId))
|
||||
.run()
|
||||
return { ok: true, logId, ...result }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
db.update(schema.syncLog)
|
||||
.set({
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: message,
|
||||
summary: JSON.stringify({ error: message }),
|
||||
})
|
||||
.where(eq(schema.syncLog.id, logId))
|
||||
.run()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB (Drizzle / @cfdm/db)
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { BillmanagerSyncAccount } from './context.js'
|
||||
import { mapPaymentToPayment, mapVdsToVps } from './mappers.js'
|
||||
import {
|
||||
fetchDashboardInfo,
|
||||
fetchPayments,
|
||||
fetchVds,
|
||||
fetchVdsOrderPricelistAllDatacenters,
|
||||
type DashboardInfo,
|
||||
type TariffItem,
|
||||
} from './operations.js'
|
||||
|
||||
export interface SyncFromBillmanagerOptions {
|
||||
skipTariffs?: boolean
|
||||
skipVpsPayments?: boolean
|
||||
}
|
||||
|
||||
export interface SyncSummary {
|
||||
added: { id: string; label: string }[]
|
||||
updated: { id: string; label: string; fields: string[] }[]
|
||||
paymentsAdded: number
|
||||
tariffsOnly?: boolean
|
||||
}
|
||||
|
||||
export interface SyncFromBillmanagerResult {
|
||||
vpsCount: number
|
||||
paymentsCount: number
|
||||
tariffsCount: number
|
||||
newTariffs: { name: string; price: string; providerId: string }[]
|
||||
balance: DashboardInfo | null
|
||||
syncSummary: SyncSummary
|
||||
}
|
||||
|
||||
const SYNC_UPDATE_FIELDS = [
|
||||
'country',
|
||||
'city',
|
||||
'datacenter',
|
||||
'os',
|
||||
'notes',
|
||||
'status',
|
||||
'tariffType',
|
||||
'currency',
|
||||
'dailyRate',
|
||||
'monthlyRate',
|
||||
'paidUntil',
|
||||
] as const
|
||||
|
||||
function normVal(v: unknown): string {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export async function syncFromBillmanager(
|
||||
account: BillmanagerSyncAccount,
|
||||
opts: SyncFromBillmanagerOptions = {},
|
||||
): Promise<SyncFromBillmanagerResult> {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const authinfo = apiCredentials.trim()
|
||||
const db = getDb()
|
||||
|
||||
const fetchVpsPayments = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
|
||||
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
|
||||
fetchVpsPayments
|
||||
? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs
|
||||
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
||||
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err instanceof Error ? err.message : err)
|
||||
return { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> }
|
||||
})
|
||||
: { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> },
|
||||
])
|
||||
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsPayments) {
|
||||
for (const item of vdsItems) {
|
||||
const vps = mapVdsToVps(item, providerId, accountId)
|
||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
const dailyRate = vps.dailyRate
|
||||
const monthlyRate = vps.monthlyRate
|
||||
const paidUntil = vps.paidUntil || ''
|
||||
const notes = vps.notes ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.vps.providerAccountId, accountId),
|
||||
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%bm-${vps.externalId}%`)),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
if (existing) {
|
||||
let userOverrides: string[] = []
|
||||
try {
|
||||
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||
} catch {
|
||||
userOverrides = []
|
||||
}
|
||||
const merged = {
|
||||
ip: vps.ip,
|
||||
ipv6: vps.ipv6,
|
||||
additionalIps,
|
||||
dns: vps.dns,
|
||||
country: vps.country,
|
||||
city: vps.city,
|
||||
datacenter: vps.datacenter,
|
||||
os: vps.os,
|
||||
status: vps.status,
|
||||
tariffType: vps.tariffType,
|
||||
currency: vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
paidUntil,
|
||||
notes,
|
||||
}
|
||||
for (const f of SYNC_UPDATE_FIELDS) {
|
||||
if (userOverrides.includes(f)) {
|
||||
merged[f] = existing[f as keyof typeof existing] as never
|
||||
}
|
||||
}
|
||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
|
||||
const changedFields = compareFields.filter(
|
||||
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
||||
)
|
||||
if (changedFields.length > 0) {
|
||||
const label = merged.dns || merged.ip || existing.id
|
||||
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
|
||||
}
|
||||
db.update(schema.vps)
|
||||
.set({
|
||||
ip: merged.ip,
|
||||
ipv6: merged.ipv6,
|
||||
additionalIps: merged.additionalIps,
|
||||
dns: merged.dns,
|
||||
country: merged.country,
|
||||
city: merged.city,
|
||||
datacenter: merged.datacenter,
|
||||
os: merged.os,
|
||||
status: merged.status,
|
||||
tariffType: merged.tariffType,
|
||||
currency: merged.currency,
|
||||
dailyRate: merged.dailyRate,
|
||||
monthlyRate: merged.monthlyRate,
|
||||
paidUntil: merged.paidUntil,
|
||||
notes: merged.notes,
|
||||
})
|
||||
.where(eq(schema.vps.id, existing.id))
|
||||
.run()
|
||||
} else {
|
||||
const label = vps.dns || vps.ip || id
|
||||
syncSummary.added.push({ id, label })
|
||||
db.insert(schema.vps)
|
||||
.values({
|
||||
id,
|
||||
ip: vps.ip,
|
||||
ipv6: vps.ipv6,
|
||||
additionalIps,
|
||||
dns: vps.dns,
|
||||
providerId: vps.providerId,
|
||||
providerAccountId: vps.providerAccountId,
|
||||
country: vps.country,
|
||||
city: vps.city,
|
||||
datacenter: vps.datacenter,
|
||||
os: vps.os,
|
||||
vcpu: vps.vcpu,
|
||||
ramGb: vps.ramGb,
|
||||
diskGb: vps.diskGb,
|
||||
diskType: vps.diskType,
|
||||
virtualization: vps.virtualization,
|
||||
bandwidthTb: vps.bandwidthTb,
|
||||
sshPort: vps.sshPort,
|
||||
rootUser: vps.rootUser,
|
||||
purpose: vps.purpose,
|
||||
environment: vps.environment,
|
||||
project: vps.project,
|
||||
projectId: null,
|
||||
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
|
||||
backupEnabled: vps.backupEnabled ? 1 : 0,
|
||||
status: vps.status,
|
||||
tariffType: vps.tariffType,
|
||||
currency: vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: vps.createdAt,
|
||||
paidUntil,
|
||||
notes,
|
||||
userOverrides: '[]',
|
||||
})
|
||||
.run()
|
||||
}
|
||||
vpsCount++
|
||||
}
|
||||
}
|
||||
|
||||
let paymentsCount = 0
|
||||
if (fetchVpsPayments) {
|
||||
const existingPaymentRows = db
|
||||
.select({ note: schema.payments.note })
|
||||
.from(schema.payments)
|
||||
.where(eq(schema.payments.providerAccountId, accountId))
|
||||
.all()
|
||||
const existingPayments = new Set(
|
||||
existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)),
|
||||
)
|
||||
|
||||
for (const item of paymentItems) {
|
||||
const payment = mapPaymentToPayment(item, accountId)
|
||||
if (!payment || payment.amount <= 0) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const payId = `pay-bm-${accountId}-${payment.externalId}`
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
id: payId,
|
||||
type: payment.type,
|
||||
date: payment.date,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
providerAccountId: payment.providerAccountId,
|
||||
vpsId: payment.vpsId,
|
||||
note,
|
||||
})
|
||||
.run()
|
||||
existingPayments.add(note)
|
||||
paymentsCount++
|
||||
syncSummary.paymentsAdded += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchVpsPayments && dashboardInfo) {
|
||||
db.update(schema.providerAccounts)
|
||||
.set({
|
||||
balanceApi: dashboardInfo.balance,
|
||||
balanceCurrency: dashboardInfo.currency || 'RUB',
|
||||
balanceUpdatedAt: new Date().toISOString(),
|
||||
enoughmoneyto: dashboardInfo.enoughmoneyto || '',
|
||||
})
|
||||
.where(eq(schema.providerAccounts.id, accountId))
|
||||
.run()
|
||||
}
|
||||
|
||||
let tariffsCount = 0
|
||||
const newTariffs: { name: string; price: string; providerId: string }[] = []
|
||||
if (fetchTariffs) {
|
||||
const existingTariffIds = new Set(
|
||||
db
|
||||
.select({ id: schema.activeTariffs.id })
|
||||
.from(schema.activeTariffs)
|
||||
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
||||
.all()
|
||||
.map((r) => r.id),
|
||||
)
|
||||
const syncedAt = new Date().toISOString()
|
||||
db.delete(schema.activeTariffs)
|
||||
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
||||
.run()
|
||||
|
||||
for (const t of tariffItems) {
|
||||
const dcKey = t.datacenterKey ?? ''
|
||||
const dcName = t.datacenterName ?? ''
|
||||
const tariffId = dcKey
|
||||
? `tariff-bm-${accountId}-${t.externalId}-${dcKey}`
|
||||
: `tariff-bm-${accountId}-${t.externalId}`
|
||||
if (!existingTariffIds.has(tariffId)) {
|
||||
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
||||
}
|
||||
db.insert(schema.activeTariffs)
|
||||
.values({
|
||||
id: tariffId,
|
||||
providerAccountId: accountId,
|
||||
providerId,
|
||||
externalId: t.externalId,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
name: t.name || '',
|
||||
desc: t.desc || '',
|
||||
vcpu: t.vcpu || 0,
|
||||
ramGb: t.ramGb || 0,
|
||||
diskGb: t.diskGb || 0,
|
||||
diskType: t.diskType || 'SSD',
|
||||
virtualization: t.virtualization || 'KVM',
|
||||
channel: t.channel || '',
|
||||
location: t.location || '',
|
||||
country: t.country || '',
|
||||
cpuModel: t.cpuModel || '',
|
||||
orderAvailable: t.orderAvailable ? 1 : 0,
|
||||
price: t.price || '',
|
||||
syncedAt,
|
||||
})
|
||||
.run()
|
||||
tariffsCount++
|
||||
}
|
||||
|
||||
if (Object.keys(slist).length > 0) {
|
||||
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
|
||||
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
|
||||
db.insert(schema.tariffSyncOptions)
|
||||
.values({
|
||||
providerAccountId: accountId,
|
||||
datacenters,
|
||||
periods,
|
||||
syncedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.tariffSyncOptions.providerAccountId,
|
||||
set: { datacenters, periods, syncedAt },
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchVpsPayments) {
|
||||
syncSummary.tariffsOnly = true
|
||||
}
|
||||
|
||||
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
|
||||
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
const SETTINGS_ID = 'settings-main'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type VpsRow = typeof schema.vps.$inferSelect
|
||||
type PaymentRow = typeof schema.payments.$inferSelect
|
||||
type LedgerRow = typeof schema.balanceLedger.$inferSelect
|
||||
|
||||
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.all<AccountRow>(sql`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
return rows
|
||||
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
|
||||
.filter((a): a is NonNullable<typeof a> => a != null)
|
||||
}
|
||||
|
||||
function getAccountBalance(
|
||||
accountId: string,
|
||||
providerAccounts: AccountRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
): number {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
|
||||
return Number(account.balanceApi)
|
||||
}
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = rows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = rows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
function getPaidUntilDate(
|
||||
vps: VpsRow,
|
||||
providerAccounts: AccountRow[],
|
||||
payments: PaymentRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
now: Date,
|
||||
): Date | null {
|
||||
if (vps.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
|
||||
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
let paidUntilFromApi: Date | null = null
|
||||
if (vps.paidUntil) {
|
||||
const d = new Date(vps.paidUntil)
|
||||
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(vps.dailyRate || 0)
|
||||
const monthlyRate = Number(vps.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger)
|
||||
const activeInAccount = getDb()
|
||||
.select({ id: schema.vps.id })
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
|
||||
)
|
||||
.all().length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
|
||||
async function sendPaymentExpiryNotifications(): Promise<void> {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (
|
||||
!settings?.notifyPaymentExpiryEnabled ||
|
||||
!settings.telegramBotToken?.trim() ||
|
||||
!settings.telegramChatId?.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
const providerAccounts = db.select().from(schema.providerAccounts).all()
|
||||
const payments = db.select().from(schema.payments).all()
|
||||
const balanceLedger = db.select().from(schema.balanceLedger).all()
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
|
||||
const now = new Date()
|
||||
const threshold = new Date(now)
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
|
||||
for (const vps of vpsList) {
|
||||
if (vps.status !== 'active') continue
|
||||
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
|
||||
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
|
||||
const provider = providers.find((p) => p.id === vps.providerId)
|
||||
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
|
||||
}
|
||||
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
|
||||
if (upcoming.length === 0) return
|
||||
|
||||
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
|
||||
const dateStr = paidUntil.toLocaleDateString('ru-RU')
|
||||
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
|
||||
})
|
||||
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
text,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
|
||||
export async function runScheduledSync(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
const token = settings.telegramBotToken?.trim()
|
||||
const chatId = settings.telegramChatId?.trim()
|
||||
const canTg = Boolean(token && chatId)
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
||||
const s = result.syncSummary
|
||||
const parts: string[] = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||
digestLines.push(`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
|
||||
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balanceAlertBelow
|
||||
if (
|
||||
canTg &&
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
apiBal != null &&
|
||||
Number.isFinite(Number(apiBal)) &&
|
||||
Number(apiBal) < Number(threshold)
|
||||
) {
|
||||
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
|
||||
lowBalanceLines.push(`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'ошибка'
|
||||
digestLines.push(`✗ ${account.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
||||
await sendTelegramMessage(token!, chatId!, `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`, settings.telegramMessageThreadId)
|
||||
}
|
||||
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
|
||||
await sendTelegramMessage(token!, chatId!, `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`, settings.telegramMessageThreadId)
|
||||
}
|
||||
|
||||
if (settings.notifyPaymentExpiryEnabled) {
|
||||
await sendPaymentExpiryNotifications()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const providers = getDb().select().from(schema.providers).all()
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (
|
||||
newTariffs.length > 0 &&
|
||||
settings.notifyNewTariffsEnabled &&
|
||||
settings.telegramBotToken?.trim() &&
|
||||
settings.telegramChatId?.trim()
|
||||
) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
||||
console.log(
|
||||
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`,
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
}
|
||||
|
||||
export function restartScheduler(): void {
|
||||
stopScheduler()
|
||||
startScheduler()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Telegram Bot API — отправка уведомлений
|
||||
*/
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
token: string,
|
||||
chatIds: string | string[],
|
||||
text: string,
|
||||
messageThreadId?: string | number | null,
|
||||
): Promise<void> {
|
||||
if (!token?.trim() || !text?.trim()) return
|
||||
const ids = Array.isArray(chatIds)
|
||||
? chatIds
|
||||
: String(chatIds || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
|
||||
const payload: Record<string, unknown> = {
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}
|
||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
||||
|
||||
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
||||
for (const chatId of ids) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
||||
if (!data.ok) {
|
||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user