mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT
629 lines
26 KiB
Python
629 lines
26 KiB
Python
"""Mixin for integrating WATA payment links into the payment service."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime
|
||
from importlib import import_module
|
||
from typing import Any, Dict, Optional
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.models import PaymentMethod, TransactionType
|
||
from app.services.subscription_auto_purchase_service import (
|
||
auto_activate_subscription_after_topup,
|
||
auto_purchase_saved_cart_after_topup,
|
||
)
|
||
from app.services.wata_service import WataAPIError, WataService
|
||
from app.utils.user_utils import format_referrer_info
|
||
from app.utils.payment_logger import payment_logger as logger
|
||
|
||
|
||
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
"""Try to find the remote WATA transaction identifier from stored payloads."""
|
||
|
||
def _from_mapping(mapping: Any) -> Optional[str]:
|
||
if isinstance(mapping, str):
|
||
try:
|
||
import json
|
||
|
||
mapping = json.loads(mapping)
|
||
except Exception: # pragma: no cover - defensive parsing
|
||
return None
|
||
if not isinstance(mapping, dict):
|
||
return None
|
||
for key in ("id", "transaction_id", "transactionId"):
|
||
value = mapping.get(key)
|
||
if not value:
|
||
continue
|
||
value_str = str(value)
|
||
if "-" in value_str:
|
||
return value_str
|
||
return None
|
||
|
||
candidate = None
|
||
|
||
if hasattr(payment, "callback_payload"):
|
||
candidate = _from_mapping(getattr(payment, "callback_payload"))
|
||
if candidate:
|
||
return candidate
|
||
|
||
metadata = getattr(payment, "metadata_json", None)
|
||
if isinstance(metadata, dict):
|
||
if "transaction" in metadata:
|
||
candidate = _from_mapping(metadata.get("transaction"))
|
||
if candidate:
|
||
return candidate
|
||
candidate = _from_mapping(metadata)
|
||
if candidate:
|
||
return candidate
|
||
|
||
candidate = _from_mapping(remote_link)
|
||
if candidate:
|
||
return candidate
|
||
|
||
return None
|
||
|
||
|
||
class WataPaymentMixin:
|
||
"""Encapsulates creation and status handling for WATA payment links."""
|
||
|
||
async def create_wata_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
amount_kopeks: int,
|
||
description: str,
|
||
*,
|
||
language: Optional[str] = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
if not getattr(self, "wata_service", None):
|
||
logger.error("WATA service is not initialised")
|
||
return None
|
||
|
||
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
|
||
logger.warning(
|
||
"Сумма WATA меньше минимальной: %s < %s",
|
||
amount_kopeks,
|
||
settings.WATA_MIN_AMOUNT_KOPEKS,
|
||
)
|
||
return None
|
||
|
||
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
|
||
logger.warning(
|
||
"Сумма WATA больше максимальной: %s > %s",
|
||
amount_kopeks,
|
||
settings.WATA_MAX_AMOUNT_KOPEKS,
|
||
)
|
||
return None
|
||
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
order_id = f"wata_{user_id}_{uuid.uuid4().hex[:12]}"
|
||
|
||
try:
|
||
response = await self.wata_service.create_payment_link( # type: ignore[union-attr]
|
||
amount_kopeks=amount_kopeks,
|
||
currency="RUB",
|
||
description=description,
|
||
order_id=order_id,
|
||
)
|
||
except WataAPIError as error:
|
||
logger.error("Ошибка создания WATA платежа: %s", error)
|
||
return None
|
||
except Exception as error: # pragma: no cover - safety net
|
||
logger.exception("Непредвиденная ошибка при создании WATA платежа: %s", error)
|
||
return None
|
||
|
||
payment_link_id = response.get("id")
|
||
payment_url = response.get("url") or response.get("paymentUrl")
|
||
status = response.get("status") or "Opened"
|
||
terminal_public_id = response.get("terminalPublicId")
|
||
success_url = response.get("successRedirectUrl")
|
||
fail_url = response.get("failRedirectUrl")
|
||
|
||
if not payment_link_id:
|
||
logger.error("WATA API не вернула идентификатор платежной ссылки: %s", response)
|
||
return None
|
||
|
||
expiration_raw = response.get("expirationDateTime")
|
||
expires_at = WataService._parse_datetime(expiration_raw)
|
||
|
||
metadata = {
|
||
"response": response,
|
||
"language": language or settings.DEFAULT_LANGUAGE,
|
||
}
|
||
|
||
local_payment = await payment_module.create_wata_payment(
|
||
db=db,
|
||
user_id=user_id,
|
||
payment_link_id=payment_link_id,
|
||
amount_kopeks=amount_kopeks,
|
||
currency="RUB",
|
||
description=description,
|
||
status=status,
|
||
type_=response.get("type"),
|
||
url=payment_url,
|
||
order_id=order_id,
|
||
metadata=metadata,
|
||
expires_at=expires_at,
|
||
terminal_public_id=terminal_public_id,
|
||
success_redirect_url=success_url,
|
||
fail_redirect_url=fail_url,
|
||
)
|
||
|
||
logger.info(
|
||
"Создан WATA платеж %s на %s₽ для пользователя %s",
|
||
payment_link_id,
|
||
amount_kopeks / 100,
|
||
user_id,
|
||
)
|
||
|
||
return {
|
||
"local_payment_id": local_payment.id,
|
||
"payment_link_id": payment_link_id,
|
||
"payment_url": payment_url,
|
||
"status": status,
|
||
"order_id": order_id,
|
||
}
|
||
|
||
async def process_wata_webhook(
|
||
self,
|
||
db: AsyncSession,
|
||
payload: Dict[str, Any],
|
||
) -> bool:
|
||
"""Handles asynchronous webhook notifications from WATA."""
|
||
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
if not isinstance(payload, dict):
|
||
logger.error("WATA webhook payload не является словарём: %s", payload)
|
||
return False
|
||
|
||
order_id_raw = payload.get("orderId")
|
||
payment_link_raw = payload.get("paymentLinkId") or payload.get("id")
|
||
transaction_status_raw = payload.get("transactionStatus")
|
||
|
||
order_id = str(order_id_raw) if order_id_raw else None
|
||
payment_link_id = str(payment_link_raw) if payment_link_raw else None
|
||
transaction_status = (transaction_status_raw or "").strip()
|
||
|
||
if not order_id and not payment_link_id:
|
||
logger.error(
|
||
"WATA webhook без orderId и paymentLinkId: %s",
|
||
payload,
|
||
)
|
||
return False
|
||
|
||
if not transaction_status:
|
||
logger.error("WATA webhook без статуса транзакции: %s", payload)
|
||
return False
|
||
|
||
payment = None
|
||
if order_id:
|
||
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
|
||
if not payment and payment_link_id:
|
||
payment = await payment_module.get_wata_payment_by_link_id(db, payment_link_id)
|
||
|
||
if not payment:
|
||
logger.error(
|
||
"WATA платеж не найден (order_id=%s, payment_link_id=%s)",
|
||
order_id,
|
||
payment_link_id,
|
||
)
|
||
return False
|
||
|
||
status_lower = transaction_status.lower()
|
||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||
metadata["last_webhook"] = payload
|
||
terminal_public_id = (
|
||
payload.get("terminalPublicId")
|
||
or payload.get("terminal_public_id")
|
||
or payload.get("terminalPublicID")
|
||
)
|
||
|
||
update_kwargs: Dict[str, Any] = {
|
||
"metadata": metadata,
|
||
"callback_payload": payload,
|
||
"terminal_public_id": terminal_public_id,
|
||
}
|
||
|
||
if transaction_status:
|
||
update_kwargs["status"] = transaction_status
|
||
update_kwargs["last_status"] = transaction_status
|
||
|
||
if status_lower != "paid" and not payment.is_paid:
|
||
update_kwargs["is_paid"] = False
|
||
|
||
payment = await payment_module.update_wata_payment_status(
|
||
db,
|
||
payment=payment,
|
||
**update_kwargs,
|
||
)
|
||
|
||
if status_lower == "paid":
|
||
if payment.is_paid:
|
||
logger.info(
|
||
"WATA платеж %s уже помечен как оплачен",
|
||
payment.payment_link_id,
|
||
)
|
||
return True
|
||
|
||
await self._finalize_wata_payment(db, payment, payload)
|
||
return True
|
||
|
||
if status_lower == "declined":
|
||
logger.info(
|
||
"WATA платеж %s отклонён",
|
||
payment.payment_link_id,
|
||
)
|
||
|
||
return True
|
||
|
||
async def get_wata_payment_status(
|
||
self,
|
||
db: AsyncSession,
|
||
local_payment_id: int,
|
||
) -> Optional[Dict[str, Any]]:
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
|
||
if not payment:
|
||
return None
|
||
|
||
remote_link: Optional[Dict[str, Any]] = None
|
||
transaction_payload: Optional[Dict[str, Any]] = None
|
||
transaction_id: Optional[str] = None
|
||
|
||
if getattr(self, "wata_service", None) and payment.payment_link_id:
|
||
try:
|
||
remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr]
|
||
except WataAPIError as error:
|
||
logger.error("Ошибка получения WATA ссылки %s: %s", payment.payment_link_id, error)
|
||
except Exception as error: # pragma: no cover - safety net
|
||
logger.exception("Непредвиденная ошибка при запросе WATA ссылки: %s", error)
|
||
|
||
if remote_link:
|
||
remote_status = remote_link.get("status") or payment.status
|
||
if remote_status != payment.status:
|
||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||
existing_metadata["link"] = remote_link
|
||
await payment_module.update_wata_payment_status(
|
||
db,
|
||
payment=payment,
|
||
status=remote_status,
|
||
last_status=remote_status,
|
||
url=remote_link.get("url") or remote_link.get("paymentUrl"),
|
||
metadata=existing_metadata,
|
||
terminal_public_id=remote_link.get("terminalPublicId"),
|
||
)
|
||
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
|
||
|
||
remote_status_normalized = (remote_status or "").lower()
|
||
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
|
||
transaction_id = _extract_transaction_id(payment, remote_link)
|
||
if transaction_id:
|
||
try:
|
||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||
transaction_id
|
||
)
|
||
except WataAPIError as error:
|
||
logger.error(
|
||
"Ошибка получения WATA транзакции %s: %s",
|
||
transaction_id,
|
||
error,
|
||
)
|
||
except Exception as error: # pragma: no cover - safety net
|
||
logger.exception(
|
||
"Непредвиденная ошибка при запросе WATA транзакции %s: %s",
|
||
transaction_id,
|
||
error,
|
||
)
|
||
if not transaction_payload:
|
||
try:
|
||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||
order_id=payment.order_id,
|
||
payment_link_id=payment.payment_link_id,
|
||
status="Paid",
|
||
limit=5,
|
||
)
|
||
items = tx_response.get("items") or []
|
||
for item in items:
|
||
if (item or {}).get("status") == "Paid":
|
||
transaction_payload = item
|
||
break
|
||
except WataAPIError as error:
|
||
logger.error(
|
||
"Ошибка поиска WATA транзакций для %s: %s",
|
||
payment.payment_link_id,
|
||
error,
|
||
)
|
||
except Exception as error: # pragma: no cover - safety net
|
||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||
|
||
if (
|
||
not transaction_payload
|
||
and not payment.is_paid
|
||
and getattr(self, "wata_service", None)
|
||
):
|
||
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
|
||
if fallback_transaction_id:
|
||
try:
|
||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||
fallback_transaction_id
|
||
)
|
||
except WataAPIError as error:
|
||
logger.error(
|
||
"Ошибка повторного запроса WATA транзакции %s: %s",
|
||
fallback_transaction_id,
|
||
error,
|
||
)
|
||
except Exception as error: # pragma: no cover - safety net
|
||
logger.exception(
|
||
"Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
|
||
fallback_transaction_id,
|
||
error,
|
||
)
|
||
|
||
if transaction_payload and not payment.is_paid:
|
||
normalized_status = None
|
||
if isinstance(transaction_payload, dict):
|
||
raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||
if raw_status:
|
||
normalized_status = str(raw_status).lower()
|
||
if normalized_status == "paid":
|
||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||
else:
|
||
logger.debug(
|
||
"WATA транзакция %s в статусе %s, повторная обработка не требуется",
|
||
transaction_id or getattr(payment, "payment_link_id", ""),
|
||
normalized_status or "unknown",
|
||
)
|
||
|
||
return {
|
||
"payment": payment,
|
||
"status": payment.status,
|
||
"is_paid": payment.is_paid,
|
||
"remote_link": remote_link,
|
||
"transaction": transaction_payload,
|
||
}
|
||
|
||
async def _finalize_wata_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
payment: Any,
|
||
transaction_payload: Dict[str, Any],
|
||
) -> Any:
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
if isinstance(transaction_payload, dict):
|
||
paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||
else:
|
||
paid_status = None
|
||
if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
|
||
logger.debug(
|
||
"Неизвестный статус WATA транзакции %s: %s",
|
||
getattr(payment, "payment_link_id", ""),
|
||
paid_status,
|
||
)
|
||
|
||
paid_at = None
|
||
if isinstance(transaction_payload, dict):
|
||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||
if not paid_at and getattr(payment, "paid_at", None):
|
||
paid_at = payment.paid_at
|
||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||
|
||
invoice_message = existing_metadata.get("invoice_message") or {}
|
||
invoice_message_removed = False
|
||
if getattr(self, "bot", None) and invoice_message:
|
||
chat_id = invoice_message.get("chat_id")
|
||
message_id = invoice_message.get("message_id")
|
||
if chat_id and message_id:
|
||
try:
|
||
await self.bot.delete_message(chat_id, message_id)
|
||
except Exception as delete_error: # pragma: no cover - depends on rights
|
||
logger.warning(
|
||
"Не удалось удалить счёт WATA %s: %s",
|
||
message_id,
|
||
delete_error,
|
||
)
|
||
else:
|
||
invoice_message_removed = True
|
||
existing_metadata.pop("invoice_message", None)
|
||
|
||
existing_metadata["transaction"] = transaction_payload
|
||
|
||
await payment_module.update_wata_payment_status(
|
||
db,
|
||
payment=payment,
|
||
status="Paid",
|
||
is_paid=True,
|
||
paid_at=paid_at,
|
||
callback_payload=transaction_payload,
|
||
metadata=existing_metadata,
|
||
)
|
||
|
||
if payment.transaction_id:
|
||
logger.info(
|
||
"WATA платеж %s уже привязан к транзакции %s",
|
||
payment.payment_link_id,
|
||
payment.transaction_id,
|
||
)
|
||
return payment
|
||
|
||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||
if not user:
|
||
logger.error("Пользователь %s не найден при обработке WATA", payment.user_id)
|
||
return payment
|
||
|
||
transaction_external_id = str(transaction_payload.get("id") or transaction_payload.get("transactionId") or "")
|
||
description = f"Пополнение через WATA ({payment.payment_link_id})"
|
||
|
||
transaction = await payment_module.create_transaction(
|
||
db,
|
||
user_id=payment.user_id,
|
||
type=TransactionType.DEPOSIT,
|
||
amount_kopeks=payment.amount_kopeks,
|
||
description=description,
|
||
payment_method=PaymentMethod.WATA,
|
||
external_id=transaction_external_id or payment.payment_link_id,
|
||
is_completed=True,
|
||
)
|
||
|
||
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
|
||
|
||
old_balance = user.balance_kopeks
|
||
was_first_topup = not user.has_made_first_topup
|
||
|
||
user.balance_kopeks += payment.amount_kopeks
|
||
user.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
|
||
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 "🔄 Пополнение"
|
||
|
||
try:
|
||
from app.services.referral_service import process_referral_topup
|
||
|
||
await process_referral_topup(
|
||
db,
|
||
user.id,
|
||
payment.amount_kopeks,
|
||
getattr(self, "bot", None),
|
||
)
|
||
except Exception as error:
|
||
logger.error("Ошибка обработки реферального пополнения WATA: %s", error)
|
||
|
||
if was_first_topup and not user.has_made_first_topup:
|
||
user.has_made_first_topup = True
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
|
||
if getattr(self, "bot", None):
|
||
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=db,
|
||
)
|
||
except Exception as error:
|
||
logger.error("Ошибка отправки админ уведомления WATA: %s", error)
|
||
|
||
if getattr(self, "bot", None):
|
||
try:
|
||
keyboard = await self.build_topup_success_keyboard(user)
|
||
await self.bot.send_message(
|
||
user.telegram_id,
|
||
(
|
||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||
"🦊 Способ: WATA\n"
|
||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||
"Обязательно активируйте подписку отдельно!\n\n"
|
||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||
"Баланс пополнен автоматически!"
|
||
),
|
||
parse_mode="HTML",
|
||
reply_markup=keyboard,
|
||
)
|
||
except Exception as error:
|
||
logger.error("Ошибка отправки уведомления пользователю WATA: %s", error)
|
||
|
||
try:
|
||
from app.services.user_cart_service import user_cart_service
|
||
from aiogram import types
|
||
|
||
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(
|
||
db,
|
||
user,
|
||
bot=getattr(self, "bot", None),
|
||
)
|
||
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(
|
||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.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 getattr(self, "bot", None) and not activation_notification_sent:
|
||
from app.localization.texts import get_texts
|
||
|
||
texts = get_texts(user.language)
|
||
cart_message = texts.t(
|
||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||
"Вы можете продолжить оформление с теми же параметрами.",
|
||
)
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||
callback_data="return_to_saved_cart",
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text="💰 Мой баланс",
|
||
callback_data="menu_balance",
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text="🏠 Главное меню",
|
||
callback_data="back_to_menu",
|
||
)
|
||
],
|
||
]
|
||
)
|
||
|
||
await self.bot.send_message(
|
||
user.telegram_id,
|
||
cart_message,
|
||
reply_markup=keyboard,
|
||
)
|
||
except Exception as error:
|
||
logger.debug("Не удалось отправить напоминание о корзине после WATA: %s", error)
|
||
|
||
return payment
|