diff --git a/server/adapters/billmanager/sync.js b/server/adapters/billmanager/sync.js index 4d0a0f5..a858786 100644 --- a/server/adapters/billmanager/sync.js +++ b/server/adapters/billmanager/sync.js @@ -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 } } diff --git a/server/db/migrations.js b/server/db/migrations.js index 0425548..b2573eb 100644 --- a/server/db/migrations.js +++ b/server/db/migrations.js @@ -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 + } + }, + }, ] diff --git a/server/routes/settings.js b/server/routes/settings.js index e4465eb..a28c1a1 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -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 = '✅ Тестовое уведомление\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() diff --git a/server/sync-scheduler.js b/server/sync-scheduler.js index 43391ea..0701890 100644 --- a/server/sync-scheduler.js +++ b/server/sync-scheduler.js @@ -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 = `⚠️ Истекает оплата (ближайшие ${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 = `🆕 Новые тарифы (${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) diff --git a/server/telegram.js b/server/telegram.js new file mode 100644 index 0000000..62b385e --- /dev/null +++ b/server/telegram.js @@ -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} + */ +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) + } + } +} diff --git a/src/lib/api.js b/src/lib/api.js index 80c9309..0b0024a 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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 +} diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index afe2e96..7458861 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -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 ( <> diff --git a/src/pages/ReportsPage.jsx b/src/pages/ReportsPage.jsx index 54dce51..8feeed8 100644 --- a/src/pages/ReportsPage.jsx +++ b/src/pages/ReportsPage.jsx @@ -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, diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index b1cbd81..4d78e03 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -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 }) { +
+
+
+

Уведомления Telegram

+
+
+

+ Уведомления отправляются в Telegram при периодической синхронизации. Каждый тип можно включить или отключить отдельно. +

+
+
+ + { + setForm((prev) => ({ ...prev, telegramBotToken: e.target.value })) + setTelegramTokenEdited(true) + }} + placeholder={current.telegramBotTokenSet ? '••••••••' : 'Токен от @BotFather'} + autoComplete="off" + /> +
+
+ + setForm((prev) => ({ ...prev, telegramChatId: e.target.value }))} + placeholder="например -1001234567890" + /> +
+ ID группы (отрицательное число). Добавьте бота в группу, затем getUpdates — chat.id. +
+
+
+ + setForm((prev) => ({ ...prev, telegramMessageThreadId: e.target.value }))} + placeholder="необязательно, например 12345" + /> +
+ Для SuperGroup с топиками — ID топика. Оставьте пустым для общей ленты группы. +
+
+
+ +
+
+ +
+
+ + +
+ {telegramTestMessage ? ( +
+ {telegramTestMessage.text} +
+ ) : null} +
+
+
+
+
diff --git a/src/pages/VpsPage.jsx b/src/pages/VpsPage.jsx index e9b40f9..6090508 100644 --- a/src/pages/VpsPage.jsx +++ b/src/pages/VpsPage.jsx @@ -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()