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:
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+44
-10
@@ -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 d = new Date(item.paidUntil)
|
|
||||||
return Number.isNaN(d.getTime()) ? null : d
|
|
||||||
}
|
|
||||||
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
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 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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user