feat(api): завершение миграции на Fastify — sync, scheduler, backup
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:
Denozordec
2026-06-27 23:22:59 +07:00
co-authored by Cursor
parent 836c8a2936
commit 0de0538218
24 changed files with 2192 additions and 46 deletions
+280
View File
@@ -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
}
}
+339
View File
@@ -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 }
}
+283
View File
@@ -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()
}
+45
View File
@@ -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)
}
}
}