mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT
488 lines
22 KiB
Python
488 lines
22 KiB
Python
import logging
|
||
import json
|
||
from typing import Optional, Dict, Any
|
||
from datetime import datetime
|
||
|
||
from aiogram import Bot
|
||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||
from app.config import settings
|
||
from app.database.database import get_db
|
||
from app.database.models import Transaction, TransactionType, PaymentMethod
|
||
from app.database.crud.transaction import (
|
||
create_transaction, get_transaction_by_external_id, complete_transaction
|
||
)
|
||
from app.database.crud.user import get_user_by_telegram_id
|
||
from app.external.tribute import TributeService as TributeAPI
|
||
from app.services.payment_service import PaymentService
|
||
from app.services.subscription_auto_purchase_service import (
|
||
auto_activate_subscription_after_topup,
|
||
auto_purchase_saved_cart_after_topup,
|
||
)
|
||
from app.utils.user_utils import format_referrer_info
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class TributeService:
|
||
_invoice_messages: Dict[int, Dict[str, int]] = {}
|
||
|
||
def __init__(self, bot: Bot):
|
||
self.bot = bot
|
||
self.tribute_api = TributeAPI()
|
||
|
||
@classmethod
|
||
def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None:
|
||
cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id}
|
||
|
||
async def _cleanup_invoice_message(self, user_id: int) -> None:
|
||
invoice_message = self._invoice_messages.pop(user_id, None)
|
||
if not invoice_message or not getattr(self, "bot", None):
|
||
return
|
||
|
||
chat_id = invoice_message.get("chat_id")
|
||
message_id = invoice_message.get("message_id")
|
||
if not chat_id or not message_id:
|
||
return
|
||
|
||
try:
|
||
await self.bot.delete_message(chat_id, message_id)
|
||
except Exception as error: # pragma: no cover - depends on bot rights
|
||
logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error)
|
||
|
||
async def create_payment_link(
|
||
self,
|
||
user_id: int,
|
||
amount_kopeks: int,
|
||
description: str = "Пополнение баланса"
|
||
) -> Optional[str]:
|
||
|
||
if not settings.TRIBUTE_ENABLED:
|
||
logger.warning("Tribute платежи отключены")
|
||
return None
|
||
|
||
try:
|
||
payment_url = await self.tribute_api.create_payment_link(
|
||
user_id=user_id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=description
|
||
)
|
||
|
||
if not payment_url:
|
||
return None
|
||
|
||
return payment_url
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||
return None
|
||
|
||
async def process_webhook(
|
||
self,
|
||
payload: str
|
||
) -> Dict[str, Any]:
|
||
|
||
try:
|
||
webhook_data = json.loads(payload)
|
||
except json.JSONDecodeError:
|
||
logger.error("Некорректный JSON в Tribute webhook")
|
||
return {"status": "error", "reason": "invalid_json"}
|
||
|
||
logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}")
|
||
|
||
processed_data = await self.tribute_api.process_webhook(webhook_data)
|
||
if not processed_data:
|
||
return {"status": "ignored", "reason": "invalid_data"}
|
||
|
||
event_type = processed_data.get("event_type", "payment")
|
||
status = processed_data.get("status")
|
||
|
||
if event_type == "payment" and status == "paid":
|
||
await self._handle_successful_payment(processed_data)
|
||
elif event_type == "payment" and status == "failed":
|
||
await self._handle_failed_payment(processed_data)
|
||
elif event_type == "refund":
|
||
await self._handle_refund(processed_data)
|
||
|
||
return {"status": "ok", "event": event_type}
|
||
|
||
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
|
||
try:
|
||
user_telegram_id = payment_data["user_id"]
|
||
amount_kopeks = payment_data["amount_kopeks"]
|
||
payment_id = payment_data["payment_id"]
|
||
|
||
logger.info(f"Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}")
|
||
|
||
async for session in get_db():
|
||
user = await get_user_by_telegram_id(session, user_telegram_id)
|
||
if not user:
|
||
logger.error(f"Пользователь {user_telegram_id} не найден")
|
||
return
|
||
|
||
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
|
||
|
||
from app.database.crud.transaction import check_tribute_payment_duplicate
|
||
|
||
duplicate_transaction = await check_tribute_payment_duplicate(
|
||
session, payment_id, amount_kopeks, user_telegram_id
|
||
)
|
||
|
||
if duplicate_transaction:
|
||
logger.warning(f"Найден дубликат платежа в течение 24ч:")
|
||
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
|
||
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
|
||
logger.warning(f" Created: {duplicate_transaction.created_at}")
|
||
logger.warning(f" External ID: {duplicate_transaction.external_id}")
|
||
logger.warning(f"Платеж игнорирован - это дубликат свежего платежа")
|
||
return
|
||
|
||
from app.database.crud.transaction import create_unique_tribute_transaction
|
||
|
||
transaction = await create_unique_tribute_transaction(
|
||
db=session,
|
||
user_id=user.id,
|
||
payment_id=payment_id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
|
||
)
|
||
|
||
old_balance = user.balance_kopeks
|
||
was_first_topup = not user.has_made_first_topup
|
||
|
||
user.balance_kopeks += amount_kopeks
|
||
user.updated_at = datetime.utcnow()
|
||
|
||
promo_group = user.get_primary_promo_group()
|
||
subscription = getattr(user, "subscription", None)
|
||
referrer_info = format_referrer_info(user)
|
||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||
|
||
await session.commit()
|
||
|
||
try:
|
||
from app.services.referral_service import process_referral_topup
|
||
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
|
||
|
||
if was_first_topup and not user.has_made_first_topup:
|
||
user.has_made_first_topup = True
|
||
await session.commit()
|
||
|
||
await session.refresh(user)
|
||
|
||
logger.info(
|
||
f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})"
|
||
)
|
||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||
|
||
if was_first_topup:
|
||
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
|
||
|
||
|
||
try:
|
||
from app.services.admin_notification_service import AdminNotificationService
|
||
notification_service = AdminNotificationService(self.bot)
|
||
await notification_service.send_balance_topup_notification(
|
||
user,
|
||
transaction,
|
||
old_balance,
|
||
topup_status=topup_status,
|
||
referrer_info=referrer_info,
|
||
subscription=subscription,
|
||
promo_group=promo_group,
|
||
db=session,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||
|
||
await self._cleanup_invoice_message(user_telegram_id)
|
||
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
||
|
||
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
||
break
|
||
|
||
except Exception as e:
|
||
logger.error(f"⌘ Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
|
||
|
||
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
|
||
|
||
try:
|
||
user_id = payment_data["user_id"]
|
||
payment_id = payment_data["payment_id"]
|
||
|
||
async for session in get_db():
|
||
transaction = await get_transaction_by_external_id(
|
||
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
|
||
)
|
||
|
||
if transaction:
|
||
transaction.description = f"{transaction.description} (платеж отклонен)"
|
||
await session.commit()
|
||
|
||
await self._send_failure_notification(user_id)
|
||
|
||
logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}")
|
||
break
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
|
||
|
||
async def _handle_refund(self, refund_data: Dict[str, Any]):
|
||
|
||
try:
|
||
user_id = refund_data["user_id"]
|
||
amount_kopeks = refund_data["amount_kopeks"]
|
||
payment_id = refund_data["payment_id"]
|
||
|
||
async for session in get_db():
|
||
await create_transaction(
|
||
db=session,
|
||
user_id=user_id,
|
||
type=TransactionType.REFUND,
|
||
amount_kopeks=-amount_kopeks,
|
||
description=f"Возврат Tribute платежа {payment_id}",
|
||
payment_method=PaymentMethod.TRIBUTE,
|
||
external_id=f"refund_{payment_id}",
|
||
is_completed=True
|
||
)
|
||
|
||
user = await get_user_by_telegram_id(session, user_id)
|
||
if user and user.balance_kopeks >= amount_kopeks:
|
||
user.balance_kopeks -= amount_kopeks
|
||
await session.commit()
|
||
|
||
await self._send_refund_notification(user_id, amount_kopeks)
|
||
|
||
logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||
break
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||
|
||
|
||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||
|
||
try:
|
||
amount_rubles = amount_kopeks / 100
|
||
|
||
async for session in get_db():
|
||
user = await get_user_by_telegram_id(session, user_id)
|
||
break
|
||
|
||
# Сначала отправляем стандартное уведомление
|
||
payment_service = PaymentService(self.bot)
|
||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||
|
||
text = (
|
||
f"✅ **Платеж успешно получен!**\n\n"
|
||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||
f"💳 Способ оплаты: Tribute\n"
|
||
f"🎉 Средства зачислены на баланс!\n\n"
|
||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||
f"Спасибо за оплату! 🙏"
|
||
)
|
||
|
||
await self.bot.send_message(
|
||
user_id,
|
||
text,
|
||
reply_markup=keyboard,
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||
from app.services.user_cart_service import user_cart_service
|
||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||
auto_purchase_success = False
|
||
if has_saved_cart:
|
||
try:
|
||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||
session,
|
||
user,
|
||
bot=self.bot,
|
||
)
|
||
except Exception as auto_error:
|
||
logger.error(
|
||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||
user.id,
|
||
auto_error,
|
||
exc_info=True,
|
||
)
|
||
|
||
if auto_purchase_success:
|
||
has_saved_cart = False
|
||
|
||
# Умная автоактивация если автопокупка не сработала
|
||
activation_notification_sent = False
|
||
if not auto_purchase_success:
|
||
try:
|
||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||
session, user, bot=self.bot, topup_amount=amount_kopeks
|
||
)
|
||
except Exception as auto_activate_error:
|
||
logger.error(
|
||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||
user.id,
|
||
auto_activate_error,
|
||
exc_info=True,
|
||
)
|
||
|
||
# Отправляем уведомление только если его ещё не отправили
|
||
if has_saved_cart and self.bot and not activation_notification_sent:
|
||
# Если у пользователя есть сохраненная корзина,
|
||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||
from app.localization.texts import get_texts
|
||
from aiogram import types
|
||
|
||
texts = get_texts(user.language)
|
||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||
total_amount=settings.format_price(amount_kopeks)
|
||
)
|
||
|
||
# Создаем клавиатуру с кнопками
|
||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||
[types.InlineKeyboardButton(
|
||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||
callback_data="subscription_resume_checkout"
|
||
)],
|
||
[types.InlineKeyboardButton(
|
||
text="💰 Мой баланс",
|
||
callback_data="menu_balance"
|
||
)],
|
||
[types.InlineKeyboardButton(
|
||
text="🏠 Главное меню",
|
||
callback_data="back_to_menu"
|
||
)]
|
||
])
|
||
|
||
await self.bot.send_message(
|
||
chat_id=user_id,
|
||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||
f"Обязательно активируйте подписку отдельно!\n\n{cart_message}",
|
||
reply_markup=keyboard
|
||
)
|
||
logger.info(
|
||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||
user_id,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||
async def _send_failure_notification(self, user_id: int):
|
||
|
||
try:
|
||
text = (
|
||
"⌘ **Платеж не прошел**\n\n"
|
||
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
|
||
"Возможные причины:\n"
|
||
"• Недостаточно средств на карте\n"
|
||
"• Технические проблемы банка\n"
|
||
"• Превышен лимит операций\n\n"
|
||
"Попробуйте еще раз или обратитесь в поддержку."
|
||
)
|
||
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")],
|
||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")]
|
||
])
|
||
|
||
await self.bot.send_message(
|
||
user_id,
|
||
text,
|
||
reply_markup=keyboard,
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
|
||
|
||
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
||
|
||
try:
|
||
amount_rubles = amount_kopeks / 100
|
||
|
||
text = (
|
||
f"🔄 **Возврат средств**\n\n"
|
||
f"💰 Сумма возврата: {int(amount_rubles)} ₽\n"
|
||
f"💳 Способ: Tribute\n\n"
|
||
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
|
||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||
)
|
||
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")],
|
||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||
])
|
||
|
||
await self.bot.send_message(
|
||
user_id,
|
||
text,
|
||
reply_markup=keyboard,
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления о возврате: {e}")
|
||
|
||
async def force_process_payment(
|
||
self,
|
||
payment_id: str,
|
||
user_id: int,
|
||
amount_kopeks: int,
|
||
description: str = "Принудительная обработка Tribute платежа"
|
||
) -> bool:
|
||
|
||
try:
|
||
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
|
||
|
||
async for session in get_db():
|
||
user = await get_user_by_telegram_id(session, user_id)
|
||
if not user:
|
||
logger.error(f"⌘ Пользователь {user_id} не найден")
|
||
return False
|
||
|
||
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
|
||
|
||
transaction = await create_transaction(
|
||
db=session,
|
||
user_id=user.id,
|
||
type=TransactionType.DEPOSIT,
|
||
amount_kopeks=amount_kopeks,
|
||
description=description,
|
||
payment_method=PaymentMethod.TRIBUTE,
|
||
external_id=external_id,
|
||
is_completed=True
|
||
)
|
||
|
||
old_balance = user.balance_kopeks
|
||
user.balance_kopeks += amount_kopeks
|
||
user.updated_at = datetime.utcnow()
|
||
|
||
await session.commit()
|
||
|
||
logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп")
|
||
|
||
await self._send_success_notification(user_id, amount_kopeks)
|
||
|
||
logger.info(f"✅ Принудительно обработан платеж {payment_id}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"⌘ Ошибка принудительной обработки: {e}", exc_info=True)
|
||
return False
|
||
|
||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||
return await self.tribute_api.get_payment_status(payment_id)
|
||
|
||
async def create_refund(
|
||
self,
|
||
payment_id: str,
|
||
amount_kopeks: Optional[int] = None,
|
||
reason: str = "Возврат по запросу"
|
||
) -> Optional[Dict[str, Any]]:
|
||
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|