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:
+13
-1
@@ -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
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user