diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 4f7e0bbb..81a222c0 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -1035,21 +1035,21 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
if settings.is_yookassa_enabled():
- keyboard.append([
- InlineKeyboardButton(
- text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
- callback_data=_build_callback("yookassa")
- )
- ])
-
if settings.YOOKASSA_SBP_ENABLED:
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"),
- callback_data=_build_callback("yookassa_sbp")
+ callback_data=_build_callback("yookassa_sbp"),
)
])
+ keyboard.append([
+ InlineKeyboardButton(
+ text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
+ callback_data=_build_callback("yookassa"),
+ )
+ ])
+
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py
new file mode 100644
index 00000000..870c33af
--- /dev/null
+++ b/app/services/payment/__init__.py
@@ -0,0 +1,23 @@
+"""Пакет с mixin-классами, делающими платёжный сервис модульным.
+
+Здесь собираем все вспомогательные части, чтобы основной `PaymentService`
+оставался компактным и импортировал только нужные компоненты.
+"""
+
+from .common import PaymentCommonMixin
+from .stars import TelegramStarsMixin
+from .yookassa import YooKassaPaymentMixin
+from .tribute import TributePaymentMixin
+from .cryptobot import CryptoBotPaymentMixin
+from .mulenpay import MulenPayPaymentMixin
+from .pal24 import Pal24PaymentMixin
+
+__all__ = [
+ "PaymentCommonMixin",
+ "TelegramStarsMixin",
+ "YooKassaPaymentMixin",
+ "TributePaymentMixin",
+ "CryptoBotPaymentMixin",
+ "MulenPayPaymentMixin",
+ "Pal24PaymentMixin",
+]
diff --git a/app/services/payment/common.py b/app/services/payment/common.py
new file mode 100644
index 00000000..c30c2b16
--- /dev/null
+++ b/app/services/payment/common.py
@@ -0,0 +1,147 @@
+"""Общие инструменты платёжного сервиса.
+
+В этом модуле собраны методы, которые нужны всем платёжным каналам:
+построение клавиатур, базовые уведомления и стандартная обработка
+успешных платежей.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+
+from app.config import settings
+from app.database.crud.user import get_user_by_telegram_id
+from app.database.database import get_db
+from app.localization.texts import get_texts
+from app.services.subscription_checkout_service import (
+ has_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+
+logger = logging.getLogger(__name__)
+
+
+class PaymentCommonMixin:
+ """Mixin с базовой логикой, которую используют остальные платёжные блоки."""
+
+ async def build_topup_success_keyboard(self, user: Any) -> InlineKeyboardMarkup:
+ """Формирует клавиатуру по завершении платежа, подстраиваясь под пользователя."""
+ # Загружаем нужные тексты с учётом выбранного языка пользователя.
+ texts = get_texts(user.language if user else "ru")
+
+ # Определяем статус подписки, чтобы показать подходящую кнопку.
+ has_active_subscription = bool(
+ user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
+ )
+
+ first_button = build_miniapp_or_callback_button(
+ text=(
+ texts.MENU_EXTEND_SUBSCRIPTION
+ if has_active_subscription
+ else texts.MENU_BUY_SUBSCRIPTION
+ ),
+ callback_data=(
+ "subscription_extend" if has_active_subscription else "menu_buy"
+ ),
+ )
+
+ keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
+
+ # Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
+ if user:
+ draft_exists = await has_subscription_checkout_draft(user.id)
+ if should_offer_checkout_resume(user, draft_exists):
+ keyboard_rows.append([
+ build_miniapp_or_callback_button(
+ text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
+ callback_data="subscription_resume_checkout",
+ )
+ ])
+
+ # Стандартные кнопки быстрого доступа к балансу и главному меню.
+ keyboard_rows.append([
+ build_miniapp_or_callback_button(
+ text="💰 Мой баланс",
+ callback_data="menu_balance",
+ )
+ ])
+ keyboard_rows.append([
+ InlineKeyboardButton(
+ text="🏠 Главное меню",
+ callback_data="back_to_menu",
+ )
+ ])
+
+ return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ async def _send_payment_success_notification(
+ self,
+ telegram_id: int,
+ amount_kopeks: int,
+ user: Any | None = None,
+ ) -> None:
+ """Отправляет пользователю уведомление об успешном платеже."""
+ if not getattr(self, "bot", None):
+ # Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
+ return
+
+ if user is None:
+ try:
+ async for db in get_db():
+ user = await get_user_by_telegram_id(db, telegram_id)
+ break
+ except Exception as fetch_error:
+ logger.warning(
+ "Не удалось получить пользователя %s для уведомления: %s",
+ telegram_id,
+ fetch_error,
+ )
+ user = None
+
+ try:
+ keyboard = await self.build_topup_success_keyboard(user)
+
+ message = (
+ "✅ Платеж успешно завершен!\n\n"
+ f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
+ "💳 Способ: Банковская карта (YooKassa)\n\n"
+ "Средства зачислены на ваш баланс!"
+ )
+
+ await self.bot.send_message(
+ chat_id=telegram_id,
+ text=message,
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления пользователю %s: %s",
+ telegram_id,
+ error,
+ )
+
+ async def process_successful_payment(
+ self,
+ payment_id: str,
+ amount_kopeks: int,
+ user_id: int,
+ payment_method: str,
+ ) -> bool:
+ """Общая точка учёта успешных платежей (используется провайдерами при необходимости)."""
+ try:
+ logger.info(
+ "Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s",
+ payment_id,
+ amount_kopeks / 100,
+ user_id,
+ payment_method,
+ )
+ return True
+ except Exception as error:
+ logger.error("Ошибка обработки платежа %s: %s", payment_id, error)
+ return False
diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py
new file mode 100644
index 00000000..69d1eb60
--- /dev/null
+++ b/app/services/payment/cryptobot.py
@@ -0,0 +1,299 @@
+"""Mixin с логикой обработки платежей CryptoBot."""
+
+from __future__ import annotations
+
+import logging
+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.utils.currency_converter import currency_converter
+from app.utils.user_utils import format_referrer_info
+
+logger = logging.getLogger(__name__)
+
+
+class CryptoBotPaymentMixin:
+ """Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
+
+ async def create_cryptobot_payment(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ amount_usd: float,
+ asset: str = "USDT",
+ description: str = "Пополнение баланса",
+ payload: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """Создаёт invoice в CryptoBot и сохраняет локальную запись."""
+ if not getattr(self, "cryptobot_service", None):
+ logger.error("CryptoBot сервис не инициализирован")
+ return None
+
+ try:
+ amount_str = f"{amount_usd:.2f}"
+
+ invoice_data = await self.cryptobot_service.create_invoice(
+ amount=amount_str,
+ asset=asset,
+ description=description,
+ payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
+ expires_in=settings.get_cryptobot_invoice_expires_seconds(),
+ )
+
+ if not invoice_data:
+ logger.error("Ошибка создания CryptoBot invoice")
+ return None
+
+ cryptobot_crud = import_module("app.database.crud.cryptobot")
+
+ local_payment = await cryptobot_crud.create_cryptobot_payment(
+ db=db,
+ user_id=user_id,
+ invoice_id=str(invoice_data["invoice_id"]),
+ amount=amount_str,
+ asset=asset,
+ status="active",
+ description=description,
+ payload=payload,
+ bot_invoice_url=invoice_data.get("bot_invoice_url"),
+ mini_app_invoice_url=invoice_data.get("mini_app_invoice_url"),
+ web_app_invoice_url=invoice_data.get("web_app_invoice_url"),
+ )
+
+ logger.info(
+ "Создан CryptoBot платеж %s на %s %s для пользователя %s",
+ invoice_data["invoice_id"],
+ amount_str,
+ asset,
+ user_id,
+ )
+
+ return {
+ "local_payment_id": local_payment.id,
+ "invoice_id": str(invoice_data["invoice_id"]),
+ "amount": amount_str,
+ "asset": asset,
+ "bot_invoice_url": invoice_data.get("bot_invoice_url"),
+ "mini_app_invoice_url": invoice_data.get("mini_app_invoice_url"),
+ "web_app_invoice_url": invoice_data.get("web_app_invoice_url"),
+ "status": "active",
+ "created_at": (
+ local_payment.created_at.isoformat()
+ if local_payment.created_at
+ else None
+ ),
+ }
+
+ except Exception as error:
+ logger.error("Ошибка создания CryptoBot платежа: %s", error)
+ return None
+
+ async def process_cryptobot_webhook(
+ self,
+ db: AsyncSession,
+ webhook_data: Dict[str, Any],
+ ) -> bool:
+ """Обрабатывает webhook от CryptoBot и начисляет средства пользователю."""
+ try:
+ update_type = webhook_data.get("update_type")
+
+ if update_type != "invoice_paid":
+ logger.info("Пропуск CryptoBot webhook с типом: %s", update_type)
+ return True
+
+ payload = webhook_data.get("payload", {})
+ invoice_id = str(payload.get("invoice_id"))
+ status = "paid"
+
+ if not invoice_id:
+ logger.error("CryptoBot webhook без invoice_id")
+ return False
+
+ cryptobot_crud = import_module("app.database.crud.cryptobot")
+ payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(
+ db, invoice_id
+ )
+ if not payment:
+ logger.error("CryptoBot платеж не найден в БД: %s", invoice_id)
+ return False
+
+ if payment.status == "paid":
+ logger.info("CryptoBot платеж %s уже обработан", invoice_id)
+ return True
+
+ paid_at_str = payload.get("paid_at")
+ if paid_at_str:
+ try:
+ paid_at = datetime.fromisoformat(
+ paid_at_str.replace("Z", "+00:00")
+ ).replace(tzinfo=None)
+ except Exception:
+ paid_at = datetime.utcnow()
+ else:
+ paid_at = datetime.utcnow()
+
+ updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
+ db, invoice_id, status, paid_at
+ )
+
+ if not updated_payment.transaction_id:
+ amount_usd = updated_payment.amount_float
+
+ try:
+ amount_rubles = await currency_converter.usd_to_rub(amount_usd)
+ amount_kopeks = int(amount_rubles * 100)
+ conversion_rate = (
+ amount_rubles / amount_usd if amount_usd > 0 else 0
+ )
+ logger.info(
+ "Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
+ amount_usd,
+ amount_rubles,
+ conversion_rate,
+ )
+ except Exception as error:
+ logger.warning(
+ "Ошибка конвертации валют для платежа %s, используем курс 1:1: %s",
+ invoice_id,
+ error,
+ )
+ amount_rubles = amount_usd
+ amount_kopeks = int(amount_usd * 100)
+ conversion_rate = 1.0
+
+ if amount_kopeks <= 0:
+ logger.error(
+ "Некорректная сумма после конвертации: %s копеек для платежа %s",
+ amount_kopeks,
+ invoice_id,
+ )
+ return False
+
+ payment_service_module = import_module("app.services.payment_service")
+ transaction = await payment_service_module.create_transaction(
+ db,
+ user_id=updated_payment.user_id,
+ type=TransactionType.DEPOSIT,
+ amount_kopeks=amount_kopeks,
+ description=(
+ "Пополнение через CryptoBot "
+ f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)"
+ ),
+ payment_method=PaymentMethod.CRYPTOBOT,
+ external_id=invoice_id,
+ is_completed=True,
+ )
+
+ await cryptobot_crud.link_cryptobot_payment_to_transaction(
+ db, invoice_id, transaction.id
+ )
+
+ get_user_by_id = payment_service_module.get_user_by_id
+ user = await get_user_by_id(db, updated_payment.user_id)
+ if not user:
+ logger.error(
+ "Пользователь с ID %s не найден при пополнении баланса",
+ updated_payment.user_id,
+ )
+ return False
+
+ 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 = getattr(user, "promo_group", None)
+ subscription = getattr(user, "subscription", None)
+ referrer_info = format_referrer_info(user)
+ topup_status = (
+ "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
+ )
+
+ await db.commit()
+
+ try:
+ from app.services.referral_service import process_referral_topup
+
+ await process_referral_topup(
+ db,
+ user.id,
+ amount_kopeks,
+ getattr(self, "bot", None),
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка обработки реферального пополнения CryptoBot: %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(
+ "Ошибка отправки уведомления о пополнении CryptoBot: %s",
+ error,
+ )
+
+ if getattr(self, "bot", None):
+ try:
+ keyboard = await self.build_topup_success_keyboard(user)
+
+ await self.bot.send_message(
+ user.telegram_id,
+ (
+ "✅ Пополнение успешно!\n\n"
+ f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
+ f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
+ f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
+ f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
+ "Баланс пополнен автоматически!"
+ ),
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ logger.info(
+ "✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
+ user.telegram_id,
+ f"{amount_rubles:.2f}",
+ updated_payment.asset,
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления о пополнении CryptoBot: %s",
+ error,
+ )
+
+ return True
+
+ except Exception as error:
+ logger.error(
+ "Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
+ )
+ return False
diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py
new file mode 100644
index 00000000..7b1bd286
--- /dev/null
+++ b/app/services/payment/mulenpay.py
@@ -0,0 +1,400 @@
+"""Mixin, инкапсулирующий работу с MulenPay."""
+
+from __future__ import annotations
+
+import logging
+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.utils.user_utils import format_referrer_info
+
+logger = logging.getLogger(__name__)
+
+
+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."""
+ if not getattr(self, "mulenpay_service", None):
+ logger.error("MulenPay сервис не инициализирован")
+ return None
+
+ if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
+ logger.warning(
+ "Сумма MulenPay меньше минимальной: %s < %s",
+ amount_kopeks,
+ settings.MULENPAY_MIN_AMOUNT_KOPEKS,
+ )
+ return None
+
+ if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
+ logger.warning(
+ "Сумма MulenPay больше максимальной: %s > %s",
+ 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("Ошибка создания MulenPay платежа")
+ 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(
+ "Создан MulenPay платеж %s на %s₽ для пользователя %s",
+ 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("Ошибка создания MulenPay платежа: %s", error)
+ return None
+
+ async def process_mulenpay_callback(
+ self,
+ db: AsyncSession,
+ callback_data: Dict[str, Any],
+ ) -> bool:
+ """Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
+ try:
+ payment_module = import_module("app.services.payment_service")
+ uuid_value = callback_data.get("uuid")
+ payment_status = (callback_data.get("payment_status") 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(
+ "MulenPay callback: uuid=%s, status=%s, amount=%s",
+ uuid_value,
+ payment_status,
+ amount_value,
+ )
+
+ if not uuid_value and mulen_payment_id_raw is None:
+ logger.error("MulenPay callback без uuid и id")
+ 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(
+ "MulenPay платеж не найден (uuid=%s, id=%s)",
+ uuid_value,
+ mulen_payment_id_raw,
+ )
+ return False
+
+ if payment.is_paid:
+ logger.info(
+ "MulenPay платеж %s уже обработан, игнорируем повторный callback",
+ 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,
+ )
+
+ if payment.transaction_id:
+ logger.info(
+ "Для MulenPay платежа %s уже создана транзакция",
+ 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"Пополнение через MulenPay: {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 не найден при обработке MulenPay",
+ payment.user_id,
+ )
+ 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"Пополнение MulenPay: {payment.amount_kopeks // 100}₽",
+ )
+
+ if was_first_topup and not user.has_made_first_topup:
+ user.has_made_first_topup = True
+ await db.commit()
+
+ await db.refresh(user)
+
+ promo_group = getattr(user, "promo_group", None)
+ 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(
+ "Ошибка отправки уведомления о пополнении MulenPay: %s",
+ error,
+ )
+
+ if getattr(self, "bot", None):
+ try:
+ keyboard = await self.build_topup_success_keyboard(user)
+ await self.bot.send_message(
+ user.telegram_id,
+ (
+ "✅ Пополнение успешно!\n\n"
+ f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
+ "🦊 Способ: Mulen Pay\n"
+ f"🆔 Транзакция: {transaction.id}\n\n"
+ "Баланс пополнен автоматически!"
+ ),
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления пользователю MulenPay: %s",
+ error,
+ )
+
+ logger.info(
+ "✅ Обработан MulenPay платеж %s для пользователя %s",
+ 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("MulenPay платеж %s отменен", 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(
+ "Получен MulenPay callback со статусом %s для платежа %s",
+ payment_status,
+ payment.uuid,
+ )
+ return True
+
+ except Exception as error:
+ logger.error("Ошибка обработки MulenPay callback: %s", 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]]:
+ """Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
+ 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 and response.get("success"):
+ remote_data = response.get("payment")
+ 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(
+ "Ошибка получения статуса MulenPay: %s", error, exc_info=True
+ )
+ return None
diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py
new file mode 100644
index 00000000..18a584c0
--- /dev/null
+++ b/app/services/payment/pal24.py
@@ -0,0 +1,431 @@
+"""Mixin для интеграции с PayPalych (Pal24)."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from importlib import import_module
+import uuid
+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.pal24_service import Pal24APIError
+from app.utils.user_utils import format_referrer_info
+
+logger = logging.getLogger(__name__)
+
+
+class Pal24PaymentMixin:
+ """Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
+
+ async def create_pal24_payment(
+ self,
+ db: AsyncSession,
+ *,
+ user_id: int,
+ amount_kopeks: int,
+ description: str,
+ language: str,
+ ttl_seconds: Optional[int] = None,
+ payer_email: Optional[str] = None,
+ payment_method: Optional[str] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """Создаёт счёт в Pal24 и сохраняет локальную запись."""
+ service = getattr(self, "pal24_service", None)
+ if not service or not service.is_configured:
+ logger.error("Pal24 сервис не инициализирован")
+ return None
+
+ if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
+ logger.warning(
+ "Сумма Pal24 меньше минимальной: %s < %s",
+ amount_kopeks,
+ settings.PAL24_MIN_AMOUNT_KOPEKS,
+ )
+ return None
+
+ if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
+ logger.warning(
+ "Сумма Pal24 больше максимальной: %s > %s",
+ amount_kopeks,
+ settings.PAL24_MAX_AMOUNT_KOPEKS,
+ )
+ return None
+
+ order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
+
+ custom_payload = {
+ "user_id": user_id,
+ "amount_kopeks": amount_kopeks,
+ "language": language,
+ }
+
+ normalized_payment_method = (payment_method or "SBP").upper()
+
+ payment_module = import_module("app.services.payment_service")
+
+ try:
+ response = await service.create_bill(
+ amount_kopeks=amount_kopeks,
+ user_id=user_id,
+ order_id=order_id,
+ description=description,
+ ttl_seconds=ttl_seconds,
+ custom_payload=custom_payload,
+ payer_email=payer_email,
+ payment_method=normalized_payment_method,
+ )
+ except Pal24APIError as error:
+ logger.error("Ошибка Pal24 API при создании счета: %s", error)
+ return None
+
+ if not response.get("success", True):
+ logger.error("Pal24 вернул ошибку при создании счета: %s", response)
+ return None
+
+ bill_id = response.get("bill_id")
+ if not bill_id:
+ logger.error("Pal24 не вернул bill_id: %s", response)
+ return None
+
+ def _pick_url(*keys: str) -> Optional[str]:
+ for key in keys:
+ value = response.get(key)
+ if value:
+ return str(value)
+ return None
+
+ transfer_url = _pick_url(
+ "transfer_url",
+ "transferUrl",
+ "transfer_link",
+ "transferLink",
+ "transfer",
+ "sbp_url",
+ "sbpUrl",
+ "sbp_link",
+ "sbpLink",
+ )
+ card_url = _pick_url(
+ "link_url",
+ "linkUrl",
+ "link",
+ "card_url",
+ "cardUrl",
+ "card_link",
+ "cardLink",
+ "payment_url",
+ "paymentUrl",
+ "url",
+ )
+ link_page_url = _pick_url(
+ "link_page_url",
+ "linkPageUrl",
+ "page_url",
+ "pageUrl",
+ )
+
+ primary_link = transfer_url or link_page_url or card_url
+ secondary_link = link_page_url or card_url or transfer_url
+
+ metadata_links = {
+ key: value
+ for key, value in {
+ "sbp": transfer_url,
+ "card": card_url,
+ "page": link_page_url,
+ }.items()
+ if value
+ }
+
+ metadata_payload = {
+ "user_id": user_id,
+ "amount_kopeks": amount_kopeks,
+ "description": description,
+ "links": metadata_links,
+ "raw_response": response,
+ }
+
+ payment = await payment_module.create_pal24_payment(
+ db,
+ user_id=user_id,
+ bill_id=bill_id,
+ amount_kopeks=amount_kopeks,
+ description=description,
+ status=response.get("status", "NEW"),
+ type_=response.get("type", "normal"),
+ currency=response.get("currency", "RUB"),
+ link_url=transfer_url or card_url,
+ link_page_url=link_page_url or primary_link,
+ order_id=order_id,
+ ttl=ttl_seconds,
+ metadata=metadata_payload,
+ )
+
+ logger.info(
+ "Создан Pal24 счет %s для пользователя %s (%s₽)",
+ bill_id,
+ user_id,
+ amount_kopeks / 100,
+ )
+
+ payment_status = getattr(payment, "status", response.get("status", "NEW"))
+
+ return {
+ "local_payment_id": payment.id,
+ "bill_id": bill_id,
+ "order_id": order_id,
+ "amount_kopeks": amount_kopeks,
+ "primary_url": primary_link,
+ "secondary_url": secondary_link,
+ "link_url": transfer_url,
+ "card_url": card_url,
+ "payment_method": normalized_payment_method,
+ "metadata_links": metadata_links,
+ "status": payment_status,
+ }
+
+ async def process_pal24_postback(
+ self,
+ db: AsyncSession,
+ postback: Dict[str, Any],
+ ) -> bool:
+ """Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
+ try:
+ payment_module = import_module("app.services.payment_service")
+
+ def _first_non_empty(*values: Optional[str]) -> Optional[str]:
+ for value in values:
+ if value:
+ return value
+ return None
+
+ payment_id = _first_non_empty(
+ postback.get("id"),
+ postback.get("TrsId"),
+ postback.get("TrsID"),
+ )
+ bill_id = _first_non_empty(
+ postback.get("bill_id"),
+ postback.get("billId"),
+ postback.get("BillId"),
+ postback.get("BillID"),
+ )
+ order_id = _first_non_empty(
+ postback.get("order_id"),
+ postback.get("orderId"),
+ postback.get("InvId"),
+ postback.get("InvID"),
+ )
+ status = (postback.get("status") or postback.get("Status") or "").upper()
+
+ if not bill_id and not order_id:
+ logger.error("Pal24 postback без идентификаторов: %s", postback)
+ return False
+
+ payment = None
+ if bill_id:
+ payment = await payment_module.get_pal24_payment_by_bill_id(db, bill_id)
+ if not payment and order_id:
+ payment = await payment_module.get_pal24_payment_by_order_id(db, order_id)
+
+ if not payment:
+ logger.error("Pal24 платеж не найден: %s / %s", bill_id, order_id)
+ return False
+
+ if payment.is_paid:
+ logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
+ return True
+
+ if status in {"PAID", "SUCCESS"}:
+ user = await payment_module.get_user_by_id(db, payment.user_id)
+ if not user:
+ logger.error(
+ "Пользователь %s не найден для Pal24 платежа",
+ payment.user_id,
+ )
+ return False
+
+ await payment_module.update_pal24_payment_status(
+ db,
+ payment,
+ status=status,
+ postback_payload=postback,
+ payment_id=payment_id,
+ )
+
+ if payment.transaction_id:
+ logger.info(
+ "Для Pal24 платежа %s уже создана транзакция",
+ payment.bill_id,
+ )
+ return True
+
+ transaction = await payment_module.create_transaction(
+ db,
+ user_id=payment.user_id,
+ type=TransactionType.DEPOSIT,
+ amount_kopeks=payment.amount_kopeks,
+ description=f"Пополнение через Pal24 ({payment_id})",
+ payment_method=PaymentMethod.PAL24,
+ external_id=str(payment_id) if payment_id else payment.bill_id,
+ is_completed=True,
+ )
+
+ await payment_module.link_pal24_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()
+
+ promo_group = getattr(user, "promo_group", None)
+ subscription = getattr(user, "subscription", None)
+ referrer_info = format_referrer_info(user)
+ topup_status = (
+ "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
+ )
+
+ await db.commit()
+
+ 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(
+ "Ошибка обработки реферального пополнения Pal24: %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(
+ "Ошибка отправки админ уведомления Pal24: %s", error
+ )
+
+ if getattr(self, "bot", None):
+ try:
+ keyboard = await self.build_topup_success_keyboard(user)
+ await self.bot.send_message(
+ user.telegram_id,
+ (
+ "✅ Пополнение успешно!\n\n"
+ f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
+ "🦊 Способ: PayPalych\n"
+ f"🆔 Транзакция: {transaction.id}\n\n"
+ "Баланс пополнен автоматически!"
+ ),
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления пользователю Pal24: %s",
+ error,
+ )
+
+ logger.info(
+ "✅ Обработан Pal24 платеж %s для пользователя %s",
+ payment.bill_id,
+ payment.user_id,
+ )
+
+ return True
+
+ await payment_module.update_pal24_payment_status(
+ db,
+ payment,
+ status=status or "UNKNOWN",
+ postback_payload=postback,
+ payment_id=payment_id,
+ )
+ logger.info(
+ "Обновили Pal24 платеж %s до статуса %s",
+ payment.bill_id,
+ status,
+ )
+ return True
+
+ except Exception as error:
+ logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
+ return False
+
+ async def get_pal24_payment_status(
+ self,
+ db: AsyncSession,
+ local_payment_id: int,
+ ) -> Optional[Dict[str, Any]]:
+ """Запрашивает актуальный статус платежа у Pal24 и синхронизирует локальную запись."""
+ try:
+ payment_module = import_module("app.services.payment_service")
+
+ payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
+ if not payment:
+ return None
+
+ remote_status = None
+ remote_data = None
+
+ service = getattr(self, "pal24_service", None)
+ if service and payment.bill_id:
+ try:
+ response = await service.get_bill_status(payment.bill_id)
+ remote_data = response
+ remote_status = response.get("status") or response.get(
+ "bill", {}
+ ).get("status")
+
+ if remote_status and remote_status != payment.status:
+ await payment_module.update_pal24_payment_status(
+ db,
+ payment,
+ status=str(remote_status).upper(),
+ )
+ payment = await payment_module.get_pal24_payment_by_id(
+ db, local_payment_id
+ )
+ except Pal24APIError as error:
+ logger.error(
+ "Ошибка Pal24 API при получении статуса: %s", error
+ )
+
+ return {
+ "payment": payment,
+ "status": payment.status,
+ "is_paid": payment.is_paid,
+ "remote_status": remote_status,
+ "remote_data": remote_data,
+ }
+
+ except Exception as error:
+ logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
+ return None
diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py
new file mode 100644
index 00000000..7822e86d
--- /dev/null
+++ b/app/services/payment/stars.py
@@ -0,0 +1,249 @@
+"""Логика Telegram Stars вынесена в отдельный mixin.
+
+Методы здесь отвечают только за работу с звёздами, что позволяет держать
+основной сервис компактным и облегчает тестирование конкретных сценариев.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
+from typing import Optional
+
+from aiogram.types import LabeledPrice
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import get_user_by_id
+from app.database.models import PaymentMethod, TransactionType
+from app.external.telegram_stars import TelegramStarsService
+from app.utils.user_utils import format_referrer_info
+
+logger = logging.getLogger(__name__)
+
+
+class TelegramStarsMixin:
+ """Mixin с операциями создания и обработки платежей через Telegram Stars."""
+
+ async def create_stars_invoice(
+ self,
+ amount_kopeks: int,
+ description: str,
+ payload: Optional[str] = None,
+ *,
+ stars_amount: Optional[int] = None,
+ ) -> str:
+ """Создаёт invoice в Telegram Stars, автоматически рассчитывая количество звёзд."""
+ if not self.bot or not getattr(self, "stars_service", None):
+ raise ValueError("Bot instance required for Stars payments")
+
+ try:
+ amount_rubles = Decimal(amount_kopeks) / Decimal(100)
+
+ # Если количество звёзд не задано, вычисляем его из курса.
+ if stars_amount is None:
+ rate = Decimal(str(settings.get_stars_rate()))
+ if rate <= 0:
+ raise ValueError("Stars rate must be positive")
+
+ normalized_stars = (amount_rubles / rate).to_integral_value(
+ rounding=ROUND_FLOOR
+ )
+ stars_amount = int(normalized_stars) or 1
+
+ if stars_amount <= 0:
+ raise ValueError("Stars amount must be positive")
+
+ invoice_link = await self.bot.create_invoice_link(
+ title="Пополнение баланса VPN",
+ description=f"{description} (≈{stars_amount} ⭐)",
+ payload=payload or f"balance_topup_{amount_kopeks}",
+ provider_token="",
+ currency="XTR",
+ prices=[LabeledPrice(label="Пополнение", amount=stars_amount)],
+ )
+
+ logger.info(
+ "Создан Stars invoice на %s звезд (~%s)",
+ stars_amount,
+ settings.format_price(amount_kopeks),
+ )
+ return invoice_link
+
+ except Exception as error:
+ logger.error("Ошибка создания Stars invoice: %s", error)
+ raise
+
+ async def process_stars_payment(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ stars_amount: int,
+ payload: str,
+ telegram_payment_charge_id: str,
+ ) -> bool:
+ """Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
+ del payload # payload пока не используется, но оставляем аргумент для совместимости.
+ try:
+ rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
+ stars_amount
+ )
+ amount_kopeks = int(
+ (rubles_amount * Decimal(100)).to_integral_value(
+ rounding=ROUND_HALF_UP
+ )
+ )
+
+ transaction = await create_transaction(
+ db=db,
+ user_id=user_id,
+ type=TransactionType.DEPOSIT,
+ amount_kopeks=amount_kopeks,
+ description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
+ payment_method=PaymentMethod.TELEGRAM_STARS,
+ external_id=telegram_payment_charge_id,
+ is_completed=True,
+ )
+
+ user = await get_user_by_id(db, user_id)
+ if not user:
+ logger.error(
+ "Пользователь с ID %s не найден при обработке Stars платежа",
+ user_id,
+ )
+ return False
+
+ # Запоминаем старые значения, чтобы корректно построить уведомления.
+ 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 = getattr(user, "promo_group", None)
+ subscription = getattr(user, "subscription", None)
+ referrer_info = format_referrer_info(user)
+ topup_status = (
+ "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
+ )
+
+ await db.commit()
+
+ description_for_referral = (
+ f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
+ )
+ logger.info(
+ "🔍 Проверка реферальной логики для описания: '%s'",
+ description_for_referral,
+ )
+
+ lower_description = description_for_referral.lower()
+ contains_allowed_keywords = any(
+ word in lower_description
+ for word in ["пополнение", "stars", "yookassa", "topup"]
+ )
+ contains_forbidden_keywords = any(
+ word in lower_description for word in ["комиссия", "бонус"]
+ )
+ allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
+
+ if allow_referral:
+ logger.info(
+ "🔞 Вызов process_referral_topup для пользователя %s",
+ user_id,
+ )
+ try:
+ from app.services.referral_service import process_referral_topup
+
+ await process_referral_topup(
+ db, user_id, amount_kopeks, getattr(self, "bot", None)
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка обработки реферального пополнения: %s", error
+ )
+ else:
+ logger.info(
+ "❌ Описание '%s' не подходит для реферальной логики",
+ description_for_referral,
+ )
+
+ if was_first_topup and not user.has_made_first_topup:
+ user.has_made_first_topup = True
+ await db.commit()
+
+ await db.refresh(user)
+
+ logger.info(
+ "💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
+ user.telegram_id,
+ old_balance,
+ user.balance_kopeks,
+ amount_kopeks,
+ )
+
+ 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(
+ "Ошибка отправки уведомления о пополнении Stars: %s",
+ error,
+ )
+
+ if getattr(self, "bot", None):
+ try:
+ keyboard = await self.build_topup_success_keyboard(user)
+
+ await self.bot.send_message(
+ user.telegram_id,
+ (
+ "✅ Пополнение успешно!\n\n"
+ f"⭐ Звезд: {stars_amount}\n"
+ f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
+ "🦊 Способ: Telegram Stars\n"
+ f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
+ "Баланс пополнен автоматически!"
+ ),
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ logger.info(
+ "✅ Отправлено уведомление пользователю %s о пополнении на %s",
+ user.telegram_id,
+ settings.format_price(amount_kopeks),
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления о пополнении Stars: %s",
+ error,
+ )
+
+ logger.info(
+ "✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
+ user_id,
+ stars_amount,
+ settings.format_price(amount_kopeks),
+ )
+ return True
+
+ except Exception as error:
+ logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
+ return False
diff --git a/app/services/payment/tribute.py b/app/services/payment/tribute.py
new file mode 100644
index 00000000..ec253169
--- /dev/null
+++ b/app/services/payment/tribute.py
@@ -0,0 +1,71 @@
+"""Mixin для платежей Tribute — простая вспомогательная обвязка."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import logging
+from typing import Dict
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class TributePaymentMixin:
+ """Содержит методы создания платежей и проверки webhook от Tribute."""
+
+ async def create_tribute_payment(
+ self,
+ amount_kopeks: int,
+ user_id: int,
+ description: str,
+ ) -> str:
+ """Формирует URL оплаты для Tribute и логирует результат."""
+ if not settings.TRIBUTE_ENABLED:
+ raise ValueError("Tribute payments are disabled")
+
+ try:
+ # Сохраняем полезную информацию для метрик и отладки.
+ payment_data = {
+ "amount": amount_kopeks,
+ "currency": "RUB",
+ "description": description,
+ "user_id": user_id,
+ "callback_url": f"{settings.WEBHOOK_URL}/tribute/callback",
+ }
+ del payment_data # данные пока не отправляются вовне, но оставляем структуру для будущего API.
+
+ payment_url = (
+ f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
+ )
+
+ logger.info(
+ "Создан Tribute платеж на %s₽ для пользователя %s",
+ amount_kopeks / 100,
+ user_id,
+ )
+ return payment_url
+
+ except Exception as error:
+ logger.error("Ошибка создания Tribute платежа: %s", error)
+ raise
+
+ def verify_tribute_webhook(self, data: Dict[str, object], signature: str) -> bool:
+ """Проверяет подпись запроса, присланного Tribute."""
+ if not settings.TRIBUTE_API_KEY:
+ return False
+
+ try:
+ message = str(data).encode()
+ expected_signature = hmac.new(
+ settings.TRIBUTE_API_KEY.encode(),
+ message,
+ hashlib.sha256,
+ ).hexdigest()
+
+ return hmac.compare_digest(signature, expected_signature)
+
+ except Exception as error:
+ logger.error("Ошибка проверки Tribute webhook: %s", error)
+ return False
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
new file mode 100644
index 00000000..a6cc0f77
--- /dev/null
+++ b/app/services/payment/yookassa.py
@@ -0,0 +1,362 @@
+"""Функции работы с YooKassa вынесены в dedicated mixin.
+
+Такое разделение облегчает поддержку и делает очевидным, какая часть
+отвечает за конкретного провайдера.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from importlib import import_module
+from typing import Any, Dict, Optional, TYPE_CHECKING
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.models import PaymentMethod, TransactionType
+from app.utils.user_utils import format_referrer_info
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from app.database.models import YooKassaPayment
+
+
+class YooKassaPaymentMixin:
+ """Mixin с операциями по созданию и подтверждению платежей YooKassa."""
+
+ async def create_yookassa_payment(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ amount_kopeks: int,
+ description: str,
+ receipt_email: Optional[str] = None,
+ receipt_phone: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """Создаёт обычный платёж в YooKassa и сохраняет локальную запись."""
+ if not getattr(self, "yookassa_service", None):
+ logger.error("YooKassa сервис не инициализирован")
+ return None
+
+ payment_module = import_module("app.services.payment_service")
+
+ try:
+ amount_rubles = amount_kopeks / 100
+
+ payment_metadata = metadata.copy() if metadata else {}
+ payment_metadata.update(
+ {
+ "user_id": str(user_id),
+ "amount_kopeks": str(amount_kopeks),
+ "type": "balance_topup",
+ }
+ )
+
+ yookassa_response = await self.yookassa_service.create_payment(
+ amount=amount_rubles,
+ currency="RUB",
+ description=description,
+ metadata=payment_metadata,
+ receipt_email=receipt_email,
+ receipt_phone=receipt_phone,
+ )
+
+ if not yookassa_response or yookassa_response.get("error"):
+ logger.error(
+ "Ошибка создания платежа YooKassa: %s", yookassa_response
+ )
+ return None
+
+ yookassa_created_at: Optional[datetime] = None
+ if yookassa_response.get("created_at"):
+ try:
+ dt_with_tz = datetime.fromisoformat(
+ yookassa_response["created_at"].replace("Z", "+00:00")
+ )
+ yookassa_created_at = dt_with_tz.replace(tzinfo=None)
+ except Exception as error:
+ logger.warning("Не удалось распарсить created_at: %s", error)
+ yookassa_created_at = None
+
+ local_payment = await payment_module.create_yookassa_payment(
+ db=db,
+ user_id=user_id,
+ yookassa_payment_id=yookassa_response["id"],
+ amount_kopeks=amount_kopeks,
+ currency="RUB",
+ description=description,
+ status=yookassa_response["status"],
+ confirmation_url=yookassa_response.get("confirmation_url"),
+ metadata_json=payment_metadata,
+ payment_method_type=None,
+ yookassa_created_at=yookassa_created_at,
+ test_mode=yookassa_response.get("test_mode", False),
+ )
+
+ logger.info(
+ "Создан платеж YooKassa %s на %s₽ для пользователя %s",
+ yookassa_response["id"],
+ amount_rubles,
+ user_id,
+ )
+
+ return {
+ "local_payment_id": local_payment.id,
+ "yookassa_payment_id": yookassa_response["id"],
+ "confirmation_url": yookassa_response.get("confirmation_url"),
+ "amount_kopeks": amount_kopeks,
+ "amount_rubles": amount_rubles,
+ "status": yookassa_response["status"],
+ "created_at": local_payment.created_at,
+ }
+
+ except Exception as error:
+ logger.error("Ошибка создания платежа YooKassa: %s", error)
+ return None
+
+ async def create_yookassa_sbp_payment(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ amount_kopeks: int,
+ description: str,
+ receipt_email: Optional[str] = None,
+ receipt_phone: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """Создаёт платёж по СБП через YooKassa."""
+ if not getattr(self, "yookassa_service", None):
+ logger.error("YooKassa сервис не инициализирован")
+ return None
+
+ payment_module = import_module("app.services.payment_service")
+
+ try:
+ amount_rubles = amount_kopeks / 100
+
+ payment_metadata = metadata.copy() if metadata else {}
+ payment_metadata.update(
+ {
+ "user_id": str(user_id),
+ "amount_kopeks": str(amount_kopeks),
+ "type": "balance_topup_sbp",
+ }
+ )
+
+ yookassa_response = (
+ await self.yookassa_service.create_sbp_payment(
+ amount=amount_rubles,
+ currency="RUB",
+ description=description,
+ metadata=payment_metadata,
+ receipt_email=receipt_email,
+ receipt_phone=receipt_phone,
+ )
+ )
+
+ if not yookassa_response or yookassa_response.get("error"):
+ logger.error(
+ "Ошибка создания платежа YooKassa СБП: %s",
+ yookassa_response,
+ )
+ return None
+
+ local_payment = await payment_module.create_yookassa_payment(
+ db=db,
+ user_id=user_id,
+ yookassa_payment_id=yookassa_response["id"],
+ amount_kopeks=amount_kopeks,
+ currency="RUB",
+ description=description,
+ status=yookassa_response["status"],
+ confirmation_url=yookassa_response.get("confirmation_url"),
+ metadata_json=payment_metadata,
+ payment_method_type="bank_card",
+ yookassa_created_at=None,
+ test_mode=yookassa_response.get("test_mode", False),
+ )
+
+ logger.info(
+ "Создан платеж YooKassa СБП %s на %s₽ для пользователя %s",
+ yookassa_response["id"],
+ amount_rubles,
+ user_id,
+ )
+
+ confirmation_token = (
+ yookassa_response.get("confirmation", {}) or {}
+ ).get("confirmation_token")
+
+ return {
+ "local_payment_id": local_payment.id,
+ "yookassa_payment_id": yookassa_response["id"],
+ "confirmation_url": yookassa_response.get("confirmation_url"),
+ "confirmation_token": confirmation_token,
+ "amount_kopeks": amount_kopeks,
+ "amount_rubles": amount_rubles,
+ "status": yookassa_response["status"],
+ "created_at": local_payment.created_at,
+ }
+
+ except Exception as error:
+ logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
+ return None
+
+ async def _process_successful_yookassa_payment(
+ self,
+ db: AsyncSession,
+ payment: "YooKassaPayment",
+ ) -> bool:
+ """Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
+ try:
+ payment_module = import_module("app.services.payment_service")
+
+ payment_description = getattr(payment, "description", "YooKassa платеж")
+
+ transaction = await payment_module.create_transaction(
+ db=db,
+ user_id=payment.user_id,
+ type=TransactionType.DEPOSIT,
+ amount_kopeks=payment.amount_kopeks,
+ description=f"Пополнение через YooKassa: {payment_description}",
+ payment_method=PaymentMethod.YOOKASSA,
+ external_id=payment.yookassa_payment_id,
+ is_completed=True,
+ )
+
+ await payment_module.link_yookassa_payment_to_transaction(
+ db,
+ payment.yookassa_payment_id,
+ transaction.id,
+ )
+
+ user = await payment_module.get_user_by_id(db, payment.user_id)
+ if user:
+ old_balance = getattr(user, "balance_kopeks", 0)
+ was_first_topup = not getattr(user, "has_made_first_topup", False)
+
+ user.balance_kopeks += payment.amount_kopeks
+ user.updated_at = datetime.utcnow()
+
+ promo_group = getattr(user, "promo_group", None)
+ subscription = getattr(user, "subscription", None)
+ referrer_info = format_referrer_info(user)
+ topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
+
+ await db.commit()
+
+ 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(
+ "Ошибка обработки реферального пополнения YooKassa: %s",
+ error,
+ )
+
+ if was_first_topup and not getattr(user, "has_made_first_topup", False):
+ 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(
+ "Ошибка отправки уведомления админам о YooKassa пополнении: %s",
+ error,
+ )
+
+ if getattr(self, "bot", None):
+ try:
+ await self._send_payment_success_notification(
+ user.telegram_id,
+ payment.amount_kopeks,
+ user=user,
+ )
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления о платеже: %s", error
+ )
+
+ logger.info(
+ "Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
+ payment.yookassa_payment_id,
+ payment.user_id,
+ payment.amount_kopeks / 100,
+ )
+
+ return True
+
+ except Exception as error:
+ logger.error(
+ "Ошибка обработки успешного платежа YooKassa %s: %s",
+ payment.yookassa_payment_id,
+ error,
+ )
+ return False
+
+ async def process_yookassa_webhook(
+ self,
+ db: AsyncSession,
+ event: Dict[str, Any],
+ ) -> bool:
+ """Обрабатывает входящий webhook YooKassa и синхронизирует состояние платежа."""
+ event_object = event.get("object", {})
+ yookassa_payment_id = event_object.get("id")
+
+ if not yookassa_payment_id:
+ logger.warning("Webhook без payment id: %s", event)
+ return False
+
+ payment_module = import_module("app.services.payment_service")
+
+ payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
+ if not payment:
+ logger.warning(
+ "Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
+ )
+ return False
+
+ payment.status = event_object.get("status", payment.status)
+ payment.confirmation_url = event_object.get("confirmation_url")
+
+ current_paid = getattr(payment, "paid", False)
+ payment.paid = event_object.get("paid", current_paid)
+
+ await db.commit()
+ await db.refresh(payment)
+
+ if payment.status == "succeeded" and payment.paid:
+ return await self._process_successful_yookassa_payment(db, payment)
+
+ logger.info(
+ "Webhook YooKassa обновил платеж %s до статуса %s",
+ yookassa_payment_id,
+ payment.status,
+ )
+ return True
diff --git a/app/services/payment_service.py b/app/services/payment_service.py
index bdf88b55..c105e986 100644
--- a/app/services/payment_service.py
+++ b/app/services/payment_service.py
@@ -1,1665 +1,186 @@
+"""Агрегирующий сервис, собирающий все платёжные модули."""
+
+from __future__ import annotations
+
import logging
-import hashlib
-import hmac
-import uuid
-from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR
-from typing import Optional, Dict, Any
-from datetime import datetime
+from importlib import import_module
+from typing import Optional
+
from aiogram import Bot
-from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
-from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
-from app.services.yookassa_service import YooKassaService
-from app.external.telegram_stars import TelegramStarsService
-from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
-from app.database.crud.transaction import create_transaction
-from app.database.crud.user import (
- add_user_balance,
- get_user_by_id,
- get_user_by_telegram_id,
-)
-from app.database.models import TransactionType, PaymentMethod
+from app.utils.currency_converter import currency_converter # noqa: F401
from app.external.cryptobot import CryptoBotService
-from app.utils.currency_converter import currency_converter
-from app.database.database import get_db
-from app.localization.texts import get_texts
-from app.utils.user_utils import format_referrer_info
-from app.services.subscription_checkout_service import (
- has_subscription_checkout_draft,
- should_offer_checkout_resume,
-)
+from app.external.telegram_stars import TelegramStarsService
from app.services.mulenpay_service import MulenPayService
-from app.services.pal24_service import Pal24Service, Pal24APIError
-from app.utils.miniapp_buttons import build_miniapp_or_callback_button
-from app.database.crud.mulenpay import (
- create_mulenpay_payment,
- get_mulenpay_payment_by_local_id,
- get_mulenpay_payment_by_uuid,
- get_mulenpay_payment_by_mulen_id,
- update_mulenpay_payment_status,
- link_mulenpay_payment_to_transaction,
-)
-from app.database.crud.pal24 import (
- create_pal24_payment,
- get_pal24_payment_by_bill_id,
- get_pal24_payment_by_id,
- get_pal24_payment_by_order_id,
- link_pal24_payment_to_transaction,
- update_pal24_payment_status,
+from app.services.pal24_service import Pal24Service
+from app.services.payment import (
+ CryptoBotPaymentMixin,
+ MulenPayPaymentMixin,
+ Pal24PaymentMixin,
+ PaymentCommonMixin,
+ TelegramStarsMixin,
+ TributePaymentMixin,
+ YooKassaPaymentMixin,
)
+from app.services.yookassa_service import YooKassaService
logger = logging.getLogger(__name__)
-class PaymentService:
-
- def __init__(self, bot: Optional[Bot] = None):
+# --- Совместимость: экспортируем функции, которые активно мокаются в тестах ---
+
+
+async def create_yookassa_payment(*args, **kwargs):
+ yk_crud = import_module("app.database.crud.yookassa")
+ return await yk_crud.create_yookassa_payment(*args, **kwargs)
+
+
+async def link_yookassa_payment_to_transaction(*args, **kwargs):
+ yk_crud = import_module("app.database.crud.yookassa")
+ return await yk_crud.link_yookassa_payment_to_transaction(*args, **kwargs)
+
+
+async def get_yookassa_payment_by_id(*args, **kwargs):
+ yk_crud = import_module("app.database.crud.yookassa")
+ return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs)
+
+
+async def create_transaction(*args, **kwargs):
+ transaction_crud = import_module("app.database.crud.transaction")
+ return await transaction_crud.create_transaction(*args, **kwargs)
+
+
+async def add_user_balance(*args, **kwargs):
+ user_crud = import_module("app.database.crud.user")
+ return await user_crud.add_user_balance(*args, **kwargs)
+
+
+async def get_user_by_id(*args, **kwargs):
+ user_crud = import_module("app.database.crud.user")
+ return await user_crud.get_user_by_id(*args, **kwargs)
+
+
+async def get_user_by_telegram_id(*args, **kwargs):
+ user_crud = import_module("app.database.crud.user")
+ return await user_crud.get_user_by_telegram_id(*args, **kwargs)
+
+
+async def create_mulenpay_payment(*args, **kwargs):
+ mulenpay_crud = import_module("app.database.crud.mulenpay")
+ return await mulenpay_crud.create_mulenpay_payment(*args, **kwargs)
+
+
+async def get_mulenpay_payment_by_uuid(*args, **kwargs):
+ mulenpay_crud = import_module("app.database.crud.mulenpay")
+ return await mulenpay_crud.get_mulenpay_payment_by_uuid(*args, **kwargs)
+
+
+async def get_mulenpay_payment_by_mulen_id(*args, **kwargs):
+ mulenpay_crud = import_module("app.database.crud.mulenpay")
+ return await mulenpay_crud.get_mulenpay_payment_by_mulen_id(*args, **kwargs)
+
+
+async def get_mulenpay_payment_by_local_id(*args, **kwargs):
+ mulenpay_crud = import_module("app.database.crud.mulenpay")
+ return await mulenpay_crud.get_mulenpay_payment_by_local_id(*args, **kwargs)
+
+
+async def update_mulenpay_payment_status(*args, **kwargs):
+ mulenpay_crud = import_module("app.database.crud.mulenpay")
+ return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
+
+
+async def link_mulenpay_payment_to_transaction(*args, **kwargs):
+ mulenpay_crud = import_module("app.database.crud.mulenpay")
+ return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
+
+
+async def create_pal24_payment(*args, **kwargs):
+ pal_crud = import_module("app.database.crud.pal24")
+ return await pal_crud.create_pal24_payment(*args, **kwargs)
+
+
+async def get_pal24_payment_by_bill_id(*args, **kwargs):
+ pal_crud = import_module("app.database.crud.pal24")
+ return await pal_crud.get_pal24_payment_by_bill_id(*args, **kwargs)
+
+
+async def get_pal24_payment_by_order_id(*args, **kwargs):
+ pal_crud = import_module("app.database.crud.pal24")
+ return await pal_crud.get_pal24_payment_by_order_id(*args, **kwargs)
+
+
+async def get_pal24_payment_by_id(*args, **kwargs):
+ pal_crud = import_module("app.database.crud.pal24")
+ return await pal_crud.get_pal24_payment_by_id(*args, **kwargs)
+
+
+async def update_pal24_payment_status(*args, **kwargs):
+ pal_crud = import_module("app.database.crud.pal24")
+ return await pal_crud.update_pal24_payment_status(*args, **kwargs)
+
+
+async def link_pal24_payment_to_transaction(*args, **kwargs):
+ pal_crud = import_module("app.database.crud.pal24")
+ return await pal_crud.link_pal24_payment_to_transaction(*args, **kwargs)
+
+
+async def create_cryptobot_payment(*args, **kwargs):
+ crypto_crud = import_module("app.database.crud.cryptobot")
+ return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
+
+
+async def get_cryptobot_payment_by_invoice_id(*args, **kwargs):
+ crypto_crud = import_module("app.database.crud.cryptobot")
+ return await crypto_crud.get_cryptobot_payment_by_invoice_id(*args, **kwargs)
+
+
+async def update_cryptobot_payment_status(*args, **kwargs):
+ crypto_crud = import_module("app.database.crud.cryptobot")
+ return await crypto_crud.update_cryptobot_payment_status(*args, **kwargs)
+
+
+async def link_cryptobot_payment_to_transaction(*args, **kwargs):
+ crypto_crud = import_module("app.database.crud.cryptobot")
+ return await crypto_crud.link_cryptobot_payment_to_transaction(*args, **kwargs)
+
+
+class PaymentService(
+ PaymentCommonMixin,
+ TelegramStarsMixin,
+ YooKassaPaymentMixin,
+ TributePaymentMixin,
+ CryptoBotPaymentMixin,
+ MulenPayPaymentMixin,
+ Pal24PaymentMixin,
+):
+ """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
+
+ def __init__(self, bot: Optional[Bot] = None) -> None:
+ # Бот нужен для отправки уведомлений и создания звёздных инвойсов.
self.bot = bot
- self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
+ # Ниже инициализируем службы-обёртки только если соответствующий провайдер включён.
+ self.yookassa_service = (
+ YooKassaService() if settings.is_yookassa_enabled() else None
+ )
self.stars_service = TelegramStarsService(bot) if bot else None
- self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
- self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None
- self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None
-
- async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
- texts = get_texts(user.language if user else "ru")
-
- has_active_subscription = (
- user
- and user.subscription
- and not user.subscription.is_trial
- and user.subscription.is_active
+ self.cryptobot_service = (
+ CryptoBotService() if settings.is_cryptobot_enabled() else None
+ )
+ self.mulenpay_service = (
+ MulenPayService() if settings.is_mulenpay_enabled() else None
+ )
+ self.pal24_service = (
+ Pal24Service() if settings.is_pal24_enabled() else None
)
- first_button = build_miniapp_or_callback_button(
- text=(
- texts.MENU_EXTEND_SUBSCRIPTION
- if has_active_subscription
- else texts.MENU_BUY_SUBSCRIPTION
- ),
- callback_data=(
- "subscription_extend" if has_active_subscription else "menu_buy"
- ),
+ logger.debug(
+ "PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
+ "MulenPay=%s, Pal24=%s)",
+ bool(self.yookassa_service),
+ bool(self.stars_service),
+ bool(self.cryptobot_service),
+ bool(self.mulenpay_service),
+ bool(self.pal24_service),
)
-
- keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
-
- if user:
- draft_exists = await has_subscription_checkout_draft(user.id)
- if should_offer_checkout_resume(user, draft_exists):
- keyboard_rows.append([
- build_miniapp_or_callback_button(
- text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
- callback_data="subscription_resume_checkout",
- )
- ])
-
- keyboard_rows.append([
- build_miniapp_or_callback_button(text="💰 Мой баланс", callback_data="menu_balance")
- ])
- keyboard_rows.append([
- InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")
- ])
-
- return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
-
- async def create_stars_invoice(
- self,
- amount_kopeks: int,
- description: str,
- payload: Optional[str] = None,
- *,
- stars_amount: Optional[int] = None,
- ) -> str:
-
- if not self.bot or not self.stars_service:
- raise ValueError("Bot instance required for Stars payments")
-
- try:
- amount_rubles = Decimal(amount_kopeks) / Decimal(100)
-
- if stars_amount is None:
- rate = Decimal(str(settings.get_stars_rate()))
- if rate <= 0:
- raise ValueError("Stars rate must be positive")
-
- normalized_stars = (amount_rubles / rate).to_integral_value(
- rounding=ROUND_FLOOR
- )
- stars_amount = int(normalized_stars)
- if stars_amount <= 0:
- stars_amount = 1
-
- if stars_amount <= 0:
- raise ValueError("Stars amount must be positive")
-
- invoice_link = await self.bot.create_invoice_link(
- title="Пополнение баланса VPN",
- description=f"{description} (≈{stars_amount} ⭐)",
- payload=payload or f"balance_topup_{amount_kopeks}",
- provider_token="",
- currency="XTR",
- prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
- )
-
- logger.info(
- "Создан Stars invoice на %s звезд (~%s)",
- stars_amount,
- settings.format_price(amount_kopeks),
- )
- return invoice_link
-
- except Exception as e:
- logger.error(f"Ошибка создания Stars invoice: {e}")
- raise
-
- async def process_stars_payment(
- self,
- db: AsyncSession,
- user_id: int,
- stars_amount: int,
- payload: str,
- telegram_payment_charge_id: str
- ) -> bool:
- try:
- rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
- amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
-
- transaction = await create_transaction(
- db=db,
- user_id=user_id,
- type=TransactionType.DEPOSIT,
- amount_kopeks=amount_kopeks,
- description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
- payment_method=PaymentMethod.TELEGRAM_STARS,
- external_id=telegram_payment_charge_id,
- is_completed=True
- )
-
- user = await get_user_by_id(db, user_id)
- if user:
- 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 = getattr(user, "promo_group", None)
- subscription = getattr(user, "subscription", None)
- referrer_info = format_referrer_info(user)
- topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
-
- await db.commit()
-
- description_for_referral = (
- f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
- )
- logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
-
- if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
- logger.info(f"🔞 Вызов process_referral_topup для пользователя {user_id}")
- try:
- from app.services.referral_service import process_referral_topup
- await process_referral_topup(db, user_id, amount_kopeks, self.bot)
- except Exception as e:
- logger.error(f"Ошибка обработки реферального пополнения: {e}")
- else:
- logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
-
- if was_first_topup and not user.has_made_first_topup:
- user.has_made_first_topup = True
- await db.commit()
-
- await db.refresh(user)
-
- logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
-
- if self.bot:
- 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 e:
- logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
-
- if self.bot:
- try:
- keyboard = await self.build_topup_success_keyboard(user)
-
- await self.bot.send_message(
- user.telegram_id,
- f"✅ Пополнение успешно!\n\n"
- f"⭐ Звезд: {stars_amount}\n"
- f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
- f"🦊 Способ: Telegram Stars\n"
- f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
- f"Баланс пополнен автоматически!",
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- logger.info(
- "✅ Отправлено уведомление пользователю %s о пополнении на %s",
- user.telegram_id,
- settings.format_price(amount_kopeks),
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
-
- logger.info(
- "✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
- user_id,
- stars_amount,
- settings.format_price(amount_kopeks),
- )
- return True
- else:
- logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
- return False
-
- except Exception as e:
- logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
- return False
-
- async def create_yookassa_payment(
- self,
- db: AsyncSession,
- user_id: int,
- amount_kopeks: int,
- description: str,
- receipt_email: Optional[str] = None,
- receipt_phone: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None
- ) -> Optional[Dict[str, Any]]:
-
- if not self.yookassa_service:
- logger.error("YooKassa сервис не инициализирован")
- return None
-
- try:
- amount_rubles = amount_kopeks / 100
-
- payment_metadata = metadata or {}
- payment_metadata.update({
- "user_id": str(user_id),
- "amount_kopeks": str(amount_kopeks),
- "type": "balance_topup"
- })
-
- yookassa_response = await self.yookassa_service.create_payment(
- amount=amount_rubles,
- currency="RUB",
- description=description,
- metadata=payment_metadata,
- receipt_email=receipt_email,
- receipt_phone=receipt_phone
- )
-
- if not yookassa_response or yookassa_response.get("error"):
- logger.error(f"Ошибка создания платежа YooKassa: {yookassa_response}")
- return None
-
- yookassa_created_at = None
- if yookassa_response.get("created_at"):
- try:
- dt_with_tz = datetime.fromisoformat(
- yookassa_response["created_at"].replace('Z', '+00:00')
- )
- yookassa_created_at = dt_with_tz.replace(tzinfo=None)
- except Exception as e:
- logger.warning(f"Не удалось парсить created_at: {e}")
- yookassa_created_at = None
-
- local_payment = await create_yookassa_payment(
- db=db,
- user_id=user_id,
- yookassa_payment_id=yookassa_response["id"],
- amount_kopeks=amount_kopeks,
- currency="RUB",
- description=description,
- status=yookassa_response["status"],
- confirmation_url=yookassa_response.get("confirmation_url"),
- metadata_json=payment_metadata,
- payment_method_type=None,
- yookassa_created_at=yookassa_created_at,
- test_mode=yookassa_response.get("test_mode", False)
- )
-
- logger.info(f"Создан платеж YooKassa {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
-
- return {
- "local_payment_id": local_payment.id,
- "yookassa_payment_id": yookassa_response["id"],
- "confirmation_url": yookassa_response.get("confirmation_url"),
- "amount_kopeks": amount_kopeks,
- "amount_rubles": amount_rubles,
- "status": yookassa_response["status"],
- "created_at": local_payment.created_at
- }
-
- except Exception as e:
- logger.error(f"Ошибка создания платежа YooKassa: {e}")
- return None
-
- async def create_yookassa_sbp_payment(
- self,
- db: AsyncSession,
- user_id: int,
- amount_kopeks: int,
- description: str,
- receipt_email: Optional[str] = None,
- receipt_phone: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None
- ) -> Optional[Dict[str, Any]]:
-
- if not self.yookassa_service:
- logger.error("YooKassa сервис не инициализирован")
- return None
-
- try:
- amount_rubles = amount_kopeks / 100
-
- payment_metadata = metadata or {}
- payment_metadata.update({
- "user_id": str(user_id),
- "amount_kopeks": str(amount_kopeks),
- "type": "balance_topup_sbp"
- })
-
- yookassa_response = await self.yookassa_service.create_sbp_payment(
- amount=amount_rubles,
- currency="RUB",
- description=description,
- metadata=payment_metadata,
- receipt_email=receipt_email,
- receipt_phone=receipt_phone
- )
-
- if not yookassa_response or yookassa_response.get("error"):
- logger.error(f"Ошибка создания платежа YooKassa СБП: {yookassa_response}")
- return None
-
- yookassa_created_at = None
- if yookassa_response.get("created_at"):
- try:
- dt_with_tz = datetime.fromisoformat(
- yookassa_response["created_at"].replace('Z', '+00:00')
- )
- yookassa_created_at = dt_with_tz.replace(tzinfo=None)
- except Exception as e:
- logger.warning(f"Не удалось парсить created_at: {e}")
- yookassa_created_at = None
-
- confirmation_token = None
- if yookassa_response.get("confirmation"):
- confirmation_token = yookassa_response["confirmation"].get("confirmation_token")
-
- if confirmation_token:
- payment_metadata["confirmation_token"] = confirmation_token
-
- local_payment = await create_yookassa_payment(
- db=db,
- user_id=user_id,
- yookassa_payment_id=yookassa_response["id"],
- amount_kopeks=amount_kopeks,
- currency="RUB",
- description=description,
- status=yookassa_response["status"],
- confirmation_url=yookassa_response.get("confirmation_url"),
- metadata_json=payment_metadata,
- payment_method_type="bank_card",
- yookassa_created_at=yookassa_created_at,
- test_mode=yookassa_response.get("test_mode", False)
- )
-
- logger.info(f"Создан платеж YooKassa СБП {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
-
- return {
- "local_payment_id": local_payment.id,
- "yookassa_payment_id": yookassa_response["id"],
- "confirmation_url": yookassa_response.get("confirmation_url"),
- "confirmation_token": confirmation_token,
- "amount_kopeks": amount_kopeks,
- "amount_rubles": amount_rubles,
- "status": yookassa_response["status"],
- "created_at": local_payment.created_at
- }
-
- except Exception as e:
- logger.error(f"Ошибка создания платежа YooKassa СБП: {e}")
- return None
-
- async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
- try:
- from app.database.crud.yookassa import (
- get_yookassa_payment_by_id,
- update_yookassa_payment_status,
- link_yookassa_payment_to_transaction
- )
- from app.database.crud.transaction import create_transaction
- from app.database.models import TransactionType, PaymentMethod
-
- payment_object = webhook_data.get("object", {})
- yookassa_payment_id = payment_object.get("id")
- status = payment_object.get("status")
- paid = payment_object.get("paid", False)
-
- if not yookassa_payment_id:
- logger.error("Webhook без ID платежа")
- return False
-
- payment = await get_yookassa_payment_by_id(db, yookassa_payment_id)
- if not payment:
- logger.error(f"Платеж не найден в БД: {yookassa_payment_id}")
- return False
-
- captured_at = None
- if status == "succeeded":
- captured_at = datetime.utcnow()
-
- updated_payment = await update_yookassa_payment_status(
- db,
- yookassa_payment_id,
- status,
- is_paid=paid,
- is_captured=(status == "succeeded"),
- captured_at=captured_at,
- payment_method_type=payment_object.get("payment_method", {}).get("type")
- )
-
- if status == "succeeded" and paid and not updated_payment.transaction_id:
- transaction = await create_transaction(
- db,
- user_id=updated_payment.user_id,
- type=TransactionType.DEPOSIT,
- amount_kopeks=updated_payment.amount_kopeks,
- description=f"Пополнение через YooKassa ({yookassa_payment_id[:8]}...)",
- payment_method=PaymentMethod.YOOKASSA,
- external_id=yookassa_payment_id,
- is_completed=True
- )
-
- await link_yookassa_payment_to_transaction(
- db, yookassa_payment_id, transaction.id
- )
-
- user = await get_user_by_id(db, updated_payment.user_id)
- if user:
- old_balance = user.balance_kopeks
- was_first_topup = not user.has_made_first_topup
-
- user.balance_kopeks += updated_payment.amount_kopeks
- user.updated_at = datetime.utcnow()
-
- promo_group = getattr(user, "promo_group", None)
- subscription = getattr(user, "subscription", None)
- referrer_info = format_referrer_info(user)
- topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
-
- await db.commit()
-
- try:
- from app.services.referral_service import process_referral_topup
- await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
- except Exception as e:
- logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
-
- 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 self.bot:
- 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 e:
- logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
-
- if self.bot:
- try:
- keyboard = await self.build_topup_success_keyboard(user)
-
- await self.bot.send_message(
- user.telegram_id,
- f"✅ Пополнение успешно!\n\n"
- f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
- f"🦊 Способ: Банковская карта\n"
- f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
- f"Баланс пополнен автоматически!",
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- logger.info(
- f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽"
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
- else:
- logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
- return False
-
- return True
-
- except Exception as e:
- logger.error(f"Ошибка обработки YooKassa webhook: {e}", exc_info=True)
- return False
-
- async def _process_successful_yookassa_payment(
- self,
- db: AsyncSession,
- payment: "YooKassaPayment"
- ) -> bool:
-
- try:
- transaction = await create_transaction(
- db=db,
- user_id=payment.user_id,
- transaction_type=TransactionType.DEPOSIT,
- amount_kopeks=payment.amount_kopeks,
- description=f"Пополнение через YooKassa: {payment.description}",
- payment_method=PaymentMethod.YOOKASSA,
- external_id=payment.yookassa_payment_id,
- is_completed=True
- )
-
- await link_yookassa_payment_to_transaction(
- db=db,
- yookassa_payment_id=payment.yookassa_payment_id,
- transaction_id=transaction.id
- )
-
- user = await get_user_by_id(db, payment.user_id)
- if user:
- await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}₽")
-
- logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
- f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽")
-
- if self.bot and user:
- try:
- await self._send_payment_success_notification(
- user.telegram_id,
- payment.amount_kopeks
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о платеже: {e}")
-
- return True
-
- except Exception as e:
- logger.error(f"Ошибка обработки успешного платежа YooKassa {payment.yookassa_payment_id}: {e}")
- return False
-
- async def _send_payment_success_notification(
- self,
- telegram_id: int,
- amount_kopeks: int
- ) -> None:
-
- if not self.bot:
- return
-
- try:
- async for db in get_db():
- user = await get_user_by_telegram_id(db, telegram_id)
- break
-
- keyboard = await self.build_topup_success_keyboard(user)
-
- message = (
- f"✅ Платеж успешно завершен!\n\n"
- f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
- f"💳 Способ: Банковская карта (YooKassa)\n\n"
- f"Средства зачислены на ваш баланс!"
- )
-
- await self.bot.send_message(
- chat_id=telegram_id,
- text=message,
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
-
- async def create_tribute_payment(
- self,
- amount_kopeks: int,
- user_id: int,
- description: str
- ) -> str:
-
- if not settings.TRIBUTE_ENABLED:
- raise ValueError("Tribute payments are disabled")
-
- try:
- payment_data = {
- "amount": amount_kopeks,
- "currency": "RUB",
- "description": description,
- "user_id": user_id,
- "callback_url": f"{settings.WEBHOOK_URL}/tribute/callback"
- }
-
- payment_url = f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
-
- logger.info(f"Создан Tribute платеж на {amount_kopeks/100}₽ для пользователя {user_id}")
- return payment_url
-
- except Exception as e:
- logger.error(f"Ошибка создания Tribute платежа: {e}")
- raise
-
- def verify_tribute_webhook(
- self,
- data: dict,
- signature: str
- ) -> bool:
-
- if not settings.TRIBUTE_API_KEY:
- return False
-
- try:
- message = str(data).encode()
- expected_signature = hmac.new(
- settings.TRIBUTE_API_KEY.encode(),
- message,
- hashlib.sha256
- ).hexdigest()
-
- return hmac.compare_digest(signature, expected_signature)
-
- except Exception as e:
- logger.error(f"Ошибка проверки Tribute webhook: {e}")
- return False
-
- async def process_successful_payment(
- self,
- payment_id: str,
- amount_kopeks: int,
- user_id: int,
- payment_method: str
- ) -> bool:
-
- try:
- logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
- return True
-
- except Exception as e:
- logger.error(f"Ошибка обработки платежа: {e}")
- return False
-
- async def create_cryptobot_payment(
- self,
- db: AsyncSession,
- user_id: int,
- amount_usd: float,
- asset: str = "USDT",
- description: str = "Пополнение баланса",
- payload: Optional[str] = None
- ) -> Optional[Dict[str, Any]]:
-
- if not self.cryptobot_service:
- logger.error("CryptoBot сервис не инициализирован")
- return None
-
- try:
- amount_str = f"{amount_usd:.2f}"
-
- invoice_data = await self.cryptobot_service.create_invoice(
- amount=amount_str,
- asset=asset,
- description=description,
- payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
- expires_in=settings.get_cryptobot_invoice_expires_seconds()
- )
-
- if not invoice_data:
- logger.error("Ошибка создания CryptoBot invoice")
- return None
-
- from app.database.crud.cryptobot import create_cryptobot_payment
-
- local_payment = await create_cryptobot_payment(
- db=db,
- user_id=user_id,
- invoice_id=str(invoice_data['invoice_id']),
- amount=amount_str,
- asset=asset,
- status="active",
- description=description,
- payload=payload,
- bot_invoice_url=invoice_data.get('bot_invoice_url'),
- mini_app_invoice_url=invoice_data.get('mini_app_invoice_url'),
- web_app_invoice_url=invoice_data.get('web_app_invoice_url')
- )
-
- logger.info(f"Создан CryptoBot платеж {invoice_data['invoice_id']} на {amount_str} {asset} для пользователя {user_id}")
-
- return {
- "local_payment_id": local_payment.id,
- "invoice_id": str(invoice_data['invoice_id']),
- "amount": amount_str,
- "asset": asset,
- "bot_invoice_url": invoice_data.get('bot_invoice_url'),
- "mini_app_invoice_url": invoice_data.get('mini_app_invoice_url'),
- "web_app_invoice_url": invoice_data.get('web_app_invoice_url'),
- "status": "active",
- "created_at": local_payment.created_at.isoformat() if local_payment.created_at else None
- }
-
- except Exception as e:
- logger.error(f"Ошибка создания CryptoBot платежа: {e}")
- return None
-
- async def create_mulenpay_payment(
- self,
- db: AsyncSession,
- user_id: int,
- amount_kopeks: int,
- description: str,
- language: Optional[str] = None,
- ) -> Optional[Dict[str, Any]]:
-
- if not self.mulenpay_service:
- logger.error("MulenPay сервис не инициализирован")
- return None
-
- if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
- logger.warning(
- "Сумма MulenPay меньше минимальной: %s < %s",
- amount_kopeks,
- settings.MULENPAY_MIN_AMOUNT_KOPEKS,
- )
- return None
-
- if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
- logger.warning(
- "Сумма MulenPay больше максимальной: %s > %s",
- amount_kopeks,
- settings.MULENPAY_MAX_AMOUNT_KOPEKS,
- )
- return None
-
- 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("Ошибка создания MulenPay платежа")
- 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 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(
- "Создан MulenPay платеж %s на %s₽ для пользователя %s",
- 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 e:
- logger.error(f"Ошибка создания MulenPay платежа: {e}")
- return None
-
- async def create_pal24_payment(
- self,
- db: AsyncSession,
- *,
- user_id: int,
- amount_kopeks: int,
- description: str,
- language: str,
- ttl_seconds: Optional[int] = None,
- payer_email: Optional[str] = None,
- payment_method: Optional[str] = None,
- ) -> Optional[Dict[str, Any]]:
-
- if not self.pal24_service or not self.pal24_service.is_configured:
- logger.error("Pal24 сервис не инициализирован")
- return None
-
- if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
- logger.warning(
- "Сумма Pal24 меньше минимальной: %s < %s",
- amount_kopeks,
- settings.PAL24_MIN_AMOUNT_KOPEKS,
- )
- return None
-
- if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
- logger.warning(
- "Сумма Pal24 больше максимальной: %s > %s",
- amount_kopeks,
- settings.PAL24_MAX_AMOUNT_KOPEKS,
- )
- return None
-
- order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
-
- custom_payload = {
- "user_id": user_id,
- "amount_kopeks": amount_kopeks,
- "language": language,
- }
-
- normalized_payment_method = (payment_method or "SBP").upper()
-
- try:
- response = await self.pal24_service.create_bill(
- amount_kopeks=amount_kopeks,
- user_id=user_id,
- order_id=order_id,
- description=description,
- ttl_seconds=ttl_seconds,
- custom_payload=custom_payload,
- payer_email=payer_email,
- payment_method=normalized_payment_method,
- )
- except Pal24APIError as error:
- logger.error("Ошибка Pal24 API при создании счета: %s", error)
- return None
-
- if not response.get("success", True):
- logger.error("Pal24 вернул ошибку при создании счета: %s", response)
- return None
-
- bill_id = response.get("bill_id")
- if not bill_id:
- logger.error("Pal24 не вернул bill_id: %s", response)
- return None
-
- def _pick_url(*keys: str) -> Optional[str]:
- for key in keys:
- value = response.get(key)
- if value:
- return str(value)
- return None
-
- transfer_url = _pick_url(
- "transfer_url",
- "transferUrl",
- "transfer_link",
- "transferLink",
- "transfer",
- "sbp_url",
- "sbpUrl",
- "sbp_link",
- "sbpLink",
- )
- card_url = _pick_url(
- "link_url",
- "linkUrl",
- "link",
- "card_url",
- "cardUrl",
- "card_link",
- "cardLink",
- "payment_url",
- "paymentUrl",
- "url",
- )
- link_page_url = _pick_url(
- "link_page_url",
- "linkPageUrl",
- "page_url",
- "pageUrl",
- )
-
- primary_link = transfer_url or link_page_url or card_url
- secondary_link = link_page_url or card_url or transfer_url
-
- metadata_links = {
- key: value
- for key, value in {
- "sbp": transfer_url,
- "card": card_url,
- "page": link_page_url,
- }.items()
- if value
- }
-
- payment = await create_pal24_payment(
- db,
- user_id=user_id,
- bill_id=bill_id,
- order_id=order_id,
- amount_kopeks=amount_kopeks,
- description=description,
- status=response.get("status", "NEW"),
- type_=response.get("type", "normal"),
- currency=response.get("currency", "RUB"),
- link_url=primary_link,
- link_page_url=secondary_link,
- ttl=ttl_seconds,
- metadata={
- "raw_response": response,
- "language": language,
- **({"links": metadata_links} if metadata_links else {}),
- },
- )
-
- payment_info = {
- "bill_id": bill_id,
- "order_id": order_id,
- "link_url": primary_link,
- "link_page_url": secondary_link,
- "local_payment_id": payment.id,
- "amount_kopeks": amount_kopeks,
- "sbp_url": transfer_url or primary_link,
- "card_url": card_url,
- "transfer_url": transfer_url,
- "payment_method": normalized_payment_method,
- }
-
- logger.info(
- "Создан Pal24 счет %s для пользователя %s на сумму %s",
- bill_id,
- user_id,
- settings.format_price(amount_kopeks),
- )
-
- return payment_info
-
- async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool:
- try:
- uuid_value = callback_data.get("uuid")
- payment_status = (callback_data.get("payment_status") 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")
-
- if not uuid_value and mulen_payment_id_raw is None:
- logger.error("MulenPay callback без uuid и id")
- return False
-
- payment = None
- if uuid_value:
- payment = await get_mulenpay_payment_by_uuid(db, uuid_value)
-
- if not payment and mulen_payment_id_int is not None:
- payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int)
-
- if not payment:
- logger.error(
- "MulenPay платеж не найден (uuid=%s, id=%s)",
- uuid_value,
- mulen_payment_id_raw,
- )
- return False
-
- if payment.transaction_id and payment.is_paid:
- logger.info("MulenPay платеж %s уже обработан", payment.uuid)
- return True
-
- paid_at = datetime.utcnow()
-
- if payment_status == "success":
- try:
- amount_kopeks = int(Decimal(str(amount_value)) * 100)
- except (InvalidOperation, TypeError):
- amount_kopeks = payment.amount_kopeks
- logger.warning(
- "Не удалось распарсить сумму MulenPay, используем значение из БД: %s",
- amount_value,
- )
-
- if amount_kopeks != payment.amount_kopeks:
- logger.warning(
- "Несовпадение суммы MulenPay: callback=%s, ожидаемо=%s",
- amount_kopeks,
- payment.amount_kopeks,
- )
-
- transaction = await create_transaction(
- db,
- user_id=payment.user_id,
- type=TransactionType.DEPOSIT,
- amount_kopeks=payment.amount_kopeks,
- description=f"Пополнение через Mulen Pay ({mulen_payment_id_raw})",
- payment_method=PaymentMethod.MULENPAY,
- external_id=(
- str(mulen_payment_id_int)
- if mulen_payment_id_int is not None
- else payment.uuid
- ),
- is_completed=True,
- )
-
- await link_mulenpay_payment_to_transaction(
- db,
- payment=payment,
- transaction_id=transaction.id,
- )
-
- user = await get_user_by_id(db, payment.user_id)
- if not user:
- logger.error("Пользователь %s не найден для MulenPay платежа", payment.user_id)
- return False
-
- 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()
-
- promo_group = getattr(user, "promo_group", None)
- subscription = getattr(user, "subscription", None)
- referrer_info = format_referrer_info(user)
- topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
-
- await db.commit()
-
- try:
- from app.services.referral_service import process_referral_topup
-
- await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
- except Exception as referral_error:
- logger.error(
- "Ошибка обработки реферального пополнения MulenPay: %s",
- referral_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)
-
- await update_mulenpay_payment_status(
- db,
- payment=payment,
- status="success",
- is_paid=True,
- paid_at=paid_at,
- callback_payload=callback_data,
- mulen_payment_id=mulen_payment_id_int,
- )
-
- if self.bot:
- 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 notify_error:
- logger.error(
- "Ошибка отправки админ уведомления MulenPay: %s",
- notify_error,
- )
-
- if self.bot:
- try:
- keyboard = await self.build_topup_success_keyboard(user)
- await self.bot.send_message(
- user.telegram_id,
- (
- "✅ Пополнение успешно!\n\n"
- f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
- "🦊 Способ: Mulen Pay\n"
- f"🆔 Транзакция: {transaction.id}\n\n"
- "Баланс пополнен автоматически!"
- ),
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- except Exception as user_notify_error:
- logger.error(
- "Ошибка отправки уведомления пользователю MulenPay: %s",
- user_notify_error,
- )
-
- logger.info(
- "✅ Обработан MulenPay платеж %s для пользователя %s",
- payment.uuid,
- payment.user_id,
- )
- return True
-
- if payment_status == "cancel":
- await update_mulenpay_payment_status(
- db,
- payment=payment,
- status="canceled",
- callback_payload=callback_data,
- mulen_payment_id=mulen_payment_id_int,
- )
- logger.info("MulenPay платеж %s отменен", payment.uuid)
- return True
-
- await 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(
- "Получен MulenPay callback со статусом %s для платежа %s",
- payment_status,
- payment.uuid,
- )
- return True
-
- except Exception as error:
- logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True)
- return False
-
- async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool:
-
- if not self.pal24_service or not self.pal24_service.is_configured:
- logger.error("Pal24 сервис не инициализирован")
- return False
-
- try:
- order_id_raw = payload.get("InvId")
- order_id = str(order_id_raw) if order_id_raw is not None else None
- if not order_id:
- logger.error("Pal24 postback без InvId")
- return False
-
- payment = await get_pal24_payment_by_order_id(db, order_id)
- if not payment:
- bill_id = payload.get("BillId")
- if bill_id:
- payment = await get_pal24_payment_by_bill_id(db, str(bill_id))
-
- if not payment:
- logger.error("Pal24 платеж не найден для order_id=%s", order_id)
- return False
-
- if payment.transaction_id and payment.is_paid:
- logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
- return True
-
- status = str(payload.get("Status", "UNKNOWN")).upper()
- payment_id = payload.get("TrsId")
- balance_amount = payload.get("BalanceAmount")
- balance_currency = payload.get("BalanceCurrency")
- payer_account = payload.get("AccountNumber")
- payment_method = payload.get("AccountType")
-
- try:
- amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum")))
- except Exception:
- logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение")
- amount_kopeks = payment.amount_kopeks
-
- if amount_kopeks != payment.amount_kopeks:
- logger.warning(
- "Несовпадение суммы Pal24: callback=%s, ожидаемо=%s",
- amount_kopeks,
- payment.amount_kopeks,
- )
-
- is_success = status in Pal24Service.BILL_SUCCESS_STATES
- is_failed = status in Pal24Service.BILL_FAILED_STATES
-
- await update_pal24_payment_status(
- db,
- payment,
- status=status,
- is_active=not is_failed,
- is_paid=is_success,
- payment_id=str(payment_id) if payment_id else None,
- payment_status=status,
- payment_method=str(payment_method) if payment_method else None,
- balance_amount=str(balance_amount) if balance_amount is not None else None,
- balance_currency=str(balance_currency) if balance_currency is not None else None,
- payer_account=str(payer_account) if payer_account is not None else None,
- callback_payload=payload,
- )
-
- if not is_success:
- logger.info(
- "Получен Pal24 статус %s для платежа %s (успех=%s)",
- status,
- payment.bill_id,
- is_success,
- )
- return True
-
- user = await get_user_by_id(db, payment.user_id)
- if not user:
- logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id)
- return False
-
- transaction = await create_transaction(
- db=db,
- user_id=payment.user_id,
- type=TransactionType.DEPOSIT,
- amount_kopeks=payment.amount_kopeks,
- description=f"Пополнение через Pal24 ({payment_id})",
- payment_method=PaymentMethod.PAL24,
- external_id=str(payment_id) if payment_id else payment.bill_id,
- is_completed=True,
- )
-
- await link_pal24_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()
-
- promo_group = getattr(user, "promo_group", None)
- subscription = getattr(user, "subscription", None)
- referrer_info = format_referrer_info(user)
- topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
-
- await db.commit()
-
- try:
- from app.services.referral_service import process_referral_topup
-
- await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
- except Exception as referral_error:
- logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_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 self.bot:
- 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 notify_error:
- logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
-
- if self.bot:
- try:
- keyboard = await self.build_topup_success_keyboard(user)
- await self.bot.send_message(
- user.telegram_id,
- (
- "✅ Пополнение успешно!\n\n"
- f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
- "🦊 Способ: PayPalych\n"
- f"🆔 Транзакция: {transaction.id}\n\n"
- "Баланс пополнен автоматически!"
- ),
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- except Exception as user_notify_error:
- logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error)
-
- logger.info(
- "✅ Обработан Pal24 платеж %s для пользователя %s",
- payment.bill_id,
- payment.user_id,
- )
-
- return True
-
- except Exception as error:
- logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
- return False
-
- @staticmethod
- def _map_mulenpay_status(status_code: Optional[int]) -> str:
- 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]]:
- try:
- payment = await get_mulenpay_payment_by_local_id(db, local_payment_id)
- if not payment:
- return None
-
- remote_status_code = None
- remote_data = None
-
- if (
- self.mulenpay_service
- and payment.mulen_payment_id is not None
- ):
- response = await self.mulenpay_service.get_payment(payment.mulen_payment_id)
- if response and response.get("success"):
- remote_data = response.get("payment")
- 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 get_mulenpay_payment_by_local_id(
- db, local_payment_id
- )
- elif mapped_status and mapped_status != payment.status:
- await update_mulenpay_payment_status(
- db,
- payment=payment,
- status=mapped_status,
- mulen_payment_id=remote_data.get("id"),
- )
- payment = await 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(f"Ошибка получения статуса MulenPay: {error}", exc_info=True)
- return None
-
- async def get_pal24_payment_status(
- self,
- db: AsyncSession,
- local_payment_id: int,
- ) -> Optional[Dict[str, Any]]:
- try:
- payment = await get_pal24_payment_by_id(db, local_payment_id)
- if not payment:
- return None
-
- remote_status = None
- remote_data = None
-
- if self.pal24_service and payment.bill_id:
- try:
- response = await self.pal24_service.get_bill_status(payment.bill_id)
- remote_data = response
- remote_status = (
- response.get("status")
- or response.get("bill", {}).get("status")
- )
-
- if remote_status and remote_status != payment.status:
- await update_pal24_payment_status(
- db,
- payment,
- status=str(remote_status).upper(),
- )
- payment = await get_pal24_payment_by_id(db, local_payment_id)
- except Pal24APIError as error:
- logger.error("Ошибка Pal24 API при получении статуса: %s", error)
-
- return {
- "payment": payment,
- "status": payment.status,
- "is_paid": payment.is_paid,
- "remote_status": remote_status,
- "remote_data": remote_data,
- }
-
- except Exception as error:
- logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
- return None
-
- async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
- try:
- from app.database.crud.cryptobot import (
- get_cryptobot_payment_by_invoice_id,
- update_cryptobot_payment_status,
- link_cryptobot_payment_to_transaction
- )
- from app.database.crud.transaction import create_transaction
- from app.database.models import TransactionType, PaymentMethod
-
- update_type = webhook_data.get("update_type")
-
- if update_type != "invoice_paid":
- logger.info(f"Пропуск CryptoBot webhook с типом: {update_type}")
- return True
-
- payload = webhook_data.get("payload", {})
- invoice_id = str(payload.get("invoice_id"))
- status = "paid"
-
- if not invoice_id:
- logger.error("CryptoBot webhook без invoice_id")
- return False
-
- payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
- if not payment:
- logger.error(f"CryptoBot платеж не найден в БД: {invoice_id}")
- return False
-
- if payment.status == "paid":
- logger.info(f"CryptoBot платеж {invoice_id} уже обработан")
- return True
-
- paid_at_str = payload.get("paid_at")
- paid_at = None
- if paid_at_str:
- try:
- paid_at = datetime.fromisoformat(paid_at_str.replace('Z', '+00:00')).replace(tzinfo=None)
- except:
- paid_at = datetime.utcnow()
- else:
- paid_at = datetime.utcnow()
-
- updated_payment = await update_cryptobot_payment_status(
- db, invoice_id, status, paid_at
- )
-
- if not updated_payment.transaction_id:
- amount_usd = updated_payment.amount_float
-
- try:
- amount_rubles = await currency_converter.usd_to_rub(amount_usd)
- amount_kopeks = int(amount_rubles * 100)
- conversion_rate = amount_rubles / amount_usd if amount_usd > 0 else 0
- logger.info(f"Конвертация USD->RUB: ${amount_usd} -> {amount_rubles}₽ (курс: {conversion_rate:.2f})")
- except Exception as e:
- logger.warning(f"Ошибка конвертации валют для платежа {invoice_id}, используем курс 1:1: {e}")
- amount_rubles = amount_usd
- amount_kopeks = int(amount_usd * 100)
- conversion_rate = 1.0
-
- if amount_kopeks <= 0:
- logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
- return False
-
- transaction = await create_transaction(
- db,
- user_id=updated_payment.user_id,
- type=TransactionType.DEPOSIT,
- amount_kopeks=amount_kopeks,
- description=f"Пополнение через CryptoBot ({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)",
- payment_method=PaymentMethod.CRYPTOBOT,
- external_id=invoice_id,
- is_completed=True
- )
-
- await link_cryptobot_payment_to_transaction(
- db, invoice_id, transaction.id
- )
-
- user = await get_user_by_id(db, updated_payment.user_id)
- if user:
- 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 = getattr(user, "promo_group", None)
- subscription = getattr(user, "subscription", None)
- referrer_info = format_referrer_info(user)
- topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
-
- await db.commit()
-
- try:
- from app.services.referral_service import process_referral_topup
- await process_referral_topup(db, user.id, amount_kopeks, self.bot)
- except Exception as e:
- logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
-
- 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 self.bot:
- 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 e:
- logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
-
- if self.bot:
- try:
- keyboard = await self.build_topup_success_keyboard(user)
-
- await self.bot.send_message(
- user.telegram_id,
- f"✅ Пополнение успешно!\n\n"
- f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
- f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
- f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
- f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
- f"Баланс пополнен автоматически!",
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
- else:
- logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
- return False
-
- return True
-
- except Exception as e:
- logger.error(f"Ошибка обработки CryptoBot webhook: {e}", exc_info=True)
- return False
diff --git a/app/services/yookassa_service.py b/app/services/yookassa_service.py
index ec642148..baf001bd 100644
--- a/app/services/yookassa_service.py
+++ b/app/services/yookassa_service.py
@@ -11,8 +11,9 @@ from app.config import settings
logger = logging.getLogger(__name__)
+
class YooKassaService:
-
+
def __init__(self,
shop_id: Optional[str] = None,
secret_key: Optional[str] = None,
@@ -23,23 +24,33 @@ class YooKassaService:
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
+ self.configured = False
+
if not shop_id or not secret_key:
logger.warning(
"YooKassa SHOP_ID или SECRET_KEY не настроены в settings. "
"Функционал платежей будет ОТКЛЮЧЕН.")
- self.configured = False
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logger.info(
f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...")
- except Exception as e:
- logger.error(f"Ошибка конфигурации YooKassa SDK: {e}",
- exc_info=True)
+ except Exception as error:
+ logger.error(
+ "Ошибка конфигурации YooKassa SDK: %s",
+ error,
+ exc_info=True,
+ )
self.configured = False
- if configured_return_url:
+ if not self.configured:
+ self.return_url = "https://t.me/"
+ logger.warning(
+ "YooKassa не активна, используем заглушку return_url: %s",
+ self.return_url,
+ )
+ elif configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
@@ -50,7 +61,7 @@ class YooKassaService:
logger.warning(
f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. "
f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.")
-
+
logger.info(f"YooKassa Service return_url: {self.return_url}")
async def create_payment(
@@ -62,7 +73,7 @@ class YooKassaService:
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Создает платеж в YooKassa"""
-
+
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.")
return None
@@ -156,7 +167,7 @@ class YooKassaService:
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
-
+
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.")
return None
@@ -178,23 +189,23 @@ class YooKassaService:
try:
builder = PaymentRequestBuilder()
-
+
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
-
+
builder.set_capture(True)
-
+
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
-
+
builder.set_description(description)
-
+
builder.set_metadata(metadata)
-
+
builder.set_payment_method_data({
"type": "sbp"
})
@@ -219,7 +230,7 @@ class YooKassaService:
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
-
+
payment_request = builder.build()
logger.info(
@@ -254,11 +265,11 @@ class YooKassaService:
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
-
+
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.")
return None
-
+
try:
logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}")
diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py
index 3b598f73..913b18d2 100644
--- a/app/utils/payment_utils.py
+++ b/app/utils/payment_utils.py
@@ -19,12 +19,21 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
})
if settings.is_yookassa_enabled():
+ if getattr(settings, "YOOKASSA_SBP_ENABLED", False):
+ methods.append({
+ "id": "yookassa_sbp",
+ "name": "СБП (YooKassa)",
+ "icon": "🏦",
+ "description": "моментальная оплата по QR",
+ "callback": "topup_yookassa_sbp",
+ })
+
methods.append({
- "id": "yookassa",
+ "id": "yookassa",
"name": "Банковская карта",
"icon": "💳",
"description": "через YooKassa",
- "callback": "topup_yookassa"
+ "callback": "topup_yookassa",
})
if settings.TRIBUTE_ENABLED:
@@ -172,4 +181,4 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_cryptobot_enabled():
count += 1
- return count
\ No newline at end of file
+ return count
diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index e336e6d9..f8ca1001 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -641,6 +641,18 @@ async def get_payment_methods(
)
if settings.is_yookassa_enabled():
+ if getattr(settings, "YOOKASSA_SBP_ENABLED", False):
+ methods.append(
+ MiniAppPaymentMethod(
+ id="yookassa_sbp",
+ icon="🏦",
+ requires_amount=True,
+ currency="RUB",
+ min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
+ max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
+ )
+ )
+
methods.append(
MiniAppPaymentMethod(
id="yookassa",
@@ -702,11 +714,12 @@ async def get_payment_methods(
order_map = {
"stars": 1,
- "yookassa": 2,
- "mulenpay": 3,
- "pal24": 4,
- "cryptobot": 5,
- "tribute": 6,
+ "yookassa_sbp": 2,
+ "yookassa": 3,
+ "mulenpay": 4,
+ "pal24": 5,
+ "cryptobot": 6,
+ "tribute": 7,
}
methods.sort(key=lambda item: order_map.get(item.id, 99))
@@ -781,6 +794,44 @@ async def create_payment_link(
},
)
+ if method == "yookassa_sbp":
+ if not settings.is_yookassa_enabled() or not getattr(settings, "YOOKASSA_SBP_ENABLED", False):
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
+ if amount_kopeks is None or amount_kopeks <= 0:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
+ if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
+ if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
+
+ payment_service = PaymentService()
+ result = await payment_service.create_yookassa_sbp_payment(
+ db=db,
+ user_id=user.id,
+ amount_kopeks=amount_kopeks,
+ description=settings.get_balance_payment_description(amount_kopeks),
+ )
+ confirmation_url = result.get("confirmation_url") if result else None
+ if not result or not confirmation_url:
+ raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
+
+ extra: dict[str, Any] = {
+ "local_payment_id": result.get("local_payment_id"),
+ "payment_id": result.get("yookassa_payment_id"),
+ "status": result.get("status"),
+ "requested_at": _current_request_timestamp(),
+ }
+ confirmation_token = result.get("confirmation_token")
+ if confirmation_token:
+ extra["confirmation_token"] = confirmation_token
+
+ return MiniAppPaymentCreateResponse(
+ method=method,
+ payment_url=confirmation_url,
+ amount_kopeks=amount_kopeks,
+ extra=extra,
+ )
+
if method == "yookassa":
if not settings.is_yookassa_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
@@ -4803,4 +4854,3 @@ async def update_subscription_devices_endpoint(
)
return MiniAppSubscriptionUpdateResponse(success=True)
-
diff --git a/docs/project_structure_reference.md b/docs/project_structure_reference.md
new file mode 100644
index 00000000..7b1fa290
--- /dev/null
+++ b/docs/project_structure_reference.md
@@ -0,0 +1,825 @@
+# База по структуре проекта
+
+Этот документ сгенерирован для быстрой навигации по репозиторию. В нём перечислены основные директории, модули, классы и функции.
+
+## Общая структура корня
+- `.dockerignore` — файл
+- `.env` — файл
+- `.env.example` — файл
+- `.gitignore` — файл
+- `CONTRIBUTING.md` — файл
+- `Dockerfile` — файл
+- `LICENSE` — файл
+- `README.md` — файл
+- `SECURITY.md` — файл
+- `__pycache__/` — директория
+- `alembic.ini` — файл
+- `app/` — директория
+- `app-config.json` — файл
+- `assets/` — директория
+- `data/` — директория
+- `docker-compose.local.yml` — файл
+- `docker-compose.yml` — файл
+- `docs/` — директория
+- `install_bot.sh` — файл
+- `locales/` — директория
+- `logs/` — директория
+- `main.py` — файл
+- `migrations/` — директория
+- `miniapp/` — директория
+- `requirements.txt` — файл
+- `tests/` — директория
+- `venv/` — директория
+- `vpn_logo.png` — файл
+
+## app
+
+- `app/bot.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/config.py` — Python-модуль
+ Классы: `Settings` (103 методов)
+ Функции: `refresh_period_prices` — Rebuild cached period price mapping using the latest settings., `get_traffic_prices`, `refresh_traffic_prices`
+- `app/database/`
+- `app/external/`
+- `app/handlers/`
+- `app/keyboards/`
+- `app/localization/`
+- `app/middlewares/`
+- `app/services/`
+- `app/states.py` — Python-модуль
+ Классы: `RegistrationStates`, `SubscriptionStates`, `BalanceStates`, `PromoCodeStates`, `AdminStates`, `SupportStates`, `TicketStates`, `AdminTicketStates`, `SupportSettingsStates`, `BotConfigStates`, `PricingStates`, `AutoPayStates`, `SquadCreateStates`, `SquadRenameStates`, `SquadMigrationStates`, `AdminSubmenuStates`
+ Функции: нет
+- `app/utils/`
+- `app/webapi/`
+
+### app/database
+
+- `app/database/crud/`
+- `app/database/database.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/models.py` — Python-модуль
+ Классы: `UserStatus`, `SubscriptionStatus`, `TransactionType`, `PromoCodeType`, `PaymentMethod`, `MainMenuButtonActionType`, `MainMenuButtonVisibility`, `YooKassaPayment` (6 методов), `CryptoBotPayment` (5 методов), `MulenPayPayment` (2 методов), `Pal24Payment` (3 методов), `PromoGroup` (3 методов), `User` (5 методов), `Subscription` (11 методов), `Transaction` (1 методов), `SubscriptionConversion` (2 методов), `PromoCode` (2 методов), `PromoCodeUse`, `ReferralEarning` (1 методов), `Squad` (1 методов), `ServiceRule`, `PrivacyPolicy`, `PublicOffer`, `FaqSetting`, `FaqPage`, `SystemSetting`, `MonitoringLog`, `SentNotification`, `DiscountOffer`, `PromoOfferTemplate`, `SubscriptionTemporaryAccess`, `PromoOfferLog`, `BroadcastHistory`, `ServerSquad` (3 методов), `SubscriptionServer`, `SupportAuditLog`, `UserMessage` (1 методов), `WelcomeText`, `AdvertisingCampaign` (2 методов), `AdvertisingCampaignRegistration` (1 методов), `TicketStatus`, `Ticket` (8 методов), `TicketMessage` (3 методов), `WebApiToken` (1 методов), `MainMenuButton` (3 методов)
+ Функции: нет
+- `app/database/universal_migration.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+
+#### app/database/crud
+
+- `app/database/crud/campaign.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/cryptobot.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/discount_offer.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/faq.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/main_menu_button.py` — Python-модуль
+ Классы: нет
+ Функции: `_enum_value`
+- `app/database/crud/mulenpay.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/notification.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/pal24.py` — CRUD helpers for PayPalych (Pal24) payments.
+ Классы: нет
+ Функции: нет
+- `app/database/crud/privacy_policy.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/promo_group.py` — Python-модуль
+ Классы: нет
+ Функции: `_normalize_period_discounts`
+- `app/database/crud/promo_offer_log.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/promo_offer_template.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_template_fields`
+- `app/database/crud/promocode.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/public_offer.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/referral.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/rules.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/server_squad.py` — Python-модуль
+ Классы: нет
+ Функции: `_generate_display_name`, `_extract_country_code`
+- `app/database/crud/squad.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/subscription.py` — Python-модуль
+ Классы: нет
+ Функции: `_get_discount_percent`
+- `app/database/crud/subscription_conversion.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/system_setting.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/ticket.py` — Python-модуль
+ Классы: `TicketCRUD` — CRUD операции для работы с тикетами, `TicketMessageCRUD` — CRUD операции для работы с сообщениями тикетов
+ Функции: нет
+- `app/database/crud/transaction.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/user.py` — Python-модуль
+ Классы: нет
+ Функции: `generate_referral_code`
+- `app/database/crud/user_message.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/database/crud/web_api_token.py` — CRUD операции для токенов административного веб-API.
+ Классы: нет
+ Функции: нет
+- `app/database/crud/welcome_text.py` — Python-модуль
+ Классы: нет
+ Функции: `replace_placeholders`, `get_available_placeholders`
+- `app/database/crud/yookassa.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+
+### app/external
+
+- `app/external/cryptobot.py` — Python-модуль
+ Классы: `CryptoBotService` (2 методов)
+ Функции: нет
+- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
+ Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
+ Функции: нет
+- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych postbacks.
+ Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 postbacks.
+ Функции: `_normalize_payload`, `create_pal24_flask_app`
+- `app/external/remnawave_api.py` — Python-модуль
+ Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
+ Функции: `format_bytes`, `parse_bytes`
+- `app/external/telegram_stars.py` — Python-модуль
+ Классы: `TelegramStarsService` (3 методов)
+ Функции: нет
+- `app/external/tribute.py` — Python-модуль
+ Классы: `TributeService` (2 методов)
+ Функции: нет
+- `app/external/webhook_server.py` — Python-модуль
+ Классы: `WebhookServer` (3 методов)
+ Функции: нет
+- `app/external/yookassa_webhook.py` — Python-модуль
+ Классы: `YooKassaWebhookHandler` (3 методов)
+ Функции: `create_yookassa_webhook_app`
+
+### app/handlers
+
+- `app/handlers/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/handlers/admin/`
+- `app/handlers/balance.py` — Python-модуль
+ Классы: нет
+ Функции: `get_quick_amount_buttons`, `register_handlers`
+- `app/handlers/common.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/menu.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_rubles`, `_collect_period_discounts`, `_build_group_discount_lines`, `_get_subscription_status`, `_insert_random_message`, `register_handlers`
+- `app/handlers/promocode.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/referral.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/server_status.py` — Python-модуль
+ Классы: нет
+ Функции: `_build_status_message`, `_split_into_pages`, `_format_server_lines`, `register_handlers`
+- `app/handlers/stars_payments.py` — Python-модуль
+ Классы: нет
+ Функции: `register_stars_handlers`
+- `app/handlers/start.py` — Python-модуль
+ Классы: нет
+ Функции: `_get_language_prompt_text`, `_get_subscription_status`, `_get_subscription_status_simple`, `_insert_random_message`, `get_referral_code_keyboard`, `register_handlers`
+- `app/handlers/subscription.py` — Python-модуль
+ Классы: `_SafeFormatDict` (1 методов)
+ Функции: `_format_text_with_placeholders`, `_get_addon_discount_percent_for_user`, `_apply_addon_discount`, `_get_promo_offer_discount_percent`, `_apply_promo_offer_discount`, `_get_period_hint_from_subscription`, `_apply_discount_to_monthly_component`, `_build_promo_group_discount_text`, `update_traffic_prices`, `format_traffic_display`, `validate_traffic_price`, `load_app_config`, `get_localized_value`, `get_step_description`, `format_additional_section`, `build_redirect_link`, `get_apps_for_device`, `get_device_name`, `create_deep_link`, `get_reset_devices_confirm_keyboard`, `get_traffic_switch_keyboard`, `get_confirm_switch_traffic_keyboard`, `register_handlers`
+- `app/handlers/support.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/tickets.py` — Python-модуль
+ Классы: `TicketStates`
+ Функции: `_split_text_into_pages`, `register_handlers` — Регистрация обработчиков тикетов
+- `app/handlers/webhooks.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+
+#### app/handlers/admin
+
+- `app/handlers/admin/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/handlers/admin/backup.py` — Python-модуль
+ Классы: `BackupStates`
+ Функции: `get_backup_main_keyboard`, `get_backup_list_keyboard`, `get_backup_manage_keyboard`, `get_backup_settings_keyboard`, `register_handlers`
+- `app/handlers/admin/bot_configuration.py` — Python-модуль
+ Классы: `BotConfigInputFilter` (1 методов)
+ Функции: `_get_group_meta`, `_get_group_description`, `_get_group_icon`, `_get_group_status`, `_get_setting_icon`, `_render_dashboard_overview`, `_build_group_category_index`, `_perform_settings_search`, `_build_search_results_keyboard`, `_parse_env_content`, `_format_preset_preview`, `_chunk`, `_parse_category_payload`, `_parse_group_payload`, `_get_grouped_categories`, `_build_groups_keyboard`, `_build_categories_keyboard`, `_build_settings_keyboard`, `_build_setting_keyboard`, `_render_setting_text`, `register_handlers`
+- `app/handlers/admin/campaigns.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_campaign_summary`, `_build_campaign_servers_keyboard`, `register_handlers`
+- `app/handlers/admin/faq.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_timestamp`, `register_handlers`
+- `app/handlers/admin/main.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/maintenance.py` — Python-модуль
+ Классы: `MaintenanceStates`
+ Функции: `register_handlers`
+- `app/handlers/admin/messages.py` — Python-модуль
+ Классы: нет
+ Функции: `get_message_buttons_selector_keyboard`, `get_updated_message_buttons_selector_keyboard`, `create_broadcast_keyboard`, `get_target_name`, `get_target_display_name`, `register_handlers`
+- `app/handlers/admin/monitoring.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_toggle`, `_build_notification_settings_view`, `_build_notification_preview_message`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_back_keyboard`, `register_handlers`
+- `app/handlers/admin/pricing.py` — Python-модуль
+ Классы: `ChoiceOption` (1 методов), `SettingEntry` (2 методов)
+ Функции: `_traffic_package_sort_key`, `_collect_traffic_packages`, `_serialize_traffic_packages`, `_language_code`, `_format_period_label`, `_format_traffic_label`, `_format_trial_summary`, `_format_core_summary`, `_get_period_items`, `_get_traffic_items`, `_get_extra_items`, `_build_period_summary`, `_build_traffic_summary`, `_build_period_options_summary`, `_build_extra_summary`, `_build_settings_section`, `_build_traffic_options_section`, `_build_period_options_section`, `_build_overview`, `_build_section`, `_build_price_prompt`, `_parse_price_input`, `_resolve_label`, `register_handlers`
+- `app/handlers/admin/privacy_policy.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_timestamp`, `register_handlers`
+- `app/handlers/admin/promo_groups.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_discount_lines`, `_format_addon_discounts_line`, `_get_addon_discounts_button_text`, `_normalize_periods_dict`, `_collect_period_discounts`, `_format_period_discounts_lines`, `_format_period_discounts_value`, `_parse_period_discounts_input`, `_format_rubles`, `_format_auto_assign_line`, `_format_auto_assign_value`, `_parse_auto_assign_threshold_input`, `_build_edit_menu_content`, `_get_edit_prompt_keyboard`, `_validate_percent`, `register_handlers`
+- `app/handlers/admin/promo_offers.py` — Python-модуль
+ Классы: нет
+ Функции: `_render_template_text`, `_build_templates_keyboard`, `_build_offer_detail_keyboard`, `_format_offer_remaining`, `_extract_offer_active_hours`, `_extract_template_id_from_notification`, `_format_promo_offer_log_entry`, `_build_logs_keyboard`, `_build_send_keyboard`, `_build_user_button_label`, `_describe_offer`, `_build_connect_button_rows`, `register_handlers`
+- `app/handlers/admin/promocodes.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/public_offer.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_timestamp`, `register_handlers`
+- `app/handlers/admin/referrals.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/remnawave.py` — Python-модуль
+ Классы: нет
+ Функции: `_format_migration_server_label`, `_build_migration_keyboard`, `register_handlers`
+- `app/handlers/admin/reports.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/rules.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/servers.py` — Python-модуль
+ Классы: нет
+ Функции: `_build_server_edit_view`, `_build_server_promo_groups_keyboard`, `register_handlers`
+- `app/handlers/admin/statistics.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/subscriptions.py` — Python-модуль
+ Классы: нет
+ Функции: `get_country_flag`, `register_handlers`
+- `app/handlers/admin/support_settings.py` — Python-модуль
+ Классы: `SupportAdvancedStates`
+ Функции: `_get_support_settings_keyboard`, `register_handlers`
+- `app/handlers/admin/system_logs.py` — Python-модуль
+ Классы: нет
+ Функции: `_resolve_log_path`, `_format_preview_block`, `_build_logs_message`, `_get_logs_keyboard`, `register_handlers`
+- `app/handlers/admin/tickets.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers` — Регистрация админских обработчиков тикетов
+- `app/handlers/admin/updates.py` — Python-модуль
+ Классы: нет
+ Функции: `get_updates_keyboard`, `get_version_info_keyboard`, `register_handlers`
+- `app/handlers/admin/user_messages.py` — Python-модуль
+ Классы: `UserMessageStates`
+ Функции: `get_user_messages_keyboard`, `get_message_actions_keyboard`, `register_handlers`
+- `app/handlers/admin/users.py` — Python-модуль
+ Классы: нет
+ Функции: `register_handlers`
+- `app/handlers/admin/welcome_text.py` — Python-модуль
+ Классы: нет
+ Функции: `get_telegram_formatting_info`, `register_welcome_text_handlers`
+
+### app/keyboards
+
+- `app/keyboards/admin.py` — Python-модуль
+ Классы: нет
+ Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
+- `app/keyboards/inline.py` — Python-модуль
+ Классы: нет
+ Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_suffix`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
+- `app/keyboards/reply.py` — Python-модуль
+ Классы: нет
+ Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
+
+### app/localization
+
+- `app/localization/default_locales/`
+- `app/localization/loader.py` — Python-модуль
+ Классы: нет
+ Функции: `_normalize_language_code`, `_resolve_user_locales_dir`, `_locale_file_exists`, `_select_fallback_language`, `_determine_default_language`, `_normalize_key`, `_flatten_locale_dict`, `_normalize_locale_dict`, `ensure_locale_templates`, `_load_default_locale`, `_load_user_locale`, `_load_locale_file`, `_merge_dicts`, `load_locale`, `clear_locale_cache`
+- `app/localization/locales/`
+- `app/localization/texts.py` — Python-модуль
+ Классы: `Texts` (8 методов)
+ Функции: `_get_cached_rules_value`, `_build_dynamic_values`, `get_texts`, `_get_default_rules`, `get_rules_sync`, `clear_rules_cache`, `reload_locales`
+
+#### app/localization/default_locales
+
+- `app/localization/default_locales/en.yml` — файл (.yml)
+- `app/localization/default_locales/ru.yml` — файл (.yml)
+
+#### app/localization/locales
+
+- `app/localization/locales/en.json` — файл (.json)
+- `app/localization/locales/ru.json` — файл (.json)
+
+### app/middlewares
+
+- `app/middlewares/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/middlewares/auth.py` — Python-модуль
+ Классы: `AuthMiddleware`
+ Функции: нет
+- `app/middlewares/channel_checker.py` — Python-модуль
+ Классы: `ChannelCheckerMiddleware` (1 методов)
+ Функции: нет
+- `app/middlewares/display_name_restriction.py` — Python-модуль
+ Классы: `DisplayNameRestrictionMiddleware` (4 методов) — Blocks users whose display name imitates links or official accounts.
+ Функции: нет
+- `app/middlewares/global_error.py` — Python-модуль
+ Классы: `GlobalErrorMiddleware` (4 методов), `ErrorStatisticsMiddleware` (4 методов)
+ Функции: нет
+- `app/middlewares/logging.py` — Python-модуль
+ Классы: `LoggingMiddleware`
+ Функции: нет
+- `app/middlewares/maintenance.py` — Python-модуль
+ Классы: `MaintenanceMiddleware`
+ Функции: нет
+- `app/middlewares/subscription_checker.py` — Python-модуль
+ Классы: `SubscriptionStatusMiddleware`
+ Функции: нет
+- `app/middlewares/throttling.py` — Python-модуль
+ Классы: `ThrottlingMiddleware` (1 методов)
+ Функции: нет
+
+### app/services
+
+- `app/services/__init__.py` — Сервисы бизнес-логики
+ Классы: нет
+ Функции: нет
+- `app/services/admin_notification_service.py` — Python-модуль
+ Классы: `AdminNotificationService` (11 методов)
+ Функции: нет
+- `app/services/backup_service.py` — Python-модуль
+ Классы: `BackupMetadata`, `BackupSettings`, `BackupService` (7 методов)
+ Функции: нет
+- `app/services/broadcast_service.py` — Python-модуль
+ Классы: `BroadcastMediaConfig`, `BroadcastConfig`, `_BroadcastTask`, `BroadcastService` (4 методов) — Handles broadcast execution triggered from the admin web API.
+ Функции: нет
+- `app/services/campaign_service.py` — Python-модуль
+ Классы: `CampaignBonusResult`, `AdvertisingCampaignService` (1 методов)
+ Функции: нет
+- `app/services/external_admin_service.py` — Утилиты для синхронизации токена внешней админки.
+ Классы: нет
+ Функции: нет
+- `app/services/faq_service.py` — Python-модуль
+ Классы: `FaqService` (3 методов)
+ Функции: нет
+- `app/services/main_menu_button_service.py` — Python-модуль
+ Классы: `_MainMenuButtonData`, `MainMenuButtonService` (2 методов)
+ Функции: нет
+- `app/services/maintenance_service.py` — Python-модуль
+ Классы: `MaintenanceStatus`, `MaintenanceService` (6 методов)
+ Функции: нет
+- `app/services/monitoring_service.py` — Python-модуль
+ Классы: `MonitoringService` (5 методов)
+ Функции: нет
+- `app/services/mulenpay_service.py` — Python-модуль
+ Классы: `MulenPayService` (4 методов) — Интеграция с Mulen Pay API.
+ Функции: нет
+- `app/services/notification_settings_service.py` — Python-модуль
+ Классы: `NotificationSettingsService` (32 методов) — Runtime-editable notification settings stored on disk.
+ Функции: нет
+- `app/services/pal24_service.py` — High level integration with PayPalych API.
+ Классы: `Pal24Service` (5 методов) — Wrapper around :class:`Pal24Client` providing domain helpers.
+ Функции: нет
+- `app/services/payment_service.py` — Python-модуль
+ Классы: `PaymentService` (3 методов)
+ Функции: нет
+- `app/services/privacy_policy_service.py` — Python-модуль
+ Классы: `PrivacyPolicyService` (3 методов) — Utility helpers around privacy policy storage and presentation.
+ Функции: нет
+- `app/services/promo_group_assignment.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/services/promo_offer_service.py` — Python-модуль
+ Классы: `PromoOfferService` (1 методов)
+ Функции: нет
+- `app/services/promocode_service.py` — Python-модуль
+ Классы: `PromoCodeService` (1 методов)
+ Функции: нет
+- `app/services/public_offer_service.py` — Python-модуль
+ Классы: `PublicOfferService` (4 методов) — Helpers for managing the public offer text and visibility.
+ Функции: нет
+- `app/services/referral_service.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/services/remnawave_service.py` — Python-модуль
+ Классы: `RemnaWaveConfigurationError` — Raised when RemnaWave API configuration is missing., `RemnaWaveService` (7 методов)
+ Функции: нет
+- `app/services/reporting_service.py` — Python-модуль
+ Классы: `ReportingServiceError` — Base error for the reporting service., `ReportPeriod`, `ReportPeriodRange`, `ReportingService` (7 методов) — Generates admin summary reports and can schedule daily delivery.
+ Функции: нет
+- `app/services/server_status_service.py` — Python-модуль
+ Классы: `ServerStatusEntry`, `ServerStatusError` — Raised when server status information cannot be fetched or parsed., `ServerStatusService` (6 методов)
+ Функции: нет
+- `app/services/subscription_checkout_service.py` — Python-модуль
+ Классы: нет
+ Функции: `should_offer_checkout_resume` — Determine whether checkout resume button should be available for the user.
+- `app/services/subscription_purchase_service.py` — Python-модуль
+ Классы: `PurchaseTrafficOption` (1 методов), `PurchaseTrafficConfig` (1 методов), `PurchaseServerOption` (1 методов), `PurchaseServersConfig` (1 методов), `PurchaseDevicesConfig` (1 методов), `PurchasePeriodConfig` (1 методов), `PurchaseSelection`, `PurchasePricingResult`, `PurchaseOptionsContext`, `PurchaseValidationError` (1 методов), `PurchaseBalanceError` (1 методов), `MiniAppSubscriptionPurchaseService` (5 методов) — Builds configuration and pricing for subscription purchases in the mini app.
+ Функции: `_apply_percentage_discount`, `_apply_discount_to_monthly_component`, `_get_promo_offer_discount_percent`, `_apply_promo_offer_discount`, `_build_server_option`
+- `app/services/subscription_service.py` — Python-модуль
+ Классы: `SubscriptionService` (7 методов)
+ Функции: `_resolve_discount_percent`, `_resolve_addon_discount_percent`, `get_traffic_reset_strategy`
+- `app/services/support_settings_service.py` — Python-модуль
+ Классы: `SupportSettingsService` (23 методов) — Runtime editable support settings with JSON persistence.
+ Функции: нет
+- `app/services/system_settings_service.py` — Python-модуль
+ Классы: `SettingDefinition` (1 методов), `ChoiceOption`, `ReadOnlySettingError` — Исключение, выбрасываемое при попытке изменить настройку только для чтения., `BotConfigurationService` (33 методов)
+ Функции: `_title_from_key`, `_truncate`
+- `app/services/tribute_service.py` — Python-модуль
+ Классы: `TributeService` (1 методов)
+ Функции: нет
+- `app/services/user_service.py` — Python-модуль
+ Классы: `UserService`
+ Функции: нет
+- `app/services/version_service.py` — Python-модуль
+ Классы: `VersionInfo` (5 методов), `VersionService` (5 методов)
+ Функции: нет
+- `app/services/web_api_token_service.py` — Python-модуль
+ Классы: `WebApiTokenService` (2 методов) — Сервис для управления токенами административного веб-API.
+ Функции: нет
+- `app/services/yookassa_service.py` — Python-модуль
+ Классы: `YooKassaService` (1 методов)
+ Функции: нет
+
+### app/utils
+
+- `app/utils/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/utils/cache.py` — Python-модуль
+ Классы: `CacheService` (1 методов), `UserCache`, `SystemCache`, `RateLimitCache`
+ Функции: `cache_key`
+- `app/utils/check_reg_process.py` — Python-модуль
+ Классы: нет
+ Функции: `is_registration_process`
+- `app/utils/currency_converter.py` — Python-модуль
+ Классы: `CurrencyConverter` (1 методов)
+ Функции: нет
+- `app/utils/decorators.py` — Python-модуль
+ Классы: нет
+ Функции: `admin_required`, `error_handler`, `_extract_event`, `state_cleanup`, `typing_action`, `rate_limit`
+- `app/utils/formatters.py` — Python-модуль
+ Классы: нет
+ Функции: `format_datetime`, `format_date`, `format_time_ago`, `format_days_declension`, `format_duration`, `format_bytes`, `format_percentage`, `format_number`, `format_price_range`, `truncate_text`, `format_username`, `format_subscription_status`, `format_traffic_usage`, `format_boolean`
+- `app/utils/message_patch.py` — Python-модуль
+ Классы: нет
+ Функции: `is_qr_message`, `_get_language`, `_default_privacy_hint`, `append_privacy_hint`, `prepare_privacy_safe_kwargs`, `is_privacy_restricted_error`, `patch_message_methods`
+- `app/utils/miniapp_buttons.py` — Python-модуль
+ Классы: нет
+ Функции: `build_miniapp_or_callback_button` — Create a button that opens the miniapp in text menu mode.
+- `app/utils/pagination.py` — Python-модуль
+ Классы: `PaginationResult` (1 методов)
+ Функции: `paginate_list`, `get_pagination_info`, `get_page_numbers`
+- `app/utils/payment_utils.py` — Python-модуль
+ Классы: нет
+ Функции: `get_available_payment_methods` — Возвращает список доступных способов оплаты с их настройками, `get_payment_methods_text` — Генерирует текст с описанием доступных способов оплаты, `is_payment_method_available` — Проверяет, доступен ли конкретный способ оплаты, `get_payment_method_status` — Возвращает статус всех способов оплаты, `get_enabled_payment_methods_count` — Возвращает количество включенных способов оплаты (не считая поддержку)
+- `app/utils/photo_message.py` — Python-модуль
+ Классы: нет
+ Функции: `_resolve_media`, `_get_language`, `_build_base_kwargs`
+- `app/utils/pricing_utils.py` — Python-модуль
+ Классы: нет
+ Функции: `calculate_months_from_days`, `get_remaining_months`, `calculate_period_multiplier`, `calculate_prorated_price`, `apply_percentage_discount`, `format_period_description`, `validate_pricing_calculation`, `get_period_info`
+- `app/utils/promo_offer.py` — Python-модуль
+ Классы: нет
+ Функции: `_escape_format_braces` — Escape braces so str.format treats them as literals., `get_user_active_promo_discount_percent`, `_format_time_left`, `_build_progress_bar`
+- `app/utils/security.py` — Утилиты безопасности и генерации ключей.
+ Классы: нет
+ Функции: `hash_api_token` — Возвращает хеш токена в формате hex., `generate_api_token` — Генерирует криптографически стойкий токен.
+- `app/utils/startup_timeline.py` — Python-модуль
+ Классы: `StepRecord`, `StageHandle` (6 методов), `StartupTimeline` (6 методов)
+ Функции: нет
+- `app/utils/subscription_utils.py` — Python-модуль
+ Классы: нет
+ Функции: `get_display_subscription_link`, `get_happ_cryptolink_redirect_link`, `convert_subscription_link_to_happ_scheme`
+- `app/utils/telegram_webapp.py` — Utilities for validating Telegram WebApp initialization data.
+ Классы: `TelegramWebAppAuthError` — Raised when Telegram WebApp init data fails validation.
+ Функции: `parse_webapp_init_data` — Validate and parse Telegram WebApp init data.
+- `app/utils/user_utils.py` — Python-модуль
+ Классы: нет
+ Функции: `format_referrer_info` — Return formatted referrer info for admin notifications.
+- `app/utils/validators.py` — Python-модуль
+ Классы: нет
+ Функции: `validate_email`, `validate_phone`, `validate_telegram_username`, `validate_promocode`, `validate_amount`, `validate_positive_integer`, `validate_date_string`, `validate_url`, `validate_uuid`, `validate_traffic_amount`, `validate_subscription_period`, `sanitize_html`, `sanitize_telegram_name` — Санитизация Telegram-имени для безопасной вставки в HTML и хранения., `validate_device_count`, `validate_referral_code`, `validate_html_tags`, `validate_html_structure`, `fix_html_tags`, `get_html_help_text`, `validate_rules_content`
+
+### app/webapi
+
+- `app/webapi/__init__.py` — Пакет административного веб-API.
+ Классы: нет
+ Функции: нет
+- `app/webapi/app.py` — Python-модуль
+ Классы: нет
+ Функции: `create_web_api_app`
+- `app/webapi/background/`
+- `app/webapi/dependencies.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/webapi/middleware.py` — Python-модуль
+ Классы: `RequestLoggingMiddleware` — Логирование входящих запросов в административный API.
+ Функции: нет
+- `app/webapi/routes/`
+- `app/webapi/schemas/`
+- `app/webapi/server.py` — Python-модуль
+ Классы: `WebAPIServer` (1 методов) — Асинхронный uvicorn-сервер для административного API.
+ Функции: нет
+
+#### app/webapi/background
+
+- `app/webapi/background/__init__.py` — Background utilities for Web API.
+ Классы: нет
+ Функции: нет
+- `app/webapi/background/backup_tasks.py` — Python-модуль
+ Классы: `BackupTaskState`, `BackupTaskManager` (1 методов)
+ Функции: нет
+
+#### app/webapi/routes
+
+- `app/webapi/routes/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/webapi/routes/backups.py` — Python-модуль
+ Классы: нет
+ Функции: `_parse_datetime`, `_to_int`, `_serialize_backup`
+- `app/webapi/routes/broadcasts.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_broadcast`
+- `app/webapi/routes/campaigns.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_campaign`
+- `app/webapi/routes/config.py` — Python-модуль
+ Классы: нет
+ Функции: `_coerce_value`, `_serialize_definition`
+- `app/webapi/routes/health.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/webapi/routes/main_menu_buttons.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize`
+- `app/webapi/routes/miniapp.py` — Python-модуль
+ Классы: нет
+ Функции: `_normalize_autopay_days`, `_get_autopay_day_options`, `_build_autopay_payload`, `_autopay_response_extras`, `_compute_cryptobot_limits`, `_current_request_timestamp`, `_compute_stars_min_amount`, `_normalize_stars_amount`, `_build_balance_invoice_payload`, `_merge_purchase_selection_from_request`, `_parse_client_timestamp`, `_classify_status`, `_format_gb`, `_format_gb_label`, `_format_limit_label`, `_normalize_amount_kopeks`, `_extract_template_id`, `_extract_offer_extra`, `_extract_offer_type`, `_normalize_effect_type`, `_determine_offer_icon`, `_extract_offer_test_squad_uuids`, `_format_offer_message`, `_extract_offer_duration_hours`, `_format_bonus_label`, `_bytes_to_gb`, `_status_label`, `_parse_datetime_string`, `_resolve_display_name`, `_is_remnawave_configured`, `_serialize_transaction`, `_is_trial_available_for_user`, `_safe_int`, `_normalize_period_discounts`, `_extract_promo_discounts`, `_normalize_language_code`, `_build_renewal_status_message`, `_build_promo_offer_payload`, `_build_renewal_period_id`, `_parse_period_identifier`, `_get_addon_discount_percent_for_user`, `_get_period_hint_from_subscription`, `_validate_subscription_id`, `_ensure_paid_subscription`
+- `app/webapi/routes/pages.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_rich_page`, `_serialize_faq_page`, `_serialize_rules`
+- `app/webapi/routes/promo_groups.py` — Python-модуль
+ Классы: нет
+ Функции: `_normalize_period_discounts`, `_serialize`
+- `app/webapi/routes/promo_offers.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_user`, `_serialize_subscription`, `_serialize_offer`, `_serialize_template`, `_build_log_response`
+- `app/webapi/routes/promocodes.py` — Python-модуль
+ Классы: нет
+ Функции: `_normalize_datetime`, `_serialize_promocode`, `_serialize_recent_use`, `_validate_create_payload`, `_validate_update_payload`
+- `app/webapi/routes/remnawave.py` — Python-модуль
+ Классы: нет
+ Функции: `_get_service`, `_ensure_service_configured`, `_serialize_node`, `_parse_last_updated`
+- `app/webapi/routes/stats.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/webapi/routes/subscriptions.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_subscription`
+- `app/webapi/routes/tickets.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_message`, `_serialize_ticket`
+- `app/webapi/routes/tokens.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize`
+- `app/webapi/routes/transactions.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize`
+- `app/webapi/routes/users.py` — Python-модуль
+ Классы: нет
+ Функции: `_serialize_promo_group`, `_serialize_subscription`, `_serialize_user`, `_apply_search_filter`
+
+#### app/webapi/schemas
+
+- `app/webapi/schemas/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `app/webapi/schemas/backups.py` — Python-модуль
+ Классы: `BackupCreateResponse`, `BackupInfo`, `BackupListResponse`, `BackupStatusResponse`, `BackupTaskInfo`, `BackupTaskListResponse`
+ Функции: нет
+- `app/webapi/schemas/broadcasts.py` — Python-модуль
+ Классы: `BroadcastMedia`, `BroadcastCreateRequest` (2 методов), `BroadcastResponse`, `BroadcastListResponse`
+ Функции: нет
+- `app/webapi/schemas/campaigns.py` — Python-модуль
+ Классы: `CampaignBase` (1 методов), `CampaignCreateRequest` (2 методов), `CampaignResponse`, `CampaignListResponse`, `CampaignUpdateRequest` (3 методов)
+ Функции: нет
+- `app/webapi/schemas/config.py` — Python-модуль
+ Классы: `SettingCategorySummary` — Краткое описание категории настройки., `SettingCategoryRef` — Ссылка на категорию, к которой относится настройка., `SettingChoice` — Вариант значения для настройки с выбором., `SettingDefinition` — Полное описание настройки и её текущего состояния., `SettingUpdateRequest` — Запрос на обновление значения настройки.
+ Функции: нет
+- `app/webapi/schemas/health.py` — Python-модуль
+ Классы: `HealthFeatureFlags` — Флаги доступности функций административного API., `HealthCheckResponse` — Ответ на health-check административного API.
+ Функции: нет
+- `app/webapi/schemas/main_menu_buttons.py` — Python-модуль
+ Классы: `MainMenuButtonResponse`, `MainMenuButtonCreateRequest`, `MainMenuButtonUpdateRequest` (2 методов), `MainMenuButtonListResponse`
+ Функции: `_clean_text`, `_validate_action_value`
+- `app/webapi/schemas/miniapp.py` — Python-модуль
+ Классы: `MiniAppBranding`, `MiniAppSubscriptionRequest`, `MiniAppSubscriptionUser`, `MiniAppPromoGroup`, `MiniAppAutoPromoGroupLevel`, `MiniAppConnectedServer`, `MiniAppDevice`, `MiniAppDeviceRemovalRequest`, `MiniAppDeviceRemovalResponse`, `MiniAppTransaction`, `MiniAppPromoOffer`, `MiniAppPromoOfferClaimRequest`, `MiniAppPromoOfferClaimResponse`, `MiniAppSubscriptionAutopay`, `MiniAppSubscriptionRenewalPeriod`, `MiniAppSubscriptionRenewalOptionsRequest`, `MiniAppSubscriptionRenewalOptionsResponse`, `MiniAppSubscriptionRenewalRequest`, `MiniAppSubscriptionRenewalResponse`, `MiniAppSubscriptionAutopayRequest`, `MiniAppSubscriptionAutopayResponse`, `MiniAppPromoCode`, `MiniAppPromoCodeActivationRequest`, `MiniAppPromoCodeActivationResponse`, `MiniAppFaqItem`, `MiniAppFaq`, `MiniAppRichTextDocument`, `MiniAppLegalDocuments`, `MiniAppReferralTerms`, `MiniAppReferralStats`, `MiniAppReferralRecentEarning`, `MiniAppReferralItem`, `MiniAppReferralList`, `MiniAppReferralInfo`, `MiniAppPaymentMethodsRequest`, `MiniAppPaymentMethod`, `MiniAppPaymentMethodsResponse`, `MiniAppPaymentCreateRequest`, `MiniAppPaymentCreateResponse`, `MiniAppPaymentStatusQuery`, `MiniAppPaymentStatusRequest`, `MiniAppPaymentStatusResult`, `MiniAppPaymentStatusResponse`, `MiniAppSubscriptionResponse`, `MiniAppSubscriptionServerOption`, `MiniAppSubscriptionTrafficOption`, `MiniAppSubscriptionDeviceOption`, `MiniAppSubscriptionCurrentSettings`, `MiniAppSubscriptionServersSettings`, `MiniAppSubscriptionTrafficSettings`, `MiniAppSubscriptionDevicesSettings`, `MiniAppSubscriptionBillingContext`, `MiniAppSubscriptionSettings`, `MiniAppSubscriptionSettingsResponse`, `MiniAppSubscriptionSettingsRequest` (1 методов), `MiniAppSubscriptionServersUpdateRequest` (1 методов), `MiniAppSubscriptionTrafficUpdateRequest` (1 методов), `MiniAppSubscriptionDevicesUpdateRequest` (1 методов), `MiniAppSubscriptionUpdateResponse`, `MiniAppSubscriptionPurchaseOptionsRequest`, `MiniAppSubscriptionPurchaseOptionsResponse`, `MiniAppSubscriptionPurchasePreviewRequest` (1 методов), `MiniAppSubscriptionPurchasePreviewResponse`, `MiniAppSubscriptionPurchaseRequest`, `MiniAppSubscriptionPurchaseResponse`, `MiniAppSubscriptionTrialRequest`, `MiniAppSubscriptionTrialResponse`
+ Функции: нет
+- `app/webapi/schemas/pages.py` — Python-модуль
+ Классы: `RichTextPageResponse` — Generic representation for rich text informational pages., `RichTextPageUpdateRequest`, `FaqPageResponse`, `FaqPageListResponse`, `FaqPageCreateRequest`, `FaqPageUpdateRequest`, `FaqReorderItem`, `FaqReorderRequest`, `FaqStatusResponse`, `FaqStatusUpdateRequest`, `ServiceRulesResponse`, `ServiceRulesUpdateRequest`, `ServiceRulesHistoryResponse`
+ Функции: нет
+- `app/webapi/schemas/promo_groups.py` — Python-модуль
+ Классы: `PromoGroupResponse`, `_PromoGroupBase` (1 методов), `PromoGroupCreateRequest`, `PromoGroupUpdateRequest`, `PromoGroupListResponse`
+ Функции: `_normalize_period_discounts`
+- `app/webapi/schemas/promo_offers.py` — Python-модуль
+ Классы: `PromoOfferUserInfo`, `PromoOfferSubscriptionInfo`, `PromoOfferResponse`, `PromoOfferListResponse`, `PromoOfferCreateRequest`, `PromoOfferTemplateResponse`, `PromoOfferTemplateListResponse`, `PromoOfferTemplateUpdateRequest`, `PromoOfferLogOfferInfo`, `PromoOfferLogResponse`, `PromoOfferLogListResponse`
+ Функции: нет
+- `app/webapi/schemas/promocodes.py` — Python-модуль
+ Классы: `PromoCodeResponse`, `PromoCodeListResponse`, `PromoCodeCreateRequest`, `PromoCodeUpdateRequest`, `PromoCodeRecentUse`, `PromoCodeDetailResponse`
+ Функции: нет
+- `app/webapi/schemas/remnawave.py` — Python-модуль
+ Классы: `RemnaWaveConnectionStatus`, `RemnaWaveStatusResponse`, `RemnaWaveNode`, `RemnaWaveNodeListResponse`, `RemnaWaveNodeActionRequest`, `RemnaWaveNodeActionResponse`, `RemnaWaveNodeStatisticsResponse`, `RemnaWaveNodeUsageResponse`, `RemnaWaveBandwidth`, `RemnaWaveTrafficPeriod`, `RemnaWaveTrafficPeriods`, `RemnaWaveSystemSummary`, `RemnaWaveServerInfo`, `RemnaWaveSystemStatsResponse`, `RemnaWaveSquad`, `RemnaWaveSquadListResponse`, `RemnaWaveSquadCreateRequest`, `RemnaWaveSquadUpdateRequest`, `RemnaWaveSquadActionRequest`, `RemnaWaveOperationResponse`, `RemnaWaveInboundsResponse`, `RemnaWaveUserTrafficResponse`, `RemnaWaveSyncFromPanelRequest`, `RemnaWaveGenericSyncResponse`, `RemnaWaveSquadMigrationPreviewResponse`, `RemnaWaveSquadMigrationRequest`, `RemnaWaveSquadMigrationStats`, `RemnaWaveSquadMigrationResponse`
+ Функции: нет
+- `app/webapi/schemas/subscriptions.py` — Python-модуль
+ Классы: `SubscriptionResponse`, `SubscriptionCreateRequest`, `SubscriptionExtendRequest`, `SubscriptionTrafficRequest`, `SubscriptionDevicesRequest`, `SubscriptionSquadRequest`
+ Функции: нет
+- `app/webapi/schemas/tickets.py` — Python-модуль
+ Классы: `TicketMessageResponse`, `TicketResponse`, `TicketStatusUpdateRequest`, `TicketPriorityUpdateRequest`, `TicketReplyBlockRequest`
+ Функции: нет
+- `app/webapi/schemas/tokens.py` — Python-модуль
+ Классы: `TokenResponse`, `TokenCreateRequest`, `TokenCreateResponse`
+ Функции: нет
+- `app/webapi/schemas/transactions.py` — Python-модуль
+ Классы: `TransactionResponse`, `TransactionListResponse`
+ Функции: нет
+- `app/webapi/schemas/users.py` — Python-модуль
+ Классы: `PromoGroupSummary`, `SubscriptionSummary`, `UserResponse`, `UserListResponse`, `UserCreateRequest`, `UserUpdateRequest`, `BalanceUpdateRequest`
+ Функции: нет
+
+## tests
+
+- `tests/conftest.py` — Глобальные фикстуры и настройки окружения для тестов.
+ Классы: нет
+ Функции: `fixed_datetime` — Возвращает фиксированную отметку времени для воспроизводимых проверок.
+- `tests/external/`
+- `tests/services/`
+- `tests/test_miniapp_payments.py` — Python-модуль
+ Классы: нет
+ Функции: `anyio_backend`, `test_compute_cryptobot_limits_scale_with_rate`
+- `tests/utils/`
+
+### tests/external
+
+- `tests/external/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `tests/external/test_cryptobot_service.py` — Тесты для внешнего клиента CryptoBotService.
+ Классы: нет
+ Функции: `anyio_backend`, `_enable_token`, `test_verify_webhook_signature`, `test_verify_webhook_signature_without_secret`
+- `tests/external/test_webhook_server.py` — Тестирование хендлеров WebhookServer без запуска реального сервера.
+ Классы: `DummyBot`
+ Функции: `anyio_backend`, `webhook_server`, `_mock_request`
+
+### tests/services
+
+- `tests/services/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `tests/services/test_mulenpay_service_adapter.py` — Юнит-тесты MulenPayService.
+ Классы: нет
+ Функции: `anyio_backend`, `_enable_service`, `test_is_configured`, `test_format_and_signature`
+- `tests/services/test_pal24_service_adapter.py` — Тесты Pal24Service и вспомогательных функций.
+ Классы: `StubPal24Client` (1 методов)
+ Функции: `_enable_pal24`, `anyio_backend`, `test_parse_postback_success`, `test_parse_postback_missing_fields`, `test_convert_to_kopeks_and_expiration`
+- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
+ Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
+ Функции: `anyio_backend`, `_make_service`
+- `tests/services/test_payment_service_mulenpay.py` — Тесты для сценариев MulenPay в PaymentService.
+ Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubMulenPayService` (1 методов)
+ Функции: `anyio_backend`, `_make_service`
+- `tests/services/test_payment_service_pal24.py` — Тесты Pal24 сценариев PaymentService.
+ Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubPal24Service` (1 методов)
+ Функции: `anyio_backend`, `_make_service`
+- `tests/services/test_payment_service_stars.py` — Тесты для Telegram Stars-сценариев внутри PaymentService.
+ Классы: `DummyBot` (1 методов) — Минимальная заглушка aiogram.Bot для тестов.
+ Функции: `anyio_backend` — Ограничиваем anyio тесты только бэкендом asyncio., `_make_service` — Создаёт экземпляр PaymentService без выполнения полного конструктора.
+- `tests/services/test_payment_service_tribute.py` — Тесты Tribute-платежей PaymentService.
+ Классы: нет
+ Функции: `anyio_backend`, `_make_service`, `test_verify_tribute_webhook_signature`, `test_verify_tribute_webhook_returns_false_without_key`
+- `tests/services/test_payment_service_webhooks.py` — Интеграционные проверки обработки вебхуков PaymentService.
+ Классы: `DummyBot` (1 методов), `FakeSession` (2 методов)
+ Функции: `_make_service`, `anyio_backend`
+- `tests/services/test_payment_service_yookassa.py` — Тесты для YooKassa-сценариев PaymentService.
+ Классы: `DummySession` (1 методов) — Простейшая заглушка AsyncSession., `DummyLocalPayment` (1 методов) — Объект, имитирующий локальную запись платежа., `StubYooKassaService` (1 методов) — Заглушка для SDK, сохраняющая вызовы.
+ Функции: `anyio_backend` — Запускаем async-тесты на asyncio, чтобы избежать зависимостей trio., `_make_service`
+- `tests/services/test_yookassa_service_adapter.py` — Тесты низкоуровневого сервиса YooKassaService.
+ Классы: `DummyLoop`
+ Функции: `anyio_backend`, `_prepare_config`, `test_init_without_credentials`
+
+### tests/utils
+
+- `tests/utils/__init__.py` — Python-модуль
+ Классы: нет
+ Функции: нет
+- `tests/utils/test_formatters_basic.py` — Тесты для базовых форматтеров из app.utils.formatters.
+ Классы: нет
+ Функции: `test_format_datetime_handles_iso_strings` — ISO-строка должна корректно преобразовываться в отформатированный текст., `test_format_date_uses_custom_format` — Можно задавать собственный шаблон вывода., `test_format_time_ago_returns_human_readable_text` — Разница во времени должна переводиться в человеко-понятную строку., `test_format_days_declension_handles_russian_rules` — Склонение дней в русском языке зависит от числа., `test_format_duration_switches_units` — В зависимости от длины интервала выбирается подходящая единица измерения., `test_format_bytes_scales_value` — Размер должен выражаться в наиболее подходящей единице., `test_format_percentage_respects_precision` — Проценты форматируются с нужным количеством знаков., `test_format_number_inserts_separators` — Разделители тысяч должны расставляться корректно как для int, так и для float., `test_truncate_text_appends_suffix` — Строки, превышающие лимит, должны обрезаться и дополняться суффиксом., `test_format_username_prefers_full_name` — Полное имя имеет приоритет, затем username, затем ID., `test_format_subscription_status_handles_active_and_expired` — Статус подписки различается для активных/просроченных случаев., `test_format_traffic_usage_supports_unlimited` — При безлимитном тарифе в строке должна появляться бесконечность., `test_format_boolean_localises_output` — Булевые значения отображаются локализованными словами.
+- `tests/utils/test_security.py` — Тесты для функций безопасности из app.utils.security.
+ Классы: нет
+ Функции: `test_hash_api_token_default_algorithm_matches_hashlib` — Проверяем, что алгоритм по умолчанию совпадает с hashlib.sha256., `test_hash_api_token_accepts_supported_algorithms` — Каждый поддерживаемый алгоритм должен выдавать корректный результат., `test_hash_api_token_rejects_unknown_algorithm` — Некорректное имя алгоритма должно приводить к ValueError., `test_generate_api_token_respects_length_bounds` — Функция должна ограничивать длину токена безопасным диапазоном., `test_generate_api_token_produces_random_values` — Два последовательных вызова должны выдавать разные токены.
+- `tests/utils/test_validators_basic.py` — Базовые тесты для валидаторов из app.utils.validators.
+ Классы: нет
+ Функции: `test_validate_email_handles_expected_patterns` — Проверяем типичные корректные и некорректные адреса., `test_validate_phone_strips_formatting_and_checks_pattern` — Телефон должен соответствовать стандарту E.164 после очистки., `test_validate_telegram_username_enforces_length` — Telegram-логин должен быть 5-32 символов и содержать допустимые символы., `test_validate_amount_returns_float_within_bounds` — Числа должны конвертироваться с уважением к диапазону., `test_validate_positive_integer_enforces_upper_bound` — Положительное целое число выходит за пределы — возвращаем None., `test_validate_traffic_amount_supports_units` — Валидатор трафика распознаёт разные единицы измерения и особые значения., `test_validate_subscription_period_accepts_reasonable_range` — Диапазон допустимой длительности от 1 до 3650 дней., `test_validate_uuid_detects_standard_format` — UUID должен соответствовать HEX шаблону версии 4/5., `test_validate_url_recognises_https_links` — Валидатор URL допускает http/https ссылки и отклоняет произвольные строки., `test_validate_html_tags_rejects_unknown_tags` — Неизвестные HTML теги должны приводить к отказу., `test_validate_html_structure_detects_wrong_nesting` — Неправильная вложенность тегов должна сообщаться пользователю., `test_fix_html_tags_repairs_missing_quotes` — Автоисправление должно добавлять кавычки у ссылок., `test_validate_rules_content_detects_structure_error` — При нарушении структуры должны вернуться сообщение и отсутствие подсказки., `test_validate_rules_content_accepts_supported_markup` — Корректный HTML должен проходить проверку без сообщений.
+
+## migrations
+
+- `migrations/alembic/`
+
+### migrations/alembic
+
+- `migrations/alembic/alembic.ini` — файл (.ini)
+- `migrations/alembic/env.py` — Python-модуль
+ Классы: нет
+ Функции: `run_migrations_offline`, `do_run_migrations`, `run_migrations_online`
+- `migrations/alembic/versions/`
+
+#### migrations/alembic/versions
+
+- `migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py` — add promo groups table and link users
+ Классы: нет
+ Функции: `_table_exists`, `_column_exists`, `_index_exists`, `_foreign_key_exists`, `upgrade`, `downgrade`
+- `migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py` — Python-модуль
+ Классы: нет
+ Функции: `upgrade`, `downgrade`
+- `migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py` — add advertising campaigns tables
+ Классы: нет
+ Функции: `_table_exists`, `_index_exists`, `upgrade`, `downgrade`
+- `migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py` — add sent notifications table
+ Классы: нет
+ Функции: `_table_exists`, `_unique_constraint_exists`, `upgrade`, `downgrade`
+
+## docs
+
+- `docs/miniapp-setup.md` — файл (.md)
+- `docs/web-admin-integration.md` — файл (.md)
+
+## miniapp
+
+- `miniapp/app-config.json` — файл (.json)
+- `miniapp/index.html` — файл (.html)
+- `miniapp/redirect/`
+
+### miniapp/redirect
+
+- `miniapp/redirect/index.html` — файл (.html)
+
+## assets
+
+- `assets/bedolaga_app3.svg` — файл (.svg)
+- `assets/logo2.svg` — файл (.svg)
+
+## locales
+
+- `locales/en.json` — файл (.json)
+- `locales/ru.json` — файл (.json)
+
+## data
+
+- `data/backups/`
+- `data/bot.db` — файл (.db)
+
+### data/backups
+
+
+## logs
+
+- `logs/bot.log` — файл (.log)
diff --git a/tests/services/test_payment_service_modularity.py b/tests/services/test_payment_service_modularity.py
new file mode 100644
index 00000000..6c0ead84
--- /dev/null
+++ b/tests/services/test_payment_service_modularity.py
@@ -0,0 +1,53 @@
+"""Проверяем, что PaymentService собирается из mixin-классов."""
+
+from pathlib import Path
+import sys
+
+import pytest
+
+ROOT_DIR = Path(__file__).resolve().parents[2]
+if str(ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(ROOT_DIR))
+
+from app.services.payment import ( # noqa: E402
+ CryptoBotPaymentMixin,
+ MulenPayPaymentMixin,
+ Pal24PaymentMixin,
+ PaymentCommonMixin,
+ TelegramStarsMixin,
+ TributePaymentMixin,
+ YooKassaPaymentMixin,
+)
+from app.services.payment_service import PaymentService # noqa: E402
+
+
+def test_payment_service_mro_contains_all_mixins() -> None:
+ """Убеждаемся, что сервис действительно включает все mixin-классы."""
+ mixins = {
+ PaymentCommonMixin,
+ TelegramStarsMixin,
+ YooKassaPaymentMixin,
+ TributePaymentMixin,
+ CryptoBotPaymentMixin,
+ MulenPayPaymentMixin,
+ Pal24PaymentMixin,
+ }
+ service_mro = set(PaymentService.__mro__)
+ assert mixins.issubset(service_mro), "PaymentService должен содержать все mixin-классы"
+
+
+@pytest.mark.parametrize(
+ "attribute",
+ [
+ "build_topup_success_keyboard",
+ "create_stars_invoice",
+ "create_yookassa_payment",
+ "create_tribute_payment",
+ "create_cryptobot_payment",
+ "create_mulenpay_payment",
+ "create_pal24_payment",
+ ],
+)
+def test_payment_service_exposes_provider_methods(attribute: str) -> None:
+ """Каждый mixin обязан добавить публичный метод в PaymentService."""
+ assert hasattr(PaymentService, attribute), f"Отсутствует метод {attribute}"