Files
remnawave-bedolaga-telegram…/app/services/payment/mulenpay.py
gy9vin 6e1d671df2 feat(traffic): добавлен новый мониторинг трафика v2 с проверкой дельты и snapshot
Новый функционал:
- Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot
- Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API
- Фильтрация по нодам (TRAFFIC_MONIT
2026-01-10 00:47:23 +03:00

605 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Mixin, инкапсулирующий работу с MulenPay."""
from __future__ import annotations
import uuid
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.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
class MulenPayPaymentMixin:
"""Mixin с созданием платежей, обработкой callback и проверкой статусов MulenPay."""
async def create_mulenpay_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
display_name = settings.get_mulenpay_display_name()
display_name_html = settings.get_mulenpay_display_name_html()
if not getattr(self, "mulenpay_service", None):
logger.error("%s сервис не инициализирован", display_name)
return None
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма %s меньше минимальной: %s < %s",
display_name,
amount_kopeks,
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма %s больше максимальной: %s > %s",
display_name,
amount_kopeks,
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module("app.services.payment_service")
try:
payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}"
amount_rubles = amount_kopeks / 100
items = [
{
"description": description[:128],
"quantity": 1,
"price": round(amount_rubles, 2),
"vat_code": settings.MULENPAY_VAT_CODE,
"payment_subject": settings.MULENPAY_PAYMENT_SUBJECT,
"payment_mode": settings.MULENPAY_PAYMENT_MODE,
}
]
response = await self.mulenpay_service.create_payment(
amount_kopeks=amount_kopeks,
description=description,
uuid=payment_uuid,
items=items,
language=language or settings.MULENPAY_LANGUAGE,
website_url=settings.WEBHOOK_URL,
)
if not response:
logger.error("Ошибка создания %s платежа", display_name)
return None
mulen_payment_id = response.get("id")
payment_url = response.get("paymentUrl")
metadata = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
}
local_payment = await payment_module.create_mulenpay_payment(
db=db,
user_id=user_id,
amount_kopeks=amount_kopeks,
uuid=payment_uuid,
description=description,
payment_url=payment_url,
mulen_payment_id=mulen_payment_id,
currency="RUB",
status="created",
metadata=metadata,
)
logger.info(
"Создан %s платеж %s на %s₽ для пользователя %s",
display_name,
mulen_payment_id,
amount_rubles,
user_id,
)
return {
"local_payment_id": local_payment.id,
"mulen_payment_id": mulen_payment_id,
"payment_url": payment_url,
"amount_kopeks": amount_kopeks,
"uuid": payment_uuid,
"status": "created",
}
except Exception as error:
logger.error("Ошибка создания %s платежа: %s", display_name, error)
return None
async def process_mulenpay_callback(
self,
db: AsyncSession,
callback_data: Dict[str, Any],
) -> bool:
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
display_name = settings.get_mulenpay_display_name()
display_name_html = settings.get_mulenpay_display_name_html()
try:
payment_module = import_module("app.services.payment_service")
uuid_value = callback_data.get("uuid")
payment_status_raw = (
callback_data.get("payment_status")
or callback_data.get("status")
or callback_data.get("paymentStatus")
)
payment_status = (payment_status_raw or "").lower()
mulen_payment_id_raw = callback_data.get("id")
mulen_payment_id_int: Optional[int] = None
if mulen_payment_id_raw is not None:
try:
mulen_payment_id_int = int(mulen_payment_id_raw)
except (TypeError, ValueError):
mulen_payment_id_int = None
amount_value = callback_data.get("amount")
logger.debug(
"%s callback: uuid=%s, status=%s, amount=%s",
display_name,
uuid_value,
payment_status,
amount_value,
)
if not uuid_value and mulen_payment_id_raw is None:
logger.error("%s callback без uuid и id", display_name)
return False
payment = None
if uuid_value:
payment = await payment_module.get_mulenpay_payment_by_uuid(db, uuid_value)
if not payment and mulen_payment_id_int is not None:
payment = await payment_module.get_mulenpay_payment_by_mulen_id(
db, mulen_payment_id_int
)
if not payment:
logger.error(
"%s платеж не найден (uuid=%s, id=%s)",
display_name,
uuid_value,
mulen_payment_id_raw,
)
return False
metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None):
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 bot rights
logger.warning(
"Не удалось удалить %s счёт %s: %s",
display_name,
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if payment.is_paid:
if invoice_message_removed:
try:
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=metadata,
)
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные %s после удаления счёта: %s",
display_name,
error,
)
logger.info(
"%s платеж %s уже обработан, игнорируем повторный callback",
display_name,
payment.uuid,
)
return True
if payment_status == "success":
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status="success",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
metadata=metadata,
)
if payment.transaction_id:
logger.info(
"Для %s платежа %s уже создана транзакция",
display_name,
payment.uuid,
)
return True
payment_description = getattr(
payment,
"description",
f"платеж {payment.uuid}",
)
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через {display_name}: {payment_description}",
payment_method=PaymentMethod.MULENPAY,
external_id=payment.uuid,
is_completed=True,
)
await payment_module.link_mulenpay_payment_to_transaction(
db=db,
payment=payment,
transaction_id=transaction.id,
)
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден при обработке %s",
payment.user_id,
display_name,
)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
await payment_module.add_user_balance(
db,
user,
payment.amount_kopeks,
f"Пополнение {display_name}: {payment.amount_kopeks // 100}",
create_transaction=False,
)
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(
"Ошибка обработки реферального пополнения %s: %s",
display_name,
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
# После коммита отношения пользователя могли быть сброшены, поэтому
# повторно загружаем пользователя с предзагрузкой зависимостей
user = await payment_module.get_user_by_id(db, user.id)
if not user:
logger.error(
"Пользователь %s не найден при повторной загрузке после %s",
payment.user_id,
display_name,
)
return False
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 "🔄 Пополнение"
)
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(
"Ошибка отправки уведомления о пополнении %s: %s",
display_name,
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"
f"🦊 Способ: {display_name_html}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю %s: %s",
display_name,
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(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан %s платеж %s для пользователя %s",
display_name,
payment.uuid,
payment.user_id,
)
return True
if payment_status == "cancel":
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status="canceled",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info("%s платеж %s отменен", display_name, payment.uuid)
return True
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=payment_status or "unknown",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info(
"Получен %s callback со статусом %s для платежа %s",
display_name,
payment_status,
payment.uuid,
)
return True
except Exception as error:
logger.error(
"Ошибка обработки %s callback: %s",
display_name,
error,
exc_info=True,
)
return False
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
"""Приводит числовой статус MulenPay к строковому значению."""
mapping = {
0: "created",
1: "processing",
2: "canceled",
3: "success",
4: "error",
5: "hold",
6: "hold",
}
return mapping.get(status_code, "unknown")
async def get_mulenpay_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
display_name = settings.get_mulenpay_display_name()
try:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id)
if not payment:
return None
remote_status_code = None
remote_data = None
if getattr(self, "mulenpay_service", None) and payment.mulen_payment_id is not None:
response = await self.mulenpay_service.get_payment(
payment.mulen_payment_id
)
if response:
if isinstance(response, dict) and response.get("success"):
remote_data = response.get("payment")
elif isinstance(response, dict) and "status" in response and "id" in response:
remote_data = response
if not remote_data and getattr(self, "mulenpay_service", None):
list_response = await self.mulenpay_service.list_payments(
limit=100,
uuid=payment.uuid,
)
items = []
if isinstance(list_response, dict):
items = list_response.get("items") or []
if items:
for candidate in items:
if not isinstance(candidate, dict):
continue
candidate_id = candidate.get("id")
candidate_uuid = candidate.get("uuid")
if (
(candidate_id is not None and candidate_id == payment.mulen_payment_id)
or (candidate_uuid and candidate_uuid == payment.uuid)
):
remote_data = candidate
break
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status_code": remote_status_code,
"remote_data": remote_data,
}
except Exception as error:
logger.error(
"Ошибка получения статуса %s: %s",
display_name,
error,
exc_info=True,
)
return None