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
+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()