mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-05-05 04:16:17 +00:00
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
23
app/services/payment/__init__.py
Normal file
23
app/services/payment/__init__.py
Normal 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",
|
||||
]
|
||||
211
app/services/payment/common.py
Normal file
211
app/services/payment/common.py
Normal 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
|
||||
299
app/services/payment/cryptobot.py
Normal file
299
app/services/payment/cryptobot.py
Normal 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
|
||||
400
app/services/payment/mulenpay.py
Normal file
400
app/services/payment/mulenpay.py
Normal 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
|
||||
431
app/services/payment/pal24.py
Normal file
431
app/services/payment/pal24.py
Normal 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
|
||||
249
app/services/payment/stars.py
Normal file
249
app/services/payment/stars.py
Normal 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
|
||||
71
app/services/payment/tribute.py
Normal file
71
app/services/payment/tribute.py
Normal 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
|
||||
364
app/services/payment/yookassa.py
Normal file
364
app/services/payment/yookassa.py
Normal 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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
825
docs/project_structure_reference.md
Normal file
825
docs/project_structure_reference.md
Normal 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
151
tests/conftest.py
Normal 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
1
tests/external/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# Пакет для тестов внешних клиентов и вебхуков.
|
||||
85
tests/external/test_cryptobot_service.py
vendored
Normal file
85
tests/external/test_cryptobot_service.py
vendored
Normal 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
137
tests/external/test_webhook_server.py
vendored
Normal 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()
|
||||
1
tests/services/__init__.py
Normal file
1
tests/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Пакет для тестов сервисов бота.
|
||||
107
tests/services/test_mulenpay_service_adapter.py
Normal file
107
tests/services/test_mulenpay_service_adapter.py
Normal 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"}
|
||||
120
tests/services/test_pal24_service_adapter.py
Normal file
120
tests/services/test_pal24_service_adapter.py
Normal 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)
|
||||
97
tests/services/test_payment_common.py
Normal file
97
tests/services/test_payment_common.py
Normal 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)
|
||||
152
tests/services/test_payment_service_cryptobot.py
Normal file
152
tests/services/test_payment_service_cryptobot.py
Normal 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
|
||||
53
tests/services/test_payment_service_modularity.py
Normal file
53
tests/services/test_payment_service_modularity.py
Normal 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}"
|
||||
140
tests/services/test_payment_service_mulenpay.py
Normal file
140
tests/services/test_payment_service_mulenpay.py
Normal 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
|
||||
166
tests/services/test_payment_service_pal24.py
Normal file
166
tests/services/test_payment_service_pal24.py
Normal 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
|
||||
142
tests/services/test_payment_service_stars.py
Normal file
142
tests/services/test_payment_service_stars.py
Normal 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="Пополнение",
|
||||
)
|
||||
79
tests/services/test_payment_service_tribute.py
Normal file
79
tests/services/test_payment_service_tribute.py
Normal 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
|
||||
490
tests/services/test_payment_service_webhooks.py
Normal file
490
tests/services/test_payment_service_webhooks.py
Normal 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
|
||||
245
tests/services/test_payment_service_yookassa.py
Normal file
245
tests/services/test_payment_service_yookassa.py
Normal 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
|
||||
179
tests/services/test_yookassa_service_adapter.py
Normal file
179
tests/services/test_yookassa_service_adapter.py
Normal 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"
|
||||
@@ -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
1
tests/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# В этом пакете будут жить модульные тесты для вспомогательных утилит приложения.
|
||||
104
tests/utils/test_formatters_basic.py
Normal file
104
tests/utils/test_formatters_basic.py
Normal 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"
|
||||
54
tests/utils/test_security.py
Normal file
54
tests/utils/test_security.py
Normal 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
|
||||
141
tests/utils/test_validators_basic.py
Normal file
141
tests/utils/test_validators_basic.py
Normal 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
|
||||
Reference in New Issue
Block a user