Merge pull request #1303 from Fr1ngg/dev4

фиксы + модульность платежек
This commit is contained in:
Egor
2025-10-13 21:01:30 +03:00
committed by GitHub
41 changed files with 6062 additions and 1706 deletions

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@
!app/**
!locales/
!locales/**
!tests/
!tests/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md

View File

@@ -223,9 +223,17 @@ async def extend_subscription(
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
logger.info(
"🔄 Статус подписки %s изменён с %s на ACTIVE",
subscription.id,
previous_status,
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0

View File

@@ -222,12 +222,28 @@ async def _continue_registration_after_language(
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
data = await state.get_data() or {}
pending_start_payload = data.pop("pending_start_payload", None)
referral_code = None
campaign = None
start_args = message.text.split()
start_parameter = None
if len(start_args) > 1:
start_parameter = start_args[1]
elif pending_start_payload:
start_parameter = pending_start_payload
logger.info(
"📦 START: Используем сохраненный payload '%s'",
pending_start_payload,
)
if pending_start_payload is not None:
await state.set_data(data)
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
start_parameter,
@@ -1409,6 +1425,41 @@ async def required_sub_channel_check(
try:
state_data = await state.get_data() or {}
pending_start_payload = state_data.pop("pending_start_payload", None)
state_updated = pending_start_payload is not None
if pending_start_payload:
logger.info(
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
pending_start_payload,
)
if "campaign_id" not in state_data and "referral_code" not in state_data:
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data["campaign_id"] = campaign.id
logger.info(
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
campaign.id,
)
else:
state_data["referral_code"] = pending_start_payload
logger.info(
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
)
else:
logger.debug(
" CHANNEL CHECK: Payload уже обработан ранее, пропускаем восстановление",
)
if state_updated:
await state.set_data(state_data)
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)

View File

@@ -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(

View File

@@ -1,5 +1,5 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import BaseMiddleware, Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -86,16 +86,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
return await handler(event, data)
is_required = settings.CHANNEL_IS_REQUIRED_SUB
if not is_required:
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
return await handler(event, data)
channel_link = settings.CHANNEL_LINK
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
if member.status in self.GOOD_MEMBER_STATUS:
return await handler(event, data)
elif member.status in self.BAD_MEMBER_STATUS:
@@ -104,6 +104,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
await self._capture_start_payload(state, event)
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return
@@ -111,10 +113,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
return await self._deny_message(event, bot, channel_link)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
@@ -123,11 +127,45 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
if not state:
return
message: Optional[Message] = None
if isinstance(event, Message):
message = event
elif isinstance(event, CallbackQuery):
message = event.message
elif isinstance(event, Update):
message = event.message
if not message or not message.text:
return
text = message.text.strip()
if not text.startswith("/start"):
return
parts = text.split(maxsplit=1)
if len(parts) < 2 or not parts[1]:
return
payload = parts[1]
data = await state.get_data() or {}
if data.get("pending_start_payload") == payload:
return
data["pending_start_payload"] = payload
await state.set_data(data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
async for db in get_db():
try:

View File

@@ -4,10 +4,12 @@ from datetime import datetime
from aiogram import Bot, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import MissingGreenlet
from app.config import settings
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
from app.database.models import (
AdvertisingCampaign,
PromoCodeType,
@@ -342,7 +344,7 @@ class AdminNotificationService:
logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}")
return False
async def send_balance_topup_notification(
def _build_balance_topup_message(
self,
user: User,
transaction: Transaction,
@@ -352,17 +354,14 @@ class AdminNotificationService:
referrer_info: str,
subscription: Subscription | None,
promo_group: PromoGroup | None,
) -> bool:
if not self._is_enabled():
return False
) -> str:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
@@ -384,10 +383,96 @@ class AdminNotificationService:
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {subscription_status}
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
⏰ <i>{timestamp}</i>"""
async def _reload_topup_notification_entities(
self,
db: AsyncSession,
user: User,
transaction: Transaction,
) -> tuple[User, Transaction, Subscription | None, PromoGroup | None]:
refreshed_user = await get_user_by_id(db, user.id)
if not refreshed_user:
raise ValueError(
f"Не удалось повторно загрузить пользователя {user.id} для уведомления о пополнении"
)
refreshed_transaction = await get_transaction_by_id(db, transaction.id)
if not refreshed_transaction:
raise ValueError(
f"Не удалось повторно загрузить транзакцию {transaction.id} для уведомления о пополнении"
)
subscription = getattr(refreshed_user, "subscription", None)
promo_group = await self._get_user_promo_group(db, refreshed_user)
return refreshed_user, refreshed_transaction, subscription, promo_group
async def send_balance_topup_notification(
self,
user: User,
transaction: Transaction,
old_balance: int,
*,
topup_status: str,
referrer_info: str,
subscription: Subscription | None,
promo_group: PromoGroup | None,
db: AsyncSession | None = None,
) -> bool:
if not self._is_enabled():
return False
try:
message = self._build_balance_topup_message(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
)
except MissingGreenlet as missing_greenlet:
if db is None:
logger.error(
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
missing_greenlet,
)
return False
try:
(
user,
transaction,
subscription,
promo_group,
) = await self._reload_topup_notification_entities(db, user, transaction)
message = self._build_balance_topup_message(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
)
except Exception as reload_error:
logger.error(
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
reload_error,
exc_info=True,
)
return False
except Exception as e:
logger.error(
f"Ошибка подготовки уведомления о пополнении: {e}",
exc_info=True,
)
return False
try:
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
return False
@@ -1074,4 +1159,3 @@ class AdminNotificationService:
if not (self._is_enabled() and runtime_enabled):
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)

View File

@@ -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",
]

View File

@@ -0,0 +1,211 @@
"""Общие инструменты платёжного сервиса.
В этом модуле собраны методы, которые нужны всем платёжным каналам:
построение клавиатур, базовые уведомления и стандартная обработка
успешных платежей.
"""
from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.exc import MissingGreenlet
from sqlalchemy.ext.asyncio import AsyncSession
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,
*,
db: AsyncSession | None = None,
payment_method_title: str | None = None,
) -> None:
"""Отправляет пользователю уведомление об успешном платеже."""
if not getattr(self, "bot", None):
# Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
return
user_snapshot = await self._ensure_user_snapshot(
telegram_id,
user,
db=db,
)
try:
keyboard = await self.build_topup_success_keyboard(user_snapshot)
payment_method = payment_method_title or "Банковская карта (YooKassa)"
message = (
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\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 _ensure_user_snapshot(
self,
telegram_id: int,
user: Any | None,
*,
db: AsyncSession | None = None,
) -> Any | None:
"""Гарантирует, что данные пользователя пригодны для построения клавиатуры."""
def _build_snapshot(source: Any | None) -> SimpleNamespace | None:
if source is None:
return None
subscription = getattr(source, "subscription", None)
subscription_snapshot = None
if subscription is not None:
subscription_snapshot = SimpleNamespace(
is_trial=getattr(subscription, "is_trial", False),
is_active=getattr(subscription, "is_active", False),
actual_status=getattr(subscription, "actual_status", None),
)
return SimpleNamespace(
id=getattr(source, "id", None),
telegram_id=getattr(source, "telegram_id", None),
language=getattr(source, "language", "ru"),
subscription=subscription_snapshot,
)
try:
snapshot = _build_snapshot(user)
except MissingGreenlet:
snapshot = None
if snapshot is not None:
return snapshot
fetch_session = db
if fetch_session is not None:
try:
fetched_user = await get_user_by_telegram_id(fetch_session, telegram_id)
return _build_snapshot(fetched_user)
except Exception as fetch_error:
logger.warning(
"Не удалось обновить пользователя %s из переданной сессии: %s",
telegram_id,
fetch_error,
)
try:
async for db_session in get_db():
fetched_user = await get_user_by_telegram_id(db_session, telegram_id)
return _build_snapshot(fetched_user)
except Exception as fetch_error:
logger.warning(
"Не удалось получить пользователя %s для уведомления: %s",
telegram_id,
fetch_error,
)
return None
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

View File

@@ -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,
(
"✅ <b>Пополнение успешно!</b>\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

View File

@@ -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,
(
"✅ <b>Пополнение успешно!</b>\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

View File

@@ -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,
(
"✅ <b>Пополнение успешно!</b>\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

View File

@@ -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,
(
"✅ <b>Пополнение успешно!</b>\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

View File

@@ -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

View File

@@ -0,0 +1,364 @@
"""Функции работы с 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,
db=db,
payment_method_title="Банковская карта (YooKassa)",
)
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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
return count

View File

@@ -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")
@@ -1047,8 +1098,13 @@ async def _resolve_payment_status_entry(
message="Payment method is required",
)
if method == "yookassa":
return await _resolve_yookassa_payment_status(db, user, query)
if method in {"yookassa", "yookassa_sbp"}:
return await _resolve_yookassa_payment_status(
db,
user,
query,
method=method,
)
if method == "mulenpay":
return await _resolve_mulenpay_payment_status(payment_service, db, user, query)
if method == "pal24":
@@ -1071,6 +1127,8 @@ async def _resolve_yookassa_payment_status(
db: AsyncSession,
user: User,
query: MiniAppPaymentStatusQuery,
*,
method: str = "yookassa",
) -> MiniAppPaymentStatusResult:
from app.database.crud.yookassa import (
get_yookassa_payment_by_id,
@@ -1085,7 +1143,7 @@ async def _resolve_yookassa_payment_status(
if not payment or payment.user_id != user.id:
return MiniAppPaymentStatusResult(
method="yookassa",
method=method,
status="pending",
is_paid=False,
amount_kopeks=query.amount_kopeks,
@@ -1104,7 +1162,7 @@ async def _resolve_yookassa_payment_status(
completed_at = payment.captured_at or payment.updated_at or payment.created_at
return MiniAppPaymentStatusResult(
method="yookassa",
method=method,
status=status,
is_paid=status == "paid",
amount_kopeks=payment.amount_kopeks,
@@ -4803,4 +4861,3 @@ async def update_subscription_devices_endpoint(
)
return MiniAppSubscriptionUpdateResponse(success=True)

View File

@@ -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)

151
tests/conftest.py Normal file
View File

@@ -0,0 +1,151 @@
"""Глобальные фикстуры и настройки окружения для тестов."""
import os
import sys
import types
from datetime import datetime, timezone
import pytest
# Подменяем параметры подключения к БД, чтобы SQLAlchemy не требовал aiosqlite.
os.environ.setdefault("DATABASE_MODE", "postgresql")
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/test_db")
os.environ.setdefault("BOT_TOKEN", "test-token")
# Создаём заглушки для драйверов, которых может не быть в окружении тестов.
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
sys.modules.setdefault("aiosqlite", types.ModuleType("aiosqlite"))
# Эмуляция redis.asyncio, чтобы модуль кеша мог импортироваться.
if "redis.asyncio" not in sys.modules:
redis_module = types.ModuleType("redis")
redis_async_module = types.ModuleType("redis.asyncio")
class _FakeRedisClient:
async def ping(self):
"""Имитируем успешный ответ ping."""
return True
async def close(self):
"""Закрытие соединения ничего не делает."""
async def get(self, key): # noqa: ANN001
return None
async def set(self, key, value, ex=None): # noqa: ANN001
return True
async def delete(self, *keys): # noqa: ANN001
return 0
async def keys(self, pattern="*"): # noqa: ANN001
return []
async def exists(self, key): # noqa: ANN001
return False
async def expire(self, key, seconds): # noqa: ANN001
return True
async def incr(self, key): # noqa: ANN001
return 1
def _from_url(url): # noqa: ANN001
return _FakeRedisClient()
redis_async_module.from_url = _from_url
redis_async_module.Redis = _FakeRedisClient
sys.modules["redis"] = redis_module
sys.modules["redis.asyncio"] = redis_async_module
# Минимальная реализация SDK YooKassa, чтобы импорт сервисов не падал.
if "yookassa" not in sys.modules:
fake_yookassa = types.ModuleType("yookassa")
class _FakeConfiguration:
@staticmethod
def configure(*args, **kwargs):
"""Конфигурация заглушки ничего не делает."""
class _FakePayment:
@staticmethod
def create(*args, **kwargs):
"""Возвращает объект с минимально необходимыми атрибутами."""
class _Response:
id = "yk_fake"
status = "pending"
paid = False
refundable = False
metadata = {}
amount = types.SimpleNamespace(value="0.00", currency="RUB")
confirmation = types.SimpleNamespace(confirmation_url="https://example.com")
created_at = datetime.utcnow()
description = ""
test = False
return _Response()
fake_yookassa.Configuration = _FakeConfiguration
fake_yookassa.Payment = _FakePayment
sys.modules["yookassa"] = fake_yookassa
# Подготавливаем вложенные пакеты, используемые сервисом.
domain_module = types.ModuleType("yookassa.domain")
request_module = types.ModuleType("yookassa.domain.request")
payment_builder_module = types.ModuleType("yookassa.domain.request.payment_request_builder")
common_module = types.ModuleType("yookassa.domain.common")
confirmation_module = types.ModuleType("yookassa.domain.common.confirmation_type")
class _FakePaymentRequestBuilder:
def __init__(self):
self.data: dict = {}
def set_amount(self, value): # noqa: ANN001 - упрощённая заглушка
self.data["amount"] = value
return self
def set_capture(self, value): # noqa: ANN001
self.data["capture"] = value
return self
def set_confirmation(self, value): # noqa: ANN001
self.data["confirmation"] = value
return self
def set_description(self, value): # noqa: ANN001
self.data["description"] = value
return self
def set_metadata(self, value): # noqa: ANN001
self.data["metadata"] = value
return self
def set_receipt(self, value): # noqa: ANN001
self.data["receipt"] = value
return self
def set_payment_method_data(self, value): # noqa: ANN001
self.data["payment_method_data"] = value
return self
def build(self):
return self.data
class _FakeConfirmationType:
REDIRECT = "redirect"
payment_builder_module.PaymentRequestBuilder = _FakePaymentRequestBuilder
confirmation_module.ConfirmationType = _FakeConfirmationType
sys.modules["yookassa.domain"] = domain_module
sys.modules["yookassa.domain.request"] = request_module
sys.modules["yookassa.domain.request.payment_request_builder"] = payment_builder_module
sys.modules["yookassa.domain.common"] = common_module
sys.modules["yookassa.domain.common.confirmation_type"] = confirmation_module
@pytest.fixture
def fixed_datetime() -> datetime:
"""Возвращает фиксированную отметку времени для воспроизводимых проверок."""
return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)

1
tests/external/__init__.py vendored Normal file
View File

@@ -0,0 +1 @@
# Пакет для тестов внешних клиентов и вебхуков.

View File

@@ -0,0 +1,85 @@
"""Тесты для внешнего клиента CryptoBotService."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
import sys
import hashlib
import hmac
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.config import settings # noqa: E402
from app.external.cryptobot import CryptoBotService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False)
monkeypatch.setattr(type(settings), "get_cryptobot_base_url", lambda self: "https://cryptobot.test", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False)
@pytest.mark.anyio("asyncio")
async def test_create_invoice_uses_make_request(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_token(monkeypatch)
service = CryptoBotService()
captured: Dict[str, Any] = {}
async def fake_make_request(method: str, endpoint: str, data: Optional[Dict[str, Any]] = None):
captured["method"] = method
captured["endpoint"] = endpoint
captured["data"] = data
return {"invoice_id": 1}
monkeypatch.setattr(service, "_make_request", fake_make_request, raising=False)
result = await service.create_invoice(
amount="10.00",
asset="USDT",
description="Пополнение",
payload="payload",
expires_in=600,
)
assert result == {"invoice_id": 1}
assert captured["method"] == "POST"
assert captured["endpoint"] == "createInvoice"
assert captured["data"]["amount"] == "10.00"
assert captured["data"]["payload"] == "payload"
@pytest.mark.anyio("asyncio")
async def test_make_request_returns_none_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "", raising=False)
service = CryptoBotService()
result = await service._make_request("GET", "getMe")
assert result is None
def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "supersecret", raising=False)
service = CryptoBotService()
body = '{"invoice_id":1}'
secret_hash = hashlib.sha256(b"supersecret").digest()
signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
assert service.verify_webhook_signature(body, signature) is True
assert service.verify_webhook_signature(body, "invalid") is False
def test_verify_webhook_signature_without_secret(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False)
service = CryptoBotService()
assert service.verify_webhook_signature("{}", "anything") is True

137
tests/external/test_webhook_server.py vendored Normal file
View File

@@ -0,0 +1,137 @@
"""Тестирование хендлеров WebhookServer без запуска реального сервера."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Tuple
import sys
from unittest.mock import AsyncMock
import pytest
from aiohttp.test_utils import make_mocked_request
from aiohttp import web
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.config import settings # noqa: E402
from app.external.webhook_server import WebhookServer # noqa: E402
class DummyBot:
async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - уведомления не проверяем
return None
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture
def webhook_server(monkeypatch: pytest.MonkeyPatch) -> Tuple[WebhookServer, AsyncMock, AsyncMock]:
monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False)
monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False)
monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "mulen-secret", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False)
monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False)
monkeypatch.setattr(type(settings), "is_cryptobot_enabled", lambda self: True, raising=False)
server = WebhookServer(DummyBot())
tribute_mock = AsyncMock()
tribute_mock.process_webhook = AsyncMock(return_value={"status": "ok"})
server.tribute_service = tribute_mock
payment_mock = AsyncMock()
payment_mock.process_mulenpay_callback = AsyncMock(return_value=True)
payment_mock.process_cryptobot_webhook = AsyncMock(return_value=True)
monkeypatch.setattr("app.external.webhook_server.PaymentService", lambda *args, **kwargs: payment_mock)
monkeypatch.setattr("app.services.payment_service.PaymentService", lambda *args, **kwargs: payment_mock)
server._verify_mulenpay_signature = lambda request, raw: True # type: ignore[attr-defined]
class DummyDB:
async def commit(self) -> None: # pragma: no cover - не проверяем транзакции
return None
async def fake_get_db():
yield DummyDB()
monkeypatch.setattr("app.external.webhook_server.get_db", fake_get_db)
class DummySessionManager:
def __init__(self) -> None:
self.session = DummyDB()
async def __aenter__(self) -> DummyDB:
return self.session
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
monkeypatch.setattr("app.database.database.AsyncSessionLocal", lambda: DummySessionManager())
return server, tribute_mock, payment_mock
def _mock_request(method: str, path: str, body: dict[str, Any], headers: dict[str, str] | None = None) -> AsyncMock:
request = AsyncMock(spec=web.Request)
request.method = method
request.path = path
request.headers = headers or {}
request.read.return_value = json.dumps(body).encode("utf-8")
return request
@pytest.mark.anyio("asyncio")
async def test_health_endpoint(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, _ = webhook_server
request = make_mocked_request("GET", "/health")
response = await server._health_check(request)
assert response.status == 200
data = json.loads(response.text)
assert data["status"] == "ok"
assert data["service"] == "payment-webhooks"
@pytest.mark.anyio("asyncio")
async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch, webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, tribute_mock, _ = webhook_server
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "key", raising=False)
class FakeTributeAPI:
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
return True
monkeypatch.setattr("app.external.tribute.TributeService", FakeTributeAPI)
request = _mock_request("POST", "/tribute", {"event_type": "payment", "status": "paid"}, headers={"trbt-signature": "sig"})
response = await server._tribute_webhook_handler(request)
assert response.status == 200
assert tribute_mock.process_webhook.await_count == 1
@pytest.mark.anyio("asyncio")
async def test_mulenpay_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, payment_mock = webhook_server
request = _mock_request("POST", "/mulen", {"uuid": "uuid", "payment_status": "success"})
response = await server._mulenpay_webhook_handler(request)
assert response.status == 200
payment_mock.process_mulenpay_callback.assert_awaited_once()
@pytest.mark.anyio("asyncio")
async def test_cryptobot_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, payment_mock = webhook_server
request = _mock_request(
"POST",
"/cryptobot",
{"update_type": "invoice_paid", "payload": {"invoice_id": 1}},
)
response = await server._cryptobot_webhook_handler(request)
assert response.status == 200
payment_mock.process_cryptobot_webhook.assert_awaited_once()

View File

@@ -0,0 +1 @@
# Пакет для тестов сервисов бота.

View File

@@ -0,0 +1,107 @@
"""Юнит-тесты MulenPayService."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
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.config import settings # noqa: E402
from app.services.mulenpay_service import MulenPayService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
def _enable_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False)
monkeypatch.setattr(settings, "MULENPAY_API_KEY", "api", raising=False)
monkeypatch.setattr(settings, "MULENPAY_SHOP_ID", "shop", raising=False)
monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "secret", raising=False)
monkeypatch.setattr(settings, "MULENPAY_BASE_URL", "https://mulenpay.test", raising=False)
def test_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
service = MulenPayService()
assert service.is_configured is False
_enable_service(monkeypatch)
service = MulenPayService()
assert service.is_configured is True
def test_format_and_signature(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
assert service._format_amount(12345) == "123.45"
signature = service._build_signature("rub", "100.00")
assert isinstance(signature, str) and len(signature) == 40
@pytest.mark.anyio("asyncio")
async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
captured_payload: Dict[str, Any] = {}
async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]:
captured_payload.update({"method": method, "endpoint": endpoint, **kwargs})
return {"success": True, "id": 101, "paymentUrl": "https://mulenpay/pay"}
service = MulenPayService()
monkeypatch.setattr(service, "_request", fake_request, raising=False)
result = await service.create_payment(
amount_kopeks=25000,
description="Пополнение",
uuid="uuid-1",
items=[{"description": "item", "quantity": 1, "price": 250.0}],
language="ru",
website_url="https://example.com",
)
assert result is not None
assert result["id"] == 101
assert captured_payload["method"] == "POST"
assert captured_payload["endpoint"] == "/v2/payments"
assert captured_payload["json_data"]["language"] == "ru"
@pytest.mark.anyio("asyncio")
async def test_create_payment_failure(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
async def fake_request(*args: Any, **kwargs: Any) -> Optional[Dict[str, Any]]:
return None
monkeypatch.setattr(service, "_request", fake_request, raising=False)
result = await service.create_payment(
amount_kopeks=1000,
description="desc",
uuid="uuid",
items=[],
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_get_payment(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]:
return {"id": 123, "status": "paid"}
monkeypatch.setattr(service, "_request", fake_request, raising=False)
result = await service.get_payment(123)
assert result == {"id": 123, "status": "paid"}

View File

@@ -0,0 +1,120 @@
"""Тесты Pal24Service и вспомогательных функций."""
from __future__ import annotations
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, Optional
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.config import settings # noqa: E402
from app.external.pal24_client import Pal24Client, Pal24APIError # noqa: E402
from app.services.pal24_service import Pal24Service # noqa: E402
class StubPal24Client:
def __init__(self, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
self.is_configured = configured
self.response = response or {
"success": True,
"bill_id": "BILL42",
"status": "NEW",
"transfer_url": "https://pal24/sbp",
"link_url": "https://pal24/card",
"currency": "RUB",
}
self.calls: list[Dict[str, Any]] = []
async def create_bill(self, **kwargs: Any) -> Dict[str, Any]:
self.calls.append(kwargs)
return self.response
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
return {"id": bill_id, "status": "NEW"}
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
return {"id": payment_id, "status": "SUCCESS"}
def _enable_pal24(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(type(settings), "is_pal24_enabled", lambda self: True, raising=False)
monkeypatch.setattr(settings, "PAL24_SHOP_ID", "shop42", raising=False)
monkeypatch.setattr(settings, "PAL24_SIGNATURE_TOKEN", "sigsecret", raising=False)
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio("asyncio")
async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
client = StubPal24Client()
service = Pal24Service(client)
monkeypatch.setattr(Pal24Client, "normalize_amount", staticmethod(lambda amount: Decimal("500.00")), raising=False)
result = await service.create_bill(
amount_kopeks=50000,
user_id=7,
order_id="order-7",
description="Пополнение",
ttl_seconds=600,
custom_payload={"extra": "value"},
payer_email="user@example.com",
payment_method="CARD",
)
assert result["bill_id"] == "BILL42"
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
assert client.calls[0]["shop_id"] == "shop42"
assert client.calls[0]["description"] == "Пополнение"
@pytest.mark.anyio("asyncio")
async def test_create_bill_requires_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
client = StubPal24Client(configured=False)
service = Pal24Service(client)
with pytest.raises(Pal24APIError):
await service.create_bill(
amount_kopeks=1000,
user_id=1,
order_id="order",
description="desc",
)
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
payload = {
"InvId": "INV1",
"OutSum": "100.00",
"Status": "SUCCESS",
"SignatureValue": sig,
}
result = Pal24Service.parse_postback(payload)
assert result["InvId"] == "INV1"
def test_parse_postback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
with pytest.raises(Pal24APIError):
Pal24Service.parse_postback({"InvId": "1"})
def test_convert_to_kopeks_and_expiration() -> None:
assert Pal24Service.convert_to_kopeks("10.50") == 1050
expiration = Pal24Service.get_expiration(60)
assert isinstance(expiration, datetime)
assert expiration - datetime.utcnow() <= timedelta(seconds=61)

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.exc import MissingGreenlet
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.common import PaymentCommonMixin
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class _FakeBot:
def __init__(self) -> None:
self.messages: list[dict] = []
async def send_message(self, **kwargs): # type: ignore[no-untyped-def]
self.messages.append(kwargs)
class _LazyUser:
id = 99
telegram_id = 555
language = "ru"
@property
def subscription(self): # type: ignore[no-untyped-def]
raise MissingGreenlet("lazy load is not available")
class _PaymentServiceStub(PaymentCommonMixin):
def __init__(self) -> None:
self.bot = _FakeBot()
self.keyboard_user: SimpleNamespace | None = None
async def build_topup_success_keyboard(self, user): # type: ignore[no-untyped-def]
self.keyboard_user = user
return InlineKeyboardMarkup(inline_keyboard=[])
@pytest.mark.anyio
async def test_send_payment_success_notification_recovers_missing_greenlet(monkeypatch: pytest.MonkeyPatch) -> None:
service = _PaymentServiceStub()
lazy_user = _LazyUser()
reloaded_user = SimpleNamespace(
id=lazy_user.id,
telegram_id=lazy_user.telegram_id,
language=lazy_user.language,
subscription=SimpleNamespace(
is_trial=False,
is_active=True,
actual_status="active",
),
)
sentinel_db = object()
async def fake_get_user_by_telegram_id(db, telegram_id): # type: ignore[no-untyped-def]
assert db is sentinel_db
assert telegram_id == lazy_user.telegram_id
return reloaded_user
async def fake_get_db(): # type: ignore[no-untyped-def]
yield object()
monkeypatch.setattr(
"app.services.payment.common.get_user_by_telegram_id",
fake_get_user_by_telegram_id,
)
monkeypatch.setattr(
"app.services.payment.common.get_db",
fake_get_db,
)
await service._send_payment_success_notification(
lazy_user.telegram_id,
12300,
user=lazy_user,
db=sentinel_db,
payment_method_title="Тестовый метод",
)
assert service.bot.messages, "Ожидалось, что уведомление будет отправлено"
message = service.bot.messages[0]
assert "Тестовый метод" in message["text"]
assert service.keyboard_user is not None
assert isinstance(service.keyboard_user, SimpleNamespace)

View File

@@ -0,0 +1,152 @@
"""Тесты сценариев CryptoBot в PaymentService."""
from pathlib import Path
from typing import Any, Dict, Optional
import sys
from datetime import datetime
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.config import settings # noqa: E402
from app.database.crud import cryptobot as cryptobot_crud # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummySession:
def __init__(self) -> None:
self.added_objects: list[Any] = []
async def commit(self) -> None: # pragma: no cover
return None
def add(self, obj: Any) -> None: # pragma: no cover
self.added_objects.append(obj)
async def flush(self) -> None: # pragma: no cover
return None
class DummyLocalPayment:
def __init__(self, payment_id: int = 888) -> None:
self.id = payment_id
self.created_at = datetime(2024, 3, 1, 9, 0, 0)
class StubCryptoBotService:
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_invoice(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
self.calls.append(kwargs)
return self.response
def _make_service(stub: Optional[StubCryptoBotService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.cryptobot_service = stub
service.mulenpay_service = None
service.pal24_service = None
service.yookassa_service = None
service.stars_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_cryptobot_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
"invoice_id": 12345,
"bot_invoice_url": "https://t.me/invoice",
"mini_app_invoice_url": "https://mini.app/invoice",
"web_app_invoice_url": "https://web.app/invoice",
}
stub = StubCryptoBotService(response)
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_cryptobot_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=555)
monkeypatch.setattr(
cryptobot_crud,
"create_cryptobot_payment",
fake_create_cryptobot_payment,
raising=False,
)
monkeypatch.setattr(
type(settings),
"get_cryptobot_invoice_expires_seconds",
lambda self: 600,
raising=False,
)
result = await service.create_cryptobot_payment(
db=db,
user_id=9,
amount_usd=12.5,
asset="USDT",
description="Пополнение",
payload="custom",
)
assert result is not None
assert result["local_payment_id"] == 555
assert result["invoice_id"] == "12345"
assert result["bot_invoice_url"] == "https://t.me/invoice"
assert stub.calls and stub.calls[0]["expires_in"] == 600
assert captured_args["invoice_id"] == "12345"
assert captured_args["amount"] == "12.50"
@pytest.mark.anyio("asyncio")
async def test_create_cryptobot_payment_returns_none_when_service_missing() -> None:
service = _make_service(None)
db = DummySession()
result = await service.create_cryptobot_payment(
db=db,
user_id=1,
amount_usd=10,
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_create_cryptobot_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubCryptoBotService(response=None)
service = _make_service(stub)
db = DummySession()
called = False
async def fake_create_cryptobot_payment(**kwargs: Any) -> DummyLocalPayment:
nonlocal called
called = True
return DummyLocalPayment()
monkeypatch.setattr(
cryptobot_crud,
"create_cryptobot_payment",
fake_create_cryptobot_payment,
raising=False,
)
result = await service.create_cryptobot_payment(
db=db,
user_id=1,
amount_usd=5,
)
assert result is None
assert called is False

View File

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

View File

@@ -0,0 +1,140 @@
"""Тесты для сценариев MulenPay в PaymentService."""
from pathlib import Path
from typing import Any, Dict, Optional
import sys
from datetime import datetime
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
from app.config import settings # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummySession:
async def commit(self) -> None: # pragma: no cover - метод вызывается, но без логики
return None
class DummyLocalPayment:
def __init__(self, payment_id: int = 501) -> None:
self.id = payment_id
self.created_at = datetime(2024, 1, 1, 12, 0, 0)
class StubMulenPayService:
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
self.calls.append(kwargs)
return self.response
def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.mulenpay_service = stub
service.pal24_service = None
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_mulenpay_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {"id": 123, "paymentUrl": "https://mulenpay/pay"}
stub = StubMulenPayService(response)
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_mulenpay_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=999)
monkeypatch.setattr(
payment_service_module,
"create_mulenpay_payment",
fake_create_mulenpay_payment,
raising=False,
)
monkeypatch.setattr(settings, "MULENPAY_MIN_AMOUNT_KOPEKS", 1000, raising=False)
monkeypatch.setattr(settings, "MULENPAY_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
monkeypatch.setattr(settings, "MULENPAY_VAT_CODE", 1, raising=False)
monkeypatch.setattr(settings, "MULENPAY_PAYMENT_SUBJECT", "service", raising=False)
monkeypatch.setattr(settings, "MULENPAY_PAYMENT_MODE", "full_payment", raising=False)
monkeypatch.setattr(settings, "MULENPAY_LANGUAGE", "ru", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False)
result = await service.create_mulenpay_payment(
db=db,
user_id=77,
amount_kopeks=25000,
description="Пополнение",
language="en",
)
assert result is not None
assert result["local_payment_id"] == 999
assert result["mulen_payment_id"] == 123
assert result["payment_url"] == "https://mulenpay/pay"
assert result["status"] == "created"
assert stub.calls and stub.calls[0]["language"] == "en"
assert captured_args["user_id"] == 77
assert captured_args["amount_kopeks"] == 25000
assert captured_args["uuid"].startswith("mulen_77_")
@pytest.mark.anyio("asyncio")
async def test_create_mulenpay_payment_respects_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubMulenPayService({"id": 1})
service = _make_service(stub)
db = DummySession()
monkeypatch.setattr(settings, "MULENPAY_MIN_AMOUNT_KOPEKS", 5000, raising=False)
monkeypatch.setattr(settings, "MULENPAY_MAX_AMOUNT_KOPEKS", 10_000, raising=False)
result_low = await service.create_mulenpay_payment(
db=db,
user_id=1,
amount_kopeks=1000,
description="Пополнение",
)
assert result_low is None
result_high = await service.create_mulenpay_payment(
db=db,
user_id=1,
amount_kopeks=20_000,
description="Пополнение",
)
assert result_high is None
assert not stub.calls
@pytest.mark.anyio("asyncio")
async def test_create_mulenpay_payment_returns_none_without_service() -> None:
service = _make_service(None)
db = DummySession()
result = await service.create_mulenpay_payment(
db=db,
user_id=1,
amount_kopeks=5000,
description="Пополнение",
)
assert result is None

View File

@@ -0,0 +1,166 @@
"""Тесты Pal24 сценариев PaymentService."""
from pathlib import Path
from typing import Any, Dict, Optional
import sys
from datetime import datetime
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
from app.config import settings # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.services.pal24_service import Pal24APIError # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummySession:
async def commit(self) -> None: # pragma: no cover
return None
class DummyLocalPayment:
def __init__(self, payment_id: int = 404) -> None:
self.id = payment_id
self.created_at = datetime(2024, 1, 2, 10, 0, 0)
class StubPal24Service:
def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
self.is_configured = configured
self.response = response or {
"success": True,
"bill_id": "BILL-1",
"transfer_url": "https://pal24/sbp",
"link_url": "https://pal24/card",
"status": "NEW",
}
self.calls: list[Dict[str, Any]] = []
self.raise_error: Optional[Exception] = None
async def create_bill(self, **kwargs: Any) -> Dict[str, Any]:
self.calls.append(kwargs)
if self.raise_error:
raise self.raise_error
return self.response
def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.pal24_service = stub
service.mulenpay_service = None
service.yookassa_service = None
service.cryptobot_service = None
service.stars_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
if args:
captured_args["db_arg"] = args[0]
return DummyLocalPayment(payment_id=321)
monkeypatch.setattr(
payment_service_module,
"create_pal24_payment",
fake_create_pal24_payment,
raising=False,
)
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False)
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
result = await service.create_pal24_payment(
db=db,
user_id=15,
amount_kopeks=50000,
description="Оплата подписки",
language="ru",
ttl_seconds=600,
payer_email="user@example.com",
payment_method="card",
)
assert result is not None
assert result["local_payment_id"] == 321
assert result["bill_id"] == "BILL-1"
assert result["payment_method"] == "CARD"
assert result["link_url"] == "https://pal24/sbp"
assert result["card_url"] == "https://pal24/card"
assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000
assert "links" in captured_args["metadata"]
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
service = _make_service(stub)
db = DummySession()
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 5000, raising=False)
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 20_000, raising=False)
result_low = await service.create_pal24_payment(
db=db,
user_id=1,
amount_kopeks=1000,
description="Пополнение",
language="ru",
)
assert result_low is None
result_high = await service.create_pal24_payment(
db=db,
user_id=1,
amount_kopeks=50_000,
description="Пополнение",
language="ru",
)
assert result_high is None
service_not_configured = _make_service(StubPal24Service(configured=False))
result_config = await service_not_configured.create_pal24_payment(
db=db,
user_id=1,
amount_kopeks=10_000,
description="Пополнение",
language="ru",
)
assert result_config is None
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
stub.raise_error = Pal24APIError("api failed")
service = _make_service(stub)
db = DummySession()
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False)
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 10_000, raising=False)
result = await service.create_pal24_payment(
db=db,
user_id=5,
amount_kopeks=2000,
description="Пополнение",
language="ru",
)
assert result is None

View File

@@ -0,0 +1,142 @@
"""Тесты для Telegram Stars-сценариев внутри PaymentService."""
from pathlib import Path
from typing import Any, Dict, Optional
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_service import PaymentService # noqa: E402
from app.config import settings # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
"""Ограничиваем anyio тесты только бэкендом asyncio."""
return "asyncio"
class DummyBot:
"""Минимальная заглушка aiogram.Bot для тестов."""
def __init__(self) -> None:
self.calls: list[Dict[str, Any]] = []
async def create_invoice_link(self, **kwargs: Any) -> str:
"""Эмулируем создание платежной ссылки и сохраняем параметры вызова."""
self.calls.append(kwargs)
return "https://t.me/invoice/stars"
def _make_service(bot: Optional[DummyBot]) -> PaymentService:
"""Создаёт экземпляр PaymentService без выполнения полного конструктора."""
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = bot
# Stars-сервис достаточно обозначить любым truthy-значением.
service.stars_service = object() if bot else None
return service
@pytest.mark.anyio("asyncio")
async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None:
"""Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1."""
bot = DummyBot()
service = _make_service(bot)
monkeypatch.setattr(
type(settings),
"get_stars_rate",
lambda self: 70,
raising=False,
)
monkeypatch.setattr(
type(settings),
"format_price",
lambda self, amount: f"{amount / 100:.0f}",
raising=False,
)
result = await service.create_stars_invoice(
amount_kopeks=14000,
description="Пополнение",
payload="custom_payload",
)
assert result == "https://t.me/invoice/stars"
assert len(bot.calls) == 1
call = bot.calls[0]
assert call["title"] == "Пополнение баланса VPN"
assert call["payload"] == "custom_payload"
prices = call["prices"]
assert len(prices) == 1
assert prices[0].amount == 2 # 14000 коп. → 140 ₽ → 2 звезды при курсе 70
assert "≈2 ⭐" in call["description"]
@pytest.mark.anyio("asyncio")
async def test_create_stars_invoice_enforces_minimum_star(monkeypatch: pytest.MonkeyPatch) -> None:
"""При слишком маленькой сумме минимум должен составлять 1 звезду."""
bot = DummyBot()
service = _make_service(bot)
monkeypatch.setattr(type(settings), "get_stars_rate", lambda self: 500, raising=False)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: amount, raising=False)
await service.create_stars_invoice(
amount_kopeks=50, # 0.5 ₽ при курсе 500 => <1 звезды
description="Микроплатёж",
)
prices = bot.calls[0]["prices"]
assert prices[0].amount == 1
@pytest.mark.anyio("asyncio")
async def test_create_stars_invoice_uses_explicit_stars(monkeypatch: pytest.MonkeyPatch) -> None:
"""Если передано значение stars_amount, функция должна использовать его напрямую."""
bot = DummyBot()
service = _make_service(bot)
# При явном указании звёзд курс не запрашивается.
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: amount, raising=False)
await service.create_stars_invoice(
amount_kopeks=1000,
description="Оплата подписки",
stars_amount=5,
)
prices = bot.calls[0]["prices"]
assert prices[0].amount == 5
assert "≈5 ⭐" in bot.calls[0]["description"]
@pytest.mark.anyio("asyncio")
async def test_create_stars_invoice_rejects_invalid_rate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Отрицательный или нулевой курс должен приводить к исключению."""
bot = DummyBot()
service = _make_service(bot)
monkeypatch.setattr(type(settings), "get_stars_rate", lambda self: 0, raising=False)
with pytest.raises(ValueError, match="Stars rate must be positive"):
await service.create_stars_invoice(
amount_kopeks=1000,
description="Пополнение",
)
@pytest.mark.anyio("asyncio")
async def test_create_stars_invoice_requires_bot() -> None:
"""Без экземпляра бота и stars_service функция должна отказывать."""
service = _make_service(bot=None)
with pytest.raises(ValueError, match="Bot instance required"):
await service.create_stars_invoice(
amount_kopeks=1000,
description="Пополнение",
)

View File

@@ -0,0 +1,79 @@
"""Тесты Tribute-платежей PaymentService."""
from pathlib import Path
import sys
import hmac
import hashlib
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_service import PaymentService # noqa: E402
from app.config import settings # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
def _make_service() -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.yookassa_service = None
service.mulenpay_service = None
service.pal24_service = None
service.cryptobot_service = None
service.stars_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_tribute_payment_requires_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service()
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
with pytest.raises(ValueError):
await service.create_tribute_payment(1000, 1, "Пополнение")
@pytest.mark.anyio("asyncio")
async def test_create_tribute_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service()
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False)
result = await service.create_tribute_payment(
amount_kopeks=15000,
user_id=5,
description="Оплата подписки",
)
assert "https://tribute.ru/pay" in result
assert "amount=15000" in result
assert "user=5" in result
def test_verify_tribute_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service()
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "secret", raising=False)
payload = {"payment": "ok"}
signature = hmac.new(
b"secret",
str(payload).encode(),
hashlib.sha256,
).hexdigest()
assert service.verify_tribute_webhook(payload, signature) is True
assert service.verify_tribute_webhook(payload, "invalid") is False
def test_verify_tribute_webhook_returns_false_without_key(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service()
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "", raising=False)
assert service.verify_tribute_webhook({}, "signature") is False

View File

@@ -0,0 +1,490 @@
"""Интеграционные проверки обработки вебхуков PaymentService."""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace, ModuleType
from typing import Any, Dict
import sys
import pytest
from unittest.mock import AsyncMock
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.config import settings # noqa: E402
class DummyBot:
def __init__(self) -> None:
self.sent_messages: list[Dict[str, Any]] = []
async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - бизнес-логика тестируется через вызов
self.sent_messages.append({"args": args, "kwargs": kwargs})
class FakeSession:
def __init__(self) -> None:
self.commits = 0
self.refreshed: list[Any] = []
self.added: list[Any] = []
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None: # pragma: no cover
return None
async def refresh(self, obj: Any) -> None:
self.refreshed.append(obj)
def add(self, obj: Any) -> None: # pragma: no cover - используется при создании транзакций
self.added.append(obj)
def _make_service(bot: DummyBot) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = bot
service.yookassa_service = None
service.stars_service = None
service.mulenpay_service = None
service.pal24_service = None
service.cryptobot_service = None
return service
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio("asyncio")
async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
uuid="mulen_uuid",
mulen_payment_id=123,
amount_kopeks=5000,
user_id=42,
transaction_id=None,
is_paid=False,
)
async def fake_get_by_uuid(db, uuid):
return payment
async def fake_get_by_id(db, mid):
return None
monkeypatch.setattr(payment_service_module, "get_mulenpay_payment_by_uuid", fake_get_by_uuid)
monkeypatch.setattr(payment_service_module, "get_mulenpay_payment_by_mulen_id", fake_get_by_id)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=777, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
updated_status: dict[str, Any] = {}
async def fake_update_status(db, payment=None, status=None, **kwargs):
payment.status = status
payment.is_paid = status == "success"
updated_status.update({"status": status, "kwargs": kwargs})
monkeypatch.setattr(payment_service_module, "update_mulenpay_payment_status", fake_update_status)
async def fake_link(db, payment=None, transaction_id=None):
payment.transaction_id = transaction_id
monkeypatch.setattr(payment_service_module, "link_mulenpay_payment_to_transaction", fake_link)
user = SimpleNamespace(
id=42,
telegram_id=100500,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
self.calls: list[Any] = []
async def send_balance_topup_notification(self, *args, **kwargs):
self.calls.append((args, kwargs))
admin_service = DummyAdminService(bot)
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: admin_service))
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"uuid": "mulen_uuid",
"payment_status": "success",
"id": 123,
"amount": "50.00",
}
result = await service.process_mulenpay_callback(fake_session, payload)
assert result is True
assert transactions and transactions[0]["user_id"] == 42
assert payment.transaction_id == 777
assert updated_status["status"] == "success"
assert user.balance_kopeks == 5000
assert fake_session.commits >= 1
assert bot.sent_messages # сообщение пользователю отправлено
@pytest.mark.anyio("asyncio")
async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
invoice_id="inv_1",
user_id=7,
status="pending",
transaction_id=None,
amount="12.50",
asset="USDT",
amount_float=12.5,
)
async def fake_get_crypto(db, invoice_id):
return payment
async def fake_update_status(db, invoice_id, status, paid_at):
payment.status = status
payment.paid_at = paid_at
return payment
async def fake_link(db, invoice_id, transaction_id):
payment.transaction_id = transaction_id
fake_cryptobot_module = ModuleType("app.database.crud.cryptobot")
fake_cryptobot_module.get_cryptobot_payment_by_invoice_id = fake_get_crypto
fake_cryptobot_module.update_cryptobot_payment_status = fake_update_status
fake_cryptobot_module.link_cryptobot_payment_to_transaction = fake_link
monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=888, **kwargs)
fake_transaction_module = ModuleType("app.database.crud.transaction")
fake_transaction_module.create_transaction = fake_create_transaction
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=7,
telegram_id=700,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
)
async def fake_get_user_crypto(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto)
referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto)
admin_calls: list[Any] = []
class DummyAdminService2:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)))
monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0))
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"update_type": "invoice_paid",
"payload": {
"invoice_id": "inv_1",
"paid_at": "2024-01-01T12:00:00Z",
},
}
result = await service.process_cryptobot_webhook(fake_session, payload)
assert result is True
assert transactions and transactions[0]["amount_kopeks"] == 14000
assert user.balance_kopeks == 14000
assert payment.transaction_id == 888
assert bot.sent_messages
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
yookassa_payment_id="yk_123",
user_id=21,
amount_kopeks=10000,
transaction_id=None,
status="pending",
is_paid=False,
)
async def fake_get_payment(db, payment_id):
return payment
async def fake_update(db, payment_id, status, is_paid, is_captured, captured_at, payment_method_type):
payment.status = status
payment.is_paid = is_paid
payment.captured_at = captured_at
return payment
async def fake_link(db, payment_id, transaction_id):
payment.transaction_id = transaction_id
yk_module = ModuleType("app.database.crud.yookassa")
yk_module.get_yookassa_payment_by_id = fake_get_payment
yk_module.update_yookassa_payment_status = fake_update
yk_module.link_yookassa_payment_to_transaction = fake_link
monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=999, **kwargs)
trx_module = ModuleType("app.database.crud.transaction")
trx_module.create_transaction = fake_create_transaction
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", trx_module)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=21,
telegram_id=2100,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
admin_calls: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)))
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"object": {
"id": "yk_123",
"status": "succeeded",
"paid": True,
"payment_method": {"type": "bank_card"},
}
}
result = await service.process_yookassa_webhook(fake_session, payload)
assert result is True
assert transactions and transactions[0]["amount_kopeks"] == 10000
assert payment.transaction_id == 999
assert user.balance_kopeks == 10000
assert bot.sent_messages
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
db = FakeSession()
result = await service.process_yookassa_webhook(db, {"object": {}})
assert result is False
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
service.pal24_service = SimpleNamespace(is_configured=True)
fake_session = FakeSession()
payment = SimpleNamespace(
bill_id="BILL-1",
order_id="order-1",
amount_kopeks=5000,
user_id=33,
transaction_id=None,
is_paid=False,
status="NEW",
)
async def fake_get_by_order(db, order_id):
return payment
async def fake_get_by_bill(db, bill_id):
return payment
async def fake_update(db, payment_obj, **kwargs):
payment.status = kwargs.get("status", payment.status)
payment.is_paid = kwargs.get("is_paid", payment.is_paid)
payment.payment_status = kwargs.get("payment_status", payment.status)
payment.callback_payload = kwargs.get("callback_payload")
return payment
async def fake_link(db, payment_obj, transaction_id):
payment.transaction_id = transaction_id
pal_module = ModuleType("app.database.crud.pal24")
pal_module.get_pal24_payment_by_order_id = fake_get_by_order
pal_module.get_pal24_payment_by_bill_id = fake_get_by_bill
pal_module.update_pal24_payment_status = fake_update
pal_module.link_pal24_payment_to_transaction = fake_link
monkeypatch.setitem(sys.modules, "app.database.crud.pal24", pal_module)
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_order_id", fake_get_by_order)
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_bill_id", fake_get_by_bill)
monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update)
monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link)
async def fake_create_transaction(db, **kwargs):
payment.transaction_id = 654
return SimpleNamespace(id=654, **kwargs)
trx_module = ModuleType("app.database.crud.transaction")
trx_module.create_transaction = fake_create_transaction
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", trx_module)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=33,
telegram_id=3300,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_pal = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_pal)
admin_calls: list[Any] = []
class DummyAdminServicePal:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)))
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"InvId": "order-1",
"OutSum": "50.00",
"Status": "SUCCESS",
"TrsId": "trs-1",
}
result = await service.process_pal24_postback(fake_session, payload)
assert result is True
assert payment.transaction_id == 654
assert user.balance_kopeks == 5000
assert bot.sent_messages
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
service.pal24_service = SimpleNamespace(is_configured=True)
db = FakeSession()
async def fake_get_by_order(db, order_id):
return None
async def fake_get_by_bill(db, bill_id):
return None
pal_module = ModuleType("app.database.crud.pal24")
pal_module.get_pal24_payment_by_order_id = fake_get_by_order
pal_module.get_pal24_payment_by_bill_id = fake_get_by_bill
pal_module.update_pal24_payment_status = AsyncMock()
pal_module.link_pal24_payment_to_transaction = AsyncMock()
monkeypatch.setitem(sys.modules, "app.database.crud.pal24", pal_module)
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_order_id", fake_get_by_order)
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_bill_id", fake_get_by_bill)
payload = {
"InvId": "order-unknown",
"OutSum": "10.00",
"Status": "SUCCESS",
}
result = await service.process_pal24_postback(db, payload)
assert result is False

View File

@@ -0,0 +1,245 @@
"""Тесты для YooKassa-сценариев PaymentService."""
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
from app.config import settings # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
"""Запускаем async-тесты на asyncio, чтобы избежать зависимостей trio."""
return "asyncio"
class DummySession:
"""Простейшая заглушка AsyncSession."""
def __init__(self) -> None:
self.committed = False
async def commit(self) -> None:
self.committed = True
async def rollback(self) -> None:
self.rolled_back = True # type: ignore[attr-defined]
class DummyLocalPayment:
"""Объект, имитирующий локальную запись платежа."""
def __init__(self, payment_id: int = 101) -> None:
self.id = payment_id
self.created_at = datetime(2024, 1, 1, 12, 0, 0)
class StubYooKassaService:
"""Заглушка для SDK, сохраняющая вызовы."""
def __init__(self, response: Dict[str, Any]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_payment(self, **kwargs: Any) -> Dict[str, Any]:
self.calls.append(kwargs)
return self.response
async def create_sbp_payment(self, **kwargs: Any) -> Dict[str, Any]:
self.calls.append(kwargs)
return self.response
def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.yookassa_service = yookassa_service
service.stars_service = None
service.mulenpay_service = None
service.pal24_service = None
service.mulenpay_service = None
service.cryptobot_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_yookassa_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""Успешное создание платежа формирует корректные метаданные и локальную запись."""
response = {
"id": "yk_123",
"status": "pending",
"confirmation_url": "https://yookassa.ru/confirm",
"amount": {"value": "140.00", "currency": "RUB"},
"metadata": {"existing": "value"},
"created_at": "2024-01-01T12:00:00Z",
"test_mode": False,
}
service = _make_service(StubYooKassaService(response))
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=555)
monkeypatch.setattr(
payment_service_module,
"create_yookassa_payment",
fake_create_yookassa_payment,
raising=False,
)
monkeypatch.setattr(
type(settings),
"format_price",
lambda self, amount: f"{amount / 100:.0f}",
raising=False,
)
result = await service.create_yookassa_payment(
db=db,
user_id=42,
amount_kopeks=14000,
description="Пополнение",
receipt_email="user@example.com",
metadata={"custom": "data"},
)
assert result is not None
assert result["local_payment_id"] == 555
assert result["yookassa_payment_id"] == "yk_123"
assert result["amount_kopeks"] == 14000
assert result["amount_rubles"] == 140
assert result["status"] == "pending"
assert captured_args["user_id"] == 42
assert captured_args["metadata_json"]["custom"] == "data"
assert captured_args["metadata_json"]["user_id"] == "42"
assert captured_args["metadata_json"]["amount_kopeks"] == "14000"
assert isinstance(captured_args["yookassa_created_at"], datetime)
@pytest.mark.anyio("asyncio")
async def test_create_yookassa_payment_returns_none_when_service_missing() -> None:
"""Если сервис не настроен, метод должен вернуть None."""
service = _make_service(None)
db = DummySession()
result = await service.create_yookassa_payment(
db=db,
user_id=1,
amount_kopeks=1000,
description="Пополнение",
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_create_yookassa_payment_handles_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ответ с ключом error должен приводить к None без записи в БД."""
response = {"error": True}
service = _make_service(StubYooKassaService(response))
db = DummySession()
called = False
async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment:
nonlocal called
called = True
return DummyLocalPayment()
monkeypatch.setattr(
payment_service_module,
"create_yookassa_payment",
fake_create_yookassa_payment,
raising=False,
)
result = await service.create_yookassa_payment(
db=db,
user_id=1,
amount_kopeks=5000,
description="Пополнение",
)
assert result is None
assert called is False
@pytest.mark.anyio("asyncio")
async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""Проверяем SBP-сценарий, включая передачу confirmation_token."""
response = {
"id": "yk_sbp_001",
"status": "pending",
"confirmation_url": "https://yookassa.ru/confirm",
"confirmation": {"confirmation_token": "token123"},
"created_at": "2024-02-01T10:00:00Z",
}
service = _make_service(StubYooKassaService(response))
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=777)
monkeypatch.setattr(
payment_service_module,
"create_yookassa_payment",
fake_create_yookassa_payment,
raising=False,
)
result = await service.create_yookassa_sbp_payment(
db=db,
user_id=7,
amount_kopeks=25000,
description="СБП пополнение",
)
assert result is not None
assert result["confirmation_token"] == "token123"
assert captured_args["payment_method_type"] == "bank_card"
assert captured_args["metadata_json"]["type"] == "balance_topup_sbp"
@pytest.mark.anyio("asyncio")
async def test_create_yookassa_sbp_payment_returns_none_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ошибочный ответ СБП не должен создавать запись."""
response = {"error": "invalid"}
service = _make_service(StubYooKassaService(response))
db = DummySession()
called = False
async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment:
nonlocal called
called = True
return DummyLocalPayment()
monkeypatch.setattr(
payment_service_module,
"create_yookassa_payment",
fake_create_yookassa_payment,
raising=False,
)
result = await service.create_yookassa_sbp_payment(
db=db,
user_id=1,
amount_kopeks=1000,
description="СБП пополнение",
)
assert result is None
assert called is False

View File

@@ -0,0 +1,179 @@
"""Тесты низкоуровневого сервиса YooKassaService."""
from __future__ import annotations
import asyncio
from datetime import datetime
from pathlib import Path
import sys
from types import SimpleNamespace
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 yookassa import Configuration, Payment as YooKassaPayment # type: ignore # noqa: E402
from app.config import settings # noqa: E402
from app.services.yookassa_service import YooKassaService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummyLoop:
async def run_in_executor(self, _executor, func):
return func()
def _prepare_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop123", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "secret123", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_RETURN_URL", "https://example.com/return", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_VAT_CODE", 1, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_PAYMENT_MODE", "full_payment", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_PAYMENT_SUBJECT", "service", raising=False)
def test_init_without_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "", raising=False)
service = YooKassaService()
assert service.configured is False
assert service.return_url == "https://t.me/"
@pytest.mark.anyio("asyncio")
async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
_prepare_config(monkeypatch)
monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", None, raising=False)
monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False)
captured_config: dict[str, tuple[str, str]] = {}
def fake_configure(shop_id: str, secret_key: str) -> None:
captured_config["values"] = (shop_id, secret_key)
monkeypatch.setattr(Configuration, "configure", fake_configure, raising=False)
response_obj = SimpleNamespace(
id="yk_1",
status="pending",
paid=False,
confirmation=SimpleNamespace(confirmation_url="https://yk/confirm"),
metadata={"meta": "value"},
amount=SimpleNamespace(value="140.00", currency="RUB"),
refundable=True,
created_at=datetime(2024, 1, 1, 12, 0, 0),
description="Desc",
test=False,
)
monkeypatch.setattr(
YooKassaPayment,
"create",
staticmethod(lambda payload, key: response_obj),
raising=False,
)
service = YooKassaService()
monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", "fallback@example.com", raising=False)
result = await service.create_payment(
amount=140.0,
currency="RUB",
description="Пополнение",
metadata={"order": "1"},
receipt_email="user@example.com",
)
assert service.configured is True
assert captured_config["values"] == ("shop123", "secret123")
assert result is not None
assert result["id"] == "yk_1"
assert result["confirmation_url"] == "https://yk/confirm"
assert result["amount_value"] == 140.0
assert result["status"] == "pending"
@pytest.mark.anyio("asyncio")
async def test_create_payment_without_contacts(monkeypatch: pytest.MonkeyPatch) -> None:
_prepare_config(monkeypatch)
monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", None, raising=False)
monkeypatch.setattr(Configuration, "configure", lambda *args, **kwargs: None, raising=False)
monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False)
monkeypatch.setattr(
YooKassaPayment,
"create",
staticmethod(lambda payload, key: SimpleNamespace()),
raising=False,
)
service = YooKassaService()
result = await service.create_payment(
amount=10,
currency="RUB",
description="desc",
metadata={},
)
assert result is not None
assert result.get("error") is True
@pytest.mark.anyio("asyncio")
async def test_create_payment_returns_none_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "", raising=False)
service = YooKassaService()
result = await service.create_payment(
amount=10,
currency="RUB",
description="desc",
metadata={},
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
_prepare_config(monkeypatch)
monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False)
monkeypatch.setattr(Configuration, "configure", lambda *args, **kwargs: None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", "fallback@example.com", raising=False)
response_obj = SimpleNamespace(
id="sbp_001",
status="pending",
paid=False,
confirmation=SimpleNamespace(confirmation_url="https://sbp/confirm"),
metadata={"meta": "value"},
amount=SimpleNamespace(value="200.00", currency="RUB"),
refundable=False,
created_at=datetime(2024, 2, 1, 9, 0, 0),
description="SBP payment",
test=True,
)
monkeypatch.setattr(
YooKassaPayment,
"create",
staticmethod(lambda payload, key: response_obj),
raising=False,
)
service = YooKassaService()
result = await service.create_sbp_payment(
amount=200.0,
currency="rub",
description="Оплата",
metadata={"type": "sbp"},
receipt_phone="+70000000000",
)
assert result is not None
assert result["id"] == "sbp_001"
assert result["confirmation_url"] == "https://sbp/confirm"
assert result["status"] == "pending"

View File

@@ -136,6 +136,58 @@ async def test_resolve_yookassa_status_includes_identifiers(monkeypatch):
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
@pytest.mark.anyio("asyncio")
async def test_resolve_payment_status_supports_yookassa_sbp(monkeypatch):
payment = types.SimpleNamespace(
id=77,
user_id=5,
amount_kopeks=25000,
currency='RUB',
status='pending',
is_paid=False,
captured_at=None,
updated_at=None,
created_at=datetime.utcnow(),
transaction_id=None,
yookassa_payment_id='yk_sbp_1',
)
async def fake_get_by_local_id(db, local_id): # noqa: ARG001
return payment if local_id == 77 else None
async def fake_get_by_id(db, payment_id): # noqa: ARG001
return None
stub_module = types.SimpleNamespace(
get_yookassa_payment_by_local_id=fake_get_by_local_id,
get_yookassa_payment_by_id=fake_get_by_id,
)
monkeypatch.setitem(sys.modules, 'app.database.crud.yookassa', stub_module)
user = types.SimpleNamespace(id=5)
query = MiniAppPaymentStatusQuery(
method='yookassa_sbp',
localPaymentId=77,
amountKopeks=25000,
startedAt='2024-05-01T10:00:00Z',
payload='sbp_payload',
)
result = await miniapp._resolve_payment_status_entry(
payment_service=types.SimpleNamespace(),
db=None,
user=user,
query=query,
)
assert result.method == 'yookassa_sbp'
assert result.status == 'pending'
assert result.extra['local_payment_id'] == 77
assert result.extra['payment_id'] == 'yk_sbp_1'
assert result.extra['payload'] == 'sbp_payload'
assert result.extra['started_at'] == '2024-05-01T10:00:00Z'
@pytest.mark.anyio("asyncio")
async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
async def fake_get_pal24_payment_by_bill_id(db, bill_id):

1
tests/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# В этом пакете будут жить модульные тесты для вспомогательных утилит приложения.

View File

@@ -0,0 +1,104 @@
"""Тесты для базовых форматтеров из app.utils.formatters."""
from datetime import datetime, timedelta
from app.utils import formatters
def test_format_datetime_handles_iso_strings(fixed_datetime: datetime) -> None:
"""ISO-строка должна корректно преобразовываться в отформатированный текст."""
iso_value = fixed_datetime.isoformat()
assert formatters.format_datetime(iso_value) == fixed_datetime.strftime("%d.%m.%Y %H:%M")
def test_format_date_uses_custom_format(fixed_datetime: datetime) -> None:
"""Можно задавать собственный шаблон вывода."""
iso_value = fixed_datetime.isoformat()
assert formatters.format_date(iso_value, format_str="%Y/%m/%d") == fixed_datetime.strftime("%Y/%m/%d")
def test_format_time_ago_returns_human_readable_text() -> None:
"""Разница во времени должна переводиться в человеко-понятную строку."""
point_in_time = datetime.utcnow() - timedelta(minutes=5)
assert formatters.format_time_ago(point_in_time, language="ru") == "5 мин. назад"
assert formatters.format_time_ago(point_in_time, language="en") == "5 minutes ago"
def test_format_days_declension_handles_russian_rules() -> None:
"""Склонение дней в русском языке зависит от числа."""
assert formatters.format_days_declension(1) == "1 день"
assert formatters.format_days_declension(3) == "3 дня"
assert formatters.format_days_declension(10) == "10 дней"
def test_format_duration_switches_units() -> None:
"""В зависимости от длины интервала выбирается подходящая единица измерения."""
assert formatters.format_duration(45) == "45 сек."
assert formatters.format_duration(120) == "2 мин."
assert formatters.format_duration(7200) == "2 ч."
assert formatters.format_duration(172800) == "2 дн."
def test_format_bytes_scales_value() -> None:
"""Размер должен выражаться в наиболее подходящей единице."""
assert formatters.format_bytes(0) == "0 B"
assert formatters.format_bytes(1024) == "1 KB"
assert formatters.format_bytes(1024 * 1024) == "1 MB"
def test_format_percentage_respects_precision() -> None:
"""Проценты форматируются с нужным количеством знаков."""
assert formatters.format_percentage(12.3456, decimals=2) == "12.35%"
def test_format_number_inserts_separators() -> None:
"""Разделители тысяч должны расставляться корректно как для int, так и для float."""
assert formatters.format_number(1234567) == "1 234 567"
assert formatters.format_number(1234.56) == "1 234.55"
def test_truncate_text_appends_suffix() -> None:
"""Строки, превышающие лимит, должны обрезаться и дополняться суффиксом."""
source = "a" * 10
assert formatters.truncate_text(source, max_length=5) == "aa..."
def test_format_username_prefers_full_name() -> None:
"""Полное имя имеет приоритет, затем username, затем ID."""
assert formatters.format_username("nickname", 1, full_name="Имя") == "Имя"
assert formatters.format_username("nickname", 1, full_name=None) == "@nickname"
assert formatters.format_username(None, 42, full_name=None) == "ID42"
def test_format_subscription_status_handles_active_and_expired() -> None:
"""Статус подписки различается для активных/просроченных случаев."""
future = datetime.utcnow() + timedelta(days=2)
active = formatters.format_subscription_status(
is_active=True,
is_trial=False,
end_date=future,
language="ru",
)
assert active.startswith("✅ Активна")
assert "(" in active and ")" in active
past = datetime.utcnow() - timedelta(days=1)
expired = formatters.format_subscription_status(
is_active=True,
is_trial=False,
end_date=past,
language="ru",
)
assert expired == "⏰ Истекла"
def test_format_traffic_usage_supports_unlimited() -> None:
"""При безлимитном тарифе в строке должна появляться бесконечность."""
assert formatters.format_traffic_usage(50.0, 0, language="ru") == "50.0 ГБ / ∞"
assert formatters.format_traffic_usage(10.0, 100, language="ru") == "10.0 ГБ / 100 ГБ (10.0%)"
def test_format_boolean_localises_output() -> None:
"""Булевые значения отображаются локализованными словами."""
assert formatters.format_boolean(True, language="ru") == "✅ Да"
assert formatters.format_boolean(False, language="en") == "❌ No"

View File

@@ -0,0 +1,54 @@
"""Тесты для функций безопасности из app.utils.security."""
import hashlib
import pytest
from app.utils.security import generate_api_token, hash_api_token
def test_hash_api_token_default_algorithm_matches_hashlib() -> None:
"""Проверяем, что алгоритм по умолчанию совпадает с hashlib.sha256."""
sample = "secret-token"
# Самостоятельно считаем эталонное значение.
expected = hashlib.sha256(sample.encode("utf-8")).hexdigest()
# Сравниваем с функцией проекта.
assert hash_api_token(sample) == expected
@pytest.mark.parametrize(
"algorithm,hash_factory",
[
("sha256", hashlib.sha256),
("sha384", hashlib.sha384),
("sha512", hashlib.sha512),
],
)
def test_hash_api_token_accepts_supported_algorithms(algorithm, hash_factory) -> None:
"""Каждый поддерживаемый алгоритм должен выдавать корректный результат."""
sample = "token-value"
expected = hash_factory(sample.encode("utf-8")).hexdigest()
assert hash_api_token(sample, algorithm=algorithm) == expected
def test_hash_api_token_rejects_unknown_algorithm() -> None:
"""Некорректное имя алгоритма должно приводить к ValueError."""
with pytest.raises(ValueError):
hash_api_token("value", algorithm="md5") # type: ignore[arg-type]
@pytest.mark.parametrize("length", [8, 24, 48, 256])
def test_generate_api_token_respects_length_bounds(length: int) -> None:
"""Функция должна ограничивать длину токена безопасным диапазоном."""
token = generate_api_token(length)
clamped = max(24, min(length, 128))
assert len(token) >= clamped
# token_urlsafe расширяет строку, поэтому добавляем запас по длине.
assert len(token) <= clamped * 2
def test_generate_api_token_produces_random_values() -> None:
"""Два последовательных вызова должны выдавать разные токены."""
first = generate_api_token(48)
second = generate_api_token(48)
assert first != second

View File

@@ -0,0 +1,141 @@
"""Базовые тесты для валидаторов из app.utils.validators."""
import pytest
from app.utils import validators
@pytest.mark.parametrize(
"email,is_valid",
[
("user@example.com", True),
("user.name+tag@sub.domain.ru", True),
("plain-address", False),
("missing-at.example.com", False),
("user@invalid", False),
],
)
def test_validate_email_handles_expected_patterns(email: str, is_valid: bool) -> None:
"""Проверяем типичные корректные и некорректные адреса."""
assert validators.validate_email(email) is is_valid
@pytest.mark.parametrize(
"phone,is_valid",
[
("+71234567890", True),
("+1 (202) 555-0101", True),
("12345", True),
("+0 123456789", False),
("abc", False),
],
)
def test_validate_phone_strips_formatting_and_checks_pattern(phone: str, is_valid: bool) -> None:
"""Телефон должен соответствовать стандарту E.164 после очистки."""
assert validators.validate_phone(phone) is is_valid
@pytest.mark.parametrize(
"username,is_valid",
[
("@valid_name", True),
("simpleUser", True),
("bad", False),
("toolongusername_more_than32_chars", False),
("", False),
],
)
def test_validate_telegram_username_enforces_length(username: str, is_valid: bool) -> None:
"""Telegram-логин должен быть 5-32 символов и содержать допустимые символы."""
assert validators.validate_telegram_username(username) is is_valid
def test_validate_amount_returns_float_within_bounds() -> None:
"""Числа должны конвертироваться с уважением к диапазону."""
assert validators.validate_amount("10.5", min_amount=5, max_amount=20) == pytest.approx(10.5)
assert validators.validate_amount("2", min_amount=5, max_amount=20) is None
assert validators.validate_amount("abc", min_amount=0, max_amount=10) is None
def test_validate_positive_integer_enforces_upper_bound() -> None:
"""Положительное целое число выходит за пределы — возвращаем None."""
assert validators.validate_positive_integer("12", max_value=20) == 12
assert validators.validate_positive_integer("0", max_value=20) is None
assert validators.validate_positive_integer("50", max_value=20) is None
assert validators.validate_positive_integer("NaN") is None
@pytest.mark.parametrize(
"value,expected",
[
("500", 500),
("10gb", 10240),
("2 TB", 2097152),
("безлимит", 0),
("invalid", None),
],
)
def test_validate_traffic_amount_supports_units(value: str, expected: int | None) -> None:
"""Валидатор трафика распознаёт разные единицы измерения и особые значения."""
assert validators.validate_traffic_amount(value) == expected
def test_validate_subscription_period_accepts_reasonable_range() -> None:
"""Диапазон допустимой длительности от 1 до 3650 дней."""
assert validators.validate_subscription_period("30") == 30
assert validators.validate_subscription_period(0) is None
assert validators.validate_subscription_period(4000) is None
def test_validate_uuid_detects_standard_format() -> None:
"""UUID должен соответствовать HEX шаблону версии 4/5."""
sample = "123e4567-e89b-12d3-a456-426614174000"
assert validators.validate_uuid(sample) is True
assert validators.validate_uuid("not-a-uuid") is False
def test_validate_url_recognises_https_links() -> None:
"""Валидатор URL допускает http/https ссылки и отклоняет произвольные строки."""
assert validators.validate_url("https://example.com/path?query=1")
assert not validators.validate_url("ftp://example.com")
def test_validate_html_tags_rejects_unknown_tags() -> None:
"""Неизвестные HTML теги должны приводить к отказу."""
ok, message = validators.validate_html_tags("<b>bold</b>")
assert ok is True
bad, error = validators.validate_html_tags("<marquee>run</marquee>")
assert bad is False
assert "Неподдерживаемый тег" in error
def test_validate_html_structure_detects_wrong_nesting() -> None:
"""Неправильная вложенность тегов должна сообщаться пользователю."""
ok, message = validators.validate_html_structure("<b><i>text</i></b>")
assert ok is True
bad, error = validators.validate_html_structure("<b><i>text</b></i>")
assert bad is False
assert "Неправильная вложенность" in error
def test_fix_html_tags_repairs_missing_quotes() -> None:
"""Автоисправление должно добавлять кавычки у ссылок."""
broken = '<a href=https://example.com>link</a>'
fixed = validators.fix_html_tags(broken)
assert 'href="https://example.com"' in fixed
def test_validate_rules_content_detects_structure_error() -> None:
"""При нарушении структуры должны вернуться сообщение и отсутствие подсказки."""
is_valid, message, suggestion = validators.validate_rules_content("<b>text</i>")
assert is_valid is False
assert "Неправильная вложенность" in message
assert suggestion is None
def test_validate_rules_content_accepts_supported_markup() -> None:
"""Корректный HTML должен проходить проверку без сообщений."""
is_valid, message, suggestion = validators.validate_rules_content("<b>Добро пожаловать!</b>")
assert is_valid is True
assert message == ""
assert suggestion is None