Implement Telegram notification features for payment expiry and new tariffs

- Added functionality to send Telegram notifications for upcoming payment expirations and new tariffs.
- Enhanced `runScheduledSync` and `runScheduledSyncTariffs` to trigger notifications based on settings.
- Updated database schema to include Telegram settings and notification preferences.
- Introduced a test endpoint for verifying Telegram notification setup in the settings page.
- Improved user interface in the settings page to manage Telegram bot token and notification preferences.
This commit is contained in:
Denozordec
2026-03-20 22:32:34 +07:00
parent 8ca276c65c
commit 7ce754ca90
10 changed files with 512 additions and 49 deletions
+8 -1
View File
@@ -172,7 +172,11 @@ export async function syncFromBillmanager(account, db, opts = {}) {
} }
let tariffsCount = 0 let tariffsCount = 0
let newTariffs = []
if (fetchTariffs) { if (fetchTariffs) {
const existingTariffIds = new Set(
db.prepare('SELECT id FROM active_tariffs WHERE providerAccountId = ?').all(accountId).map((r) => r.id),
)
const syncedAt = new Date().toISOString() const syncedAt = new Date().toISOString()
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId) db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt) const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
@@ -182,6 +186,9 @@ export async function syncFromBillmanager(account, db, opts = {}) {
const dcKey = t.datacenterKey ?? '' const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? '' const dcName = t.datacenterName ?? ''
const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}` const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}`
if (!existingTariffIds.has(id)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.run(tariffInsertSql, db.run(tariffInsertSql,
id, id,
accountId, accountId,
@@ -220,5 +227,5 @@ export async function syncFromBillmanager(account, db, opts = {}) {
} }
} }
return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo } return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo }
} }
+35
View File
@@ -138,4 +138,39 @@ export const MIGRATIONS = [
} }
}, },
}, },
{
name: 'settings_telegram',
run(db) {
try {
db.exec('ALTER TABLE settings ADD COLUMN telegramBotToken TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN telegramChatId TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN notifyNewTariffsEnabled INTEGER')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'settings_telegram_thread',
run(db) {
try {
db.exec('ALTER TABLE settings ADD COLUMN telegramMessageThreadId TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
] ]
+52 -6
View File
@@ -1,6 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { getDb } from '../db.js' import { getDb } from '../db.js'
import { startScheduler } from '../sync-scheduler.js' import { startScheduler } from '../sync-scheduler.js'
import { sendTelegramMessage } from '../telegram.js'
const router = Router() const router = Router()
@@ -14,14 +15,33 @@ export function rowToSettings(row) {
customFields = [] customFields = []
} }
} }
const { telegramBotToken, ...rest } = row
return { return {
...row, ...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
autoConvert: Boolean(row.autoConvert), autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled), syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
customFields: Array.isArray(customFields) ? customFields : [], customFields: Array.isArray(customFields) ? customFields : [],
} }
} }
router.post('/telegram/test', async (req, res) => {
try {
const db = getDb()
const row = db.prepare('SELECT telegramBotToken, telegramChatId, telegramMessageThreadId FROM settings WHERE id = ?').get('settings-main')
if (!row?.telegramBotToken?.trim() || !row?.telegramChatId?.trim()) {
return res.status(400).json({ ok: false, error: 'Укажите токен бота и Chat ID в настройках' })
}
const text = '✅ <b>Тестовое уведомление</b>\n\nVPS Tracker — уведомления настроены корректно.'
await sendTelegramMessage(row.telegramBotToken, row.telegramChatId, text, row.telegramMessageThreadId || undefined)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ ok: false, error: err.message || 'Ошибка отправки' })
}
})
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const db = getDb() const db = getDb()
@@ -48,11 +68,17 @@ router.put('/:id', (req, res) => {
const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0) const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0)
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60) const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440) const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled !== undefined ? (r.notifyPaymentExpiryEnabled ? 1 : 0) : (existing?.notifyPaymentExpiryEnabled ? 1 : 0)
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled !== undefined ? (r.notifyNewTariffsEnabled ? 1 : 0) : (existing?.notifyNewTariffsEnabled ? 1 : 0)
const telegramBotToken = r.telegramBotToken !== undefined ? (r.telegramBotToken || '') : (existing?.telegramBotToken ?? '')
const telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
const customFields = serializeCustomFields(r.customFields ?? existing?.customFields) const customFields = serializeCustomFields(r.customFields ?? existing?.customFields)
if (existing) { if (existing) {
db.prepare(` db.prepare(`
UPDATE settings SET UPDATE settings SET
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?, customFields = ? baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
r.baseCurrency ?? existing.baseCurrency ?? 'RUB', r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
@@ -62,13 +88,18 @@ router.put('/:id', (req, res) => {
syncEnabled, syncEnabled,
syncIntervalMinutes, syncIntervalMinutes,
syncTariffsIntervalMinutes, syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields, customFields,
id, id,
) )
} else { } else {
db.prepare(` db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields) INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
id, id,
r.baseCurrency ?? 'RUB', r.baseCurrency ?? 'RUB',
@@ -78,6 +109,11 @@ router.put('/:id', (req, res) => {
syncEnabled, syncEnabled,
syncIntervalMinutes, syncIntervalMinutes,
syncTariffsIntervalMinutes, syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields, customFields,
) )
} }
@@ -97,10 +133,15 @@ router.post('/', (req, res) => {
const syncEnabled = r.syncEnabled ? 1 : 0 const syncEnabled = r.syncEnabled ? 1 : 0
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60) const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 1 : 0
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled ? 1 : 0
const telegramBotToken = r.telegramBotToken ?? ''
const telegramChatId = r.telegramChatId ?? ''
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
const customFields = serializeCustomFields(r.customFields) const customFields = serializeCustomFields(r.customFields)
db.prepare(` db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields) INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
id, id,
r.baseCurrency ?? 'RUB', r.baseCurrency ?? 'RUB',
@@ -110,6 +151,11 @@ router.post('/', (req, res) => {
syncEnabled, syncEnabled,
syncIntervalMinutes, syncIntervalMinutes,
syncTariffsIntervalMinutes, syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields, customFields,
) )
startScheduler() startScheduler()
+123 -6
View File
@@ -1,10 +1,110 @@
import { getDb } from './db.js' import { getDb } from './db.js'
import { syncFromBillmanager } from './adapters/billmanager/index.js' import { syncFromBillmanager } from './adapters/billmanager/index.js'
import { sendTelegramMessage } from './telegram.js'
let syncIntervalId = null let syncIntervalId = null
let syncTariffsIntervalId = null let syncTariffsIntervalId = null
export function runScheduledSync() { const UPCOMING_DAYS = 7
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
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(db, vps, providerAccounts, payments, balanceLedger, now) {
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 = 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 diffMs = paidUntilFromApi - today
const diffDays = Math.round(diffMs / (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 = db
.prepare('SELECT id FROM vps WHERE providerAccountId = ? AND status = ?')
.all(vps.providerAccountId, 'active').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(db) {
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.notifyPaymentExpiryEnabled || !settings?.telegramBotToken?.trim() || !settings?.telegramChatId?.trim()) {
return
}
const vpsList = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const now = new Date()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const upcoming = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now)
if (!paidUntil || paidUntil > threshold || paidUntil < new Date(now.getFullYear(), now.getMonth(), now.getDate())) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil - b.paidUntil)
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() {
try { try {
const db = getDb() const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main') const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
@@ -14,16 +114,21 @@ export function runScheduledSync() {
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != '' WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
`).all() `).all()
for (const account of accounts) { for (const account of accounts) {
syncFromBillmanager(account, db, { skipTariffs: true }).catch((err) => { try {
await syncFromBillmanager(account, db, { skipTariffs: true })
} catch (err) {
console.warn(`Sync VPS/payments failed for account ${account.id}:`, err.message) console.warn(`Sync VPS/payments failed for account ${account.id}:`, err.message)
}) }
}
if (settings?.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications(db)
} }
} catch (err) { } catch (err) {
console.warn('Scheduled sync error:', err.message) console.warn('Scheduled sync error:', err.message)
} }
} }
export function runScheduledSyncTariffs() { export async function runScheduledSyncTariffs() {
try { try {
const db = getDb() const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main') const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
@@ -32,10 +137,22 @@ export function runScheduledSyncTariffs() {
SELECT * FROM provider_accounts SELECT * FROM provider_accounts
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != '' WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
`).all() `).all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
for (const account of accounts) { for (const account of accounts) {
syncFromBillmanager(account, db, { skipVpsPayments: true }).catch((err) => { try {
const result = await syncFromBillmanager(account, db, { 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 || '—'}`)
const text = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err.message) console.warn(`Sync tariffs failed for account ${account.id}:`, err.message)
}) }
} }
} catch (err) { } catch (err) {
console.warn('Scheduled sync tariffs error:', err.message) console.warn('Scheduled sync tariffs error:', err.message)
+49
View File
@@ -0,0 +1,49 @@
/**
* Telegram Bot API — отправка уведомлений
*/
/**
* @param {string} token - Bot token from @BotFather
* @param {string|string[]} chatIds - Chat ID(s), comma-separated string or array
* @param {string} text - Message text
* @param {string|number} [messageThreadId] - ID топика в SuperGroup (для отправки в цепочку сообщений)
* @returns {Promise<void>}
*/
export async function sendTelegramMessage(token, chatIds, text, messageThreadId) {
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 = {
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(() => ({}))
if (!data.ok) {
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
}
} catch (err) {
console.warn(`Telegram sendMessage error for chat ${chatId}:`, err.message)
}
}
}
+13 -1
View File
@@ -51,7 +51,14 @@ async function fetchApi(path, options = {}) {
...options, ...options,
}) })
if (!res.ok) { if (!res.ok) {
const err = new Error(res.statusText || 'API error') let message = res.statusText || 'API error'
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
/* ignore */
}
const err = new Error(message)
err.status = res.status err.status = res.status
err.response = res err.response = res
throw err throw err
@@ -156,3 +163,8 @@ export async function testApiConnection(apiBaseUrl, apiCredentials) {
export async function fetchSyncStatus() { export async function fetchSyncStatus() {
return fetchApi('/api/sync/status') return fetchApi('/api/sync/status')
} }
export async function sendTelegramTestNotification() {
const res = await fetchApi('/api/settings/telegram/test', { method: 'POST' })
return res
}
+42 -8
View File
@@ -124,6 +124,13 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
}, [payments, balanceLedger, vps, providerAccounts, providers, baseCurrency, ratesData]) }, [payments, balanceLedger, vps, providerAccounts, providers, baseCurrency, ratesData])
const accountBalances = providerAccounts.map((account) => { const accountBalances = providerAccounts.map((account) => {
if (account.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return {
...account,
balance: Number(account.balance_api),
currency: account.balance_currency || account.currency,
}
}
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === account.id) const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === account.id)
const credits = ledgerRows const credits = ledgerRows
.filter((row) => row.direction === 'credit') .filter((row) => row.direction === 'credit')
@@ -141,6 +148,10 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
const UPCOMING_DAYS = 7 const UPCOMING_DAYS = 7
const getAccountBalance = (accountId) => { const getAccountBalance = (accountId) => {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId) const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = ledgerRows const credits = ledgerRows
.filter((row) => row.direction === 'credit') .filter((row) => row.direction === 'credit')
@@ -153,26 +164,49 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
const getPaidUntilDate = (item) => { const getPaidUntilDate = (item) => {
if (item.status !== 'active') return null if (item.status !== 'active') return null
if (item.paidUntil) { const account = providerAccounts.find((a) => a.id === item.providerAccountId)
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
const paidUntilFromApi = item.paidUntil
? (() => {
const d = new Date(item.paidUntil) const d = new Date(item.paidUntil)
return Number.isNaN(d.getTime()) ? null : d return Number.isNaN(d.getTime()) ? null : d
})()
: null
const isPaidUntilNextDay =
paidUntilFromApi &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi - today
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) {
return paidUntilFromApi
} }
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const dailyRate = Number(item.dailyRate || 0) const dailyRate = Number(item.dailyRate || 0)
const monthlyRate = Number(item.monthlyRate || 0) const monthlyRate = Number(item.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30 const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return null if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const directPayments = payments
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const accountBalance = getAccountBalance(item.providerAccountId) const accountBalance = getAccountBalance(item.providerAccountId)
const activeInAccount = vps.filter( const activeInAccount = vps.filter(
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active', (v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
).length ).length
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0 const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
const directPayments = payments
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const funds = directPayments + allocatedBalance const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate) const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return null if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
const paidUntil = new Date() const paidUntil = new Date()
paidUntil.setDate(paidUntil.getDate() + coveredDays) paidUntil.setDate(paidUntil.getDate() + coveredDays)
return paidUntil return paidUntil
@@ -190,7 +224,7 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
.filter(({ paidUntil }) => paidUntil && paidUntil <= threshold && paidUntil >= new Date(now.getFullYear(), now.getMonth(), now.getDate())) .filter(({ paidUntil }) => paidUntil && paidUntil <= threshold && paidUntil >= new Date(now.getFullYear(), now.getMonth(), now.getDate()))
.sort((a, b) => a.paidUntil - b.paidUntil) .sort((a, b) => a.paidUntil - b.paidUntil)
.slice(0, 10) .slice(0, 10)
}, [vps, payments, balanceLedger]) }, [vps, payments, balanceLedger, providerAccounts])
return ( return (
<> <>
+1 -12
View File
@@ -91,18 +91,7 @@ export function ReportsPage({ db, settings, ratesData }) {
const allDebits = [...debitsDirect, ...debitsAccountLevel] const allDebits = [...debitsDirect, ...debitsAccountLevel]
const totalPayments = allPayments.reduce((acc, item) => acc + Number(item.amount || 0), 0) const totalPayments = allPayments.reduce((acc, item) => acc + Number(item.amount || 0), 0)
const totalDebits = allDebits.reduce((acc, item) => acc + Number(item.amount || 0), 0) const totalDebits = allDebits.reduce((acc, item) => acc + Number(item.amount || 0), 0)
let total = totalPayments + totalDebits const total = totalPayments + totalDebits
if (total === 0 && (vps.monthlyRate != null && vps.monthlyRate > 0 || vps.dailyRate != null && vps.dailyRate > 0)) {
const monthly = Number(vps.monthlyRate) || 0
const daily = Number(vps.dailyRate) || 0
if (dateFrom && dateTo) {
const days = Math.ceil((new Date(dateTo) - new Date(dateFrom)) / (24 * 60 * 60 * 1000)) + 1
total = monthly > 0 ? monthly * Math.ceil(days / 30) : daily * days
} else {
total = monthly > 0 ? monthly : daily * 30
}
}
return { return {
providerId: vps.providerId, providerId: vps.providerId,
+157 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { IconPlus, IconTrash } from '@tabler/icons-react' import { IconPlus, IconSend, IconTrash } from '@tabler/icons-react'
import { PageHeader } from '../components/PageHeader' import { PageHeader } from '../components/PageHeader'
import { sendTelegramTestNotification } from '../lib/api'
const defaultSettings = { const defaultSettings = {
baseCurrency: 'RUB', baseCurrency: 'RUB',
@@ -9,6 +10,11 @@ const defaultSettings = {
syncEnabled: false, syncEnabled: false,
syncIntervalMinutes: 60, syncIntervalMinutes: 60,
syncTariffsIntervalMinutes: 1440, syncTariffsIntervalMinutes: 1440,
telegramBotToken: '',
telegramChatId: '',
telegramMessageThreadId: '',
notifyPaymentExpiryEnabled: false,
notifyNewTariffsEnabled: false,
} }
export function SettingsPage({ db, actions, ratesData, ratesError }) { export function SettingsPage({ db, actions, ratesData, ratesError }) {
@@ -20,21 +26,34 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled), syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled),
syncIntervalMinutes: current.syncIntervalMinutes ?? 60, syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440, syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440,
telegramBotToken: '',
telegramChatId: current.telegramChatId ?? '',
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
}) })
const [telegramTokenEdited, setTelegramTokenEdited] = useState(false)
const [telegramTestLoading, setTelegramTestLoading] = useState(false)
const [telegramTestMessage, setTelegramTestMessage] = useState(null)
const [newFieldLabel, setNewFieldLabel] = useState('') const [newFieldLabel, setNewFieldLabel] = useState('')
const customFields = Array.isArray(current.customFields) ? current.customFields : [] const customFields = Array.isArray(current.customFields) ? current.customFields : []
useEffect(() => { useEffect(() => {
/* eslint-disable-next-line react-hooks/set-state-in-effect -- sync form when settings change from parent */ /* eslint-disable-next-line react-hooks/set-state-in-effect -- sync form when settings change from parent */
setForm({ setForm((prev) => ({
...prev,
baseCurrency: current.baseCurrency || 'RUB', baseCurrency: current.baseCurrency || 'RUB',
ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js', ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js',
autoConvert: current.autoConvert !== false, autoConvert: current.autoConvert !== false,
syncEnabled: Boolean(current.syncEnabled), syncEnabled: Boolean(current.syncEnabled),
syncIntervalMinutes: current.syncIntervalMinutes ?? 60, syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440, syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440,
}) telegramChatId: current.telegramChatId ?? '',
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes]) telegramMessageThreadId: current.telegramMessageThreadId ?? '',
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
}))
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes, current.telegramChatId, current.telegramMessageThreadId, current.notifyPaymentExpiryEnabled, current.notifyNewTariffsEnabled])
const availableCurrencies = useMemo(() => { const availableCurrencies = useMemo(() => {
const list = new Set(['RUB', 'USD', 'EUR']) const list = new Set(['RUB', 'USD', 'EUR'])
@@ -63,6 +82,35 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
}) })
} }
const onTelegramTest = async () => {
setTelegramTestMessage(null)
setTelegramTestLoading(true)
try {
await sendTelegramTestNotification()
setTelegramTestMessage({ type: 'success', text: 'Тестовое уведомление отправлено' })
} catch (err) {
setTelegramTestMessage({ type: 'danger', text: err.message || 'Ошибка отправки' })
} finally {
setTelegramTestLoading(false)
}
}
const onTelegramSubmit = (event) => {
event.preventDefault()
const payload = {
telegramChatId: form.telegramChatId || '',
telegramMessageThreadId: form.telegramMessageThreadId || '',
notifyPaymentExpiryEnabled: form.notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled: form.notifyNewTariffsEnabled,
}
if (telegramTokenEdited && form.telegramBotToken !== undefined) {
payload.telegramBotToken = form.telegramBotToken || ''
}
actions.upsertSettings(payload)
setTelegramTokenEdited(false)
setForm((prev) => ({ ...prev, telegramBotToken: '' }))
}
const addCustomField = () => { const addCustomField = () => {
const label = newFieldLabel.trim() const label = newFieldLabel.trim()
if (!label) return if (!label) return
@@ -248,6 +296,111 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
</div> </div>
</div> </div>
<div className="col-12 col-lg-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Уведомления Telegram</h3>
</div>
<div className="card-body">
<p className="text-secondary small mb-3">
Уведомления отправляются в Telegram при периодической синхронизации. Каждый тип можно включить или отключить отдельно.
</p>
<form className="row g-3" onSubmit={onTelegramSubmit}>
<div className="col-12">
<label className="form-label">Токен бота</label>
<input
type="password"
className="form-control"
value={form.telegramBotToken}
onChange={(e) => {
setForm((prev) => ({ ...prev, telegramBotToken: e.target.value }))
setTelegramTokenEdited(true)
}}
placeholder={current.telegramBotTokenSet ? '••••••••' : 'Токен от @BotFather'}
autoComplete="off"
/>
</div>
<div className="col-12">
<label className="form-label">Chat ID (SuperGroup)</label>
<input
type="text"
className="form-control"
value={form.telegramChatId}
onChange={(e) => setForm((prev) => ({ ...prev, telegramChatId: e.target.value }))}
placeholder="например -1001234567890"
/>
<div className="text-secondary small mt-1">
ID группы (отрицательное число). Добавьте бота в группу, затем getUpdates — chat.id.
</div>
</div>
<div className="col-12">
<label className="form-label">ID топика (цепочки сообщений)</label>
<input
type="text"
className="form-control"
value={form.telegramMessageThreadId}
onChange={(e) => setForm((prev) => ({ ...prev, telegramMessageThreadId: e.target.value }))}
placeholder="необязательно, например 12345"
/>
<div className="text-secondary small mt-1">
Для SuperGroup с топиками — ID топика. Оставьте пустым для общей ленты группы.
</div>
</div>
<div className="col-12">
<label className="form-check">
<input
className="form-check-input"
type="checkbox"
checked={form.notifyPaymentExpiryEnabled}
onChange={(e) => setForm((prev) => ({ ...prev, notifyPaymentExpiryEnabled: e.target.checked }))}
/>
<span className="form-check-label">Уведомления об истекающей оплате (ближайшие 7 дней)</span>
</label>
</div>
<div className="col-12">
<label className="form-check">
<input
className="form-check-input"
type="checkbox"
checked={form.notifyNewTariffsEnabled}
onChange={(e) => setForm((prev) => ({ ...prev, notifyNewTariffsEnabled: e.target.checked }))}
/>
<span className="form-check-label">Уведомления о новых тарифах</span>
</label>
</div>
<div className="col-12 d-flex justify-content-end gap-2">
<button
type="button"
className="btn btn-outline-primary"
onClick={onTelegramTest}
disabled={telegramTestLoading || !current.telegramBotTokenSet || !current.telegramChatId}
>
{telegramTestLoading ? (
<>
<span className="spinner-border spinner-border-sm me-1" />
Отправка…
</>
) : (
<>
<IconSend size={16} className="me-1" />
Тестовое уведомление
</>
)}
</button>
<button type="submit" className="btn btn-primary">
Сохранить
</button>
</div>
{telegramTestMessage ? (
<div className={`col-12 alert alert-${telegramTestMessage.type} py-2 mb-0`}>
{telegramTestMessage.text}
</div>
) : null}
</form>
</div>
</div>
</div>
<div className="col-12 col-lg-6"> <div className="col-12 col-lg-6">
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
+30 -9
View File
@@ -260,6 +260,10 @@ export function VpsPage({ db, actions, settings, ratesData }) {
} }
const getAccountBalance = (providerAccountId) => { const getAccountBalance = (providerAccountId) => {
const account = db.providerAccounts?.find((a) => a.id === providerAccountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
const rows = db.balanceLedger.filter((row) => row.providerAccountId === providerAccountId) const rows = db.balanceLedger.filter((row) => row.providerAccountId === providerAccountId)
const credits = rows const credits = rows
.filter((row) => row.direction === 'credit') .filter((row) => row.direction === 'credit')
@@ -274,24 +278,41 @@ export function VpsPage({ db, actions, settings, ratesData }) {
if (item.status !== 'active') { if (item.status !== 'active') {
return '-' return '-'
} }
const account = db.providerAccounts?.find((a) => a.id === item.providerAccountId)
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
let paidUntilFromApi = null
if (item.paidUntil) { if (item.paidUntil) {
try { try {
const d = new Date(item.paidUntil) const d = new Date(item.paidUntil)
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleDateString('ru-RU') paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
} catch { } catch {
return item.paidUntil paidUntilFromApi = null
} }
} }
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const now = new Date()
const isPaidUntilNextDay =
paidUntilFromApi &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi - today
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) {
return paidUntilFromApi.toLocaleDateString('ru-RU')
}
const dailyRate = Number(item.dailyRate || 0) const dailyRate = Number(item.dailyRate || 0)
const monthlyRate = Number(item.monthlyRate || 0) const monthlyRate = Number(item.monthlyRate || 0)
const activeRate = tariffType === 'daily' ? dailyRate : monthlyRate
if (activeRate <= 0) {
return '-'
}
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30 const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) { if (!Number.isFinite(burnRate) || burnRate <= 0) {
return '-' return paidUntilFromApi ? paidUntilFromApi.toLocaleDateString('ru-RU') : '-'
} }
const directPayments = db.payments const directPayments = db.payments
@@ -307,7 +328,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const funds = directPayments + allocatedBalance const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate) const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) { if (!Number.isFinite(coveredDays) || coveredDays <= 0) {
return '-' return paidUntilFromApi ? paidUntilFromApi.toLocaleDateString('ru-RU') : '-'
} }
const paidUntil = new Date() const paidUntil = new Date()