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}"