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 newTariffs = []
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()
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)
@@ -182,6 +186,9 @@ export async function syncFromBillmanager(account, db, opts = {}) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
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,
id,
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 { getDb } from '../db.js'
import { startScheduler } from '../sync-scheduler.js'
import { sendTelegramMessage } from '../telegram.js'
const router = Router()
@@ -14,14 +15,33 @@ export function rowToSettings(row) {
customFields = []
}
}
const { telegramBotToken, ...rest } = row
return {
...row,
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
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) => {
try {
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 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 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)
if (existing) {
db.prepare(`
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 = ?
`).run(
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
@@ -62,13 +88,18 @@ router.put('/:id', (req, res) => {
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
id,
)
} else {
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
@@ -78,6 +109,11 @@ router.put('/:id', (req, res) => {
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
)
}
@@ -97,10 +133,15 @@ router.post('/', (req, res) => {
const syncEnabled = r.syncEnabled ? 1 : 0
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
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)
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
@@ -110,6 +151,11 @@ router.post('/', (req, res) => {
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
)
startScheduler()
+123 -6
View File
@@ -1,10 +1,110 @@
import { getDb } from './db.js'
import { syncFromBillmanager } from './adapters/billmanager/index.js'
import { sendTelegramMessage } from './telegram.js'
let syncIntervalId = 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 {
const db = getDb()
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 != ''
`).all()
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)
})
}
}
if (settings?.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications(db)
}
} catch (err) {
console.warn('Scheduled sync error:', err.message)
}
}
export function runScheduledSyncTariffs() {
export async function runScheduledSyncTariffs() {
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
@@ -32,10 +137,22 @@ export function runScheduledSyncTariffs() {
SELECT * FROM provider_accounts
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
`).all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
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)
})
}
}
} catch (err) {
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,
})
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.response = res
throw err
@@ -156,3 +163,8 @@ export async function testApiConnection(apiBaseUrl, apiCredentials) {
export async function fetchSyncStatus() {
return fetchApi('/api/sync/status')
}
export async function sendTelegramTestNotification() {
const res = await fetchApi('/api/settings/telegram/test', { method: 'POST' })
return res
}
+44 -10
View File
@@ -124,6 +124,13 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
}, [payments, balanceLedger, vps, providerAccounts, providers, baseCurrency, ratesData])
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 credits = ledgerRows
.filter((row) => row.direction === 'credit')
@@ -141,6 +148,10 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
const UPCOMING_DAYS = 7
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 credits = ledgerRows
.filter((row) => row.direction === 'credit')
@@ -153,26 +164,49 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
const getPaidUntilDate = (item) => {
if (item.status !== 'active') return null
if (item.paidUntil) {
const d = new Date(item.paidUntil)
return Number.isNaN(d.getTime()) ? null : d
}
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)
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 dailyRate = Number(item.dailyRate || 0)
const monthlyRate = Number(item.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return null
const directPayments = payments
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const accountBalance = getAccountBalance(item.providerAccountId)
const activeInAccount = vps.filter(
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
).length
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 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()
paidUntil.setDate(paidUntil.getDate() + coveredDays)
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()))
.sort((a, b) => a.paidUntil - b.paidUntil)
.slice(0, 10)
}, [vps, payments, balanceLedger])
}, [vps, payments, balanceLedger, providerAccounts])
return (
<>
+1 -12
View File
@@ -91,18 +91,7 @@ export function ReportsPage({ db, settings, ratesData }) {
const allDebits = [...debitsDirect, ...debitsAccountLevel]
const totalPayments = allPayments.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
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
}
}
const total = totalPayments + totalDebits
return {
providerId: vps.providerId,
+157 -4
View File
@@ -1,6 +1,7 @@
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 { sendTelegramTestNotification } from '../lib/api'
const defaultSettings = {
baseCurrency: 'RUB',
@@ -9,6 +10,11 @@ const defaultSettings = {
syncEnabled: false,
syncIntervalMinutes: 60,
syncTariffsIntervalMinutes: 1440,
telegramBotToken: '',
telegramChatId: '',
telegramMessageThreadId: '',
notifyPaymentExpiryEnabled: false,
notifyNewTariffsEnabled: false,
}
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),
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
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 customFields = Array.isArray(current.customFields) ? current.customFields : []
useEffect(() => {
/* 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',
ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js',
autoConvert: current.autoConvert !== false,
syncEnabled: Boolean(current.syncEnabled),
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440,
})
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes])
telegramChatId: current.telegramChatId ?? '',
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 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 label = newFieldLabel.trim()
if (!label) return
@@ -248,6 +296,111 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
</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="card">
<div className="card-header">
+30 -9
View File
@@ -260,6 +260,10 @@ export function VpsPage({ db, actions, settings, ratesData }) {
}
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 credits = rows
.filter((row) => row.direction === 'credit')
@@ -274,24 +278,41 @@ export function VpsPage({ db, actions, settings, ratesData }) {
if (item.status !== 'active') {
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) {
try {
const d = new Date(item.paidUntil)
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleDateString('ru-RU')
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
} 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 monthlyRate = Number(item.monthlyRate || 0)
const activeRate = tariffType === 'daily' ? dailyRate : monthlyRate
if (activeRate <= 0) {
return '-'
}
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) {
return '-'
return paidUntilFromApi ? paidUntilFromApi.toLocaleDateString('ru-RU') : '-'
}
const directPayments = db.payments
@@ -307,7 +328,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) {
return '-'
return paidUntilFromApi ? paidUntilFromApi.toLocaleDateString('ru-RU') : '-'
}
const paidUntil = new Date()