Модульная структура платежки

This commit is contained in:
gy9vin
2025-10-12 16:14:00 +03:00
parent 30645d633e
commit dec3aefdec
15 changed files with 3135 additions and 1684 deletions

View File

@@ -1035,21 +1035,21 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
if settings.is_yookassa_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
callback_data=_build_callback("yookassa")
)
])
if settings.YOOKASSA_SBP_ENABLED:
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"),
callback_data=_build_callback("yookassa_sbp")
callback_data=_build_callback("yookassa_sbp"),
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
callback_data=_build_callback("yookassa"),
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(

View File

@@ -0,0 +1,23 @@
"""Пакет с mixin-классами, делающими платёжный сервис модульным.
Здесь собираем все вспомогательные части, чтобы основной `PaymentService`
оставался компактным и импортировал только нужные компоненты.
"""
from .common import PaymentCommonMixin
from .stars import TelegramStarsMixin
from .yookassa import YooKassaPaymentMixin
from .tribute import TributePaymentMixin
from .cryptobot import CryptoBotPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
__all__ = [
"PaymentCommonMixin",
"TelegramStarsMixin",
"YooKassaPaymentMixin",
"TributePaymentMixin",
"CryptoBotPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
]

View File

@@ -0,0 +1,147 @@
"""Общие инструменты платёжного сервиса.
В этом модуле собраны методы, которые нужны всем платёжным каналам:
построение клавиатур, базовые уведомления и стандартная обработка
успешных платежей.
"""
from __future__ import annotations
import logging
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import get_db
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
class PaymentCommonMixin:
"""Mixin с базовой логикой, которую используют остальные платёжные блоки."""
async def build_topup_success_keyboard(self, user: Any) -> InlineKeyboardMarkup:
"""Формирует клавиатуру по завершении платежа, подстраиваясь под пользователя."""
# Загружаем нужные тексты с учётом выбранного языка пользователя.
texts = get_texts(user.language if user else "ru")
# Определяем статус подписки, чтобы показать подходящую кнопку.
has_active_subscription = bool(
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
first_button = build_miniapp_or_callback_button(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
if user:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
# Стандартные кнопки быстрого доступа к балансу и главному меню.
keyboard_rows.append([
build_miniapp_or_callback_button(
text="💰 Мой баланс",
callback_data="menu_balance",
)
])
keyboard_rows.append([
InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
async def _send_payment_success_notification(
self,
telegram_id: int,
amount_kopeks: int,
user: Any | None = None,
) -> None:
"""Отправляет пользователю уведомление об успешном платеже."""
if not getattr(self, "bot", None):
# Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
return
if user is None:
try:
async for db in get_db():
user = await get_user_by_telegram_id(db, telegram_id)
break
except Exception as fetch_error:
logger.warning(
"Не удалось получить пользователя %s для уведомления: %s",
telegram_id,
fetch_error,
)
user = None
try:
keyboard = await self.build_topup_success_keyboard(user)
message = (
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"💳 Способ: Банковская карта (YooKassa)\n\n"
"Средства зачислены на ваш баланс!"
)
await self.bot.send_message(
chat_id=telegram_id,
text=message,
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю %s: %s",
telegram_id,
error,
)
async def process_successful_payment(
self,
payment_id: str,
amount_kopeks: int,
user_id: int,
payment_method: str,
) -> bool:
"""Общая точка учёта успешных платежей (используется провайдерами при необходимости)."""
try:
logger.info(
"Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s",
payment_id,
amount_kopeks / 100,
user_id,
payment_method,
)
return True
except Exception as error:
logger.error("Ошибка обработки платежа %s: %s", payment_id, error)
return False

View File

@@ -0,0 +1,299 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class CryptoBotPaymentMixin:
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
async def create_cryptobot_payment(
self,
db: AsyncSession,
user_id: int,
amount_usd: float,
asset: str = "USDT",
description: str = "Пополнение баланса",
payload: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт invoice в CryptoBot и сохраняет локальную запись."""
if not getattr(self, "cryptobot_service", None):
logger.error("CryptoBot сервис не инициализирован")
return None
try:
amount_str = f"{amount_usd:.2f}"
invoice_data = await self.cryptobot_service.create_invoice(
amount=amount_str,
asset=asset,
description=description,
payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
expires_in=settings.get_cryptobot_invoice_expires_seconds(),
)
if not invoice_data:
logger.error("Ошибка создания CryptoBot invoice")
return None
cryptobot_crud = import_module("app.database.crud.cryptobot")
local_payment = await cryptobot_crud.create_cryptobot_payment(
db=db,
user_id=user_id,
invoice_id=str(invoice_data["invoice_id"]),
amount=amount_str,
asset=asset,
status="active",
description=description,
payload=payload,
bot_invoice_url=invoice_data.get("bot_invoice_url"),
mini_app_invoice_url=invoice_data.get("mini_app_invoice_url"),
web_app_invoice_url=invoice_data.get("web_app_invoice_url"),
)
logger.info(
"Создан CryptoBot платеж %s на %s %s для пользователя %s",
invoice_data["invoice_id"],
amount_str,
asset,
user_id,
)
return {
"local_payment_id": local_payment.id,
"invoice_id": str(invoice_data["invoice_id"]),
"amount": amount_str,
"asset": asset,
"bot_invoice_url": invoice_data.get("bot_invoice_url"),
"mini_app_invoice_url": invoice_data.get("mini_app_invoice_url"),
"web_app_invoice_url": invoice_data.get("web_app_invoice_url"),
"status": "active",
"created_at": (
local_payment.created_at.isoformat()
if local_payment.created_at
else None
),
}
except Exception as error:
logger.error("Ошибка создания CryptoBot платежа: %s", error)
return None
async def process_cryptobot_webhook(
self,
db: AsyncSession,
webhook_data: Dict[str, Any],
) -> bool:
"""Обрабатывает webhook от CryptoBot и начисляет средства пользователю."""
try:
update_type = webhook_data.get("update_type")
if update_type != "invoice_paid":
logger.info("Пропуск CryptoBot webhook с типом: %s", update_type)
return True
payload = webhook_data.get("payload", {})
invoice_id = str(payload.get("invoice_id"))
status = "paid"
if not invoice_id:
logger.error("CryptoBot webhook без invoice_id")
return False
cryptobot_crud = import_module("app.database.crud.cryptobot")
payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(
db, invoice_id
)
if not payment:
logger.error("CryptoBot платеж не найден в БД: %s", invoice_id)
return False
if payment.status == "paid":
logger.info("CryptoBot платеж %s уже обработан", invoice_id)
return True
paid_at_str = payload.get("paid_at")
if paid_at_str:
try:
paid_at = datetime.fromisoformat(
paid_at_str.replace("Z", "+00:00")
).replace(tzinfo=None)
except Exception:
paid_at = datetime.utcnow()
else:
paid_at = datetime.utcnow()
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
db, invoice_id, status, paid_at
)
if not updated_payment.transaction_id:
amount_usd = updated_payment.amount_float
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
conversion_rate = (
amount_rubles / amount_usd if amount_usd > 0 else 0
)
logger.info(
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
amount_usd,
amount_rubles,
conversion_rate,
)
except Exception as error:
logger.warning(
"Ошибка конвертации валют для платежа %s, используем курс 1:1: %s",
invoice_id,
error,
)
amount_rubles = amount_usd
amount_kopeks = int(amount_usd * 100)
conversion_rate = 1.0
if amount_kopeks <= 0:
logger.error(
"Некорректная сумма после конвертации: %s копеек для платежа %s",
amount_kopeks,
invoice_id,
)
return False
payment_service_module = import_module("app.services.payment_service")
transaction = await payment_service_module.create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=(
"Пополнение через CryptoBot "
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)"
),
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
is_completed=True,
)
await cryptobot_crud.link_cryptobot_payment_to_transaction(
db, invoice_id, transaction.id
)
get_user_by_id = payment_service_module.get_user_by_id
user = await get_user_by_id(db, updated_payment.user_id)
if not user:
logger.error(
"Пользователь с ID %s не найден при пополнении баланса",
updated_payment.user_id,
)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения CryptoBot: %s",
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
user.telegram_id,
f"{amount_rubles:.2f}",
updated_payment.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
return True
except Exception as error:
logger.error(
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
)
return False

View File

@@ -0,0 +1,400 @@
"""Mixin, инкапсулирующий работу с MulenPay."""
from __future__ import annotations
import logging
import uuid
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class MulenPayPaymentMixin:
"""Mixin с созданием платежей, обработкой callback и проверкой статусов MulenPay."""
async def create_mulenpay_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
if not getattr(self, "mulenpay_service", None):
logger.error("MulenPay сервис не инициализирован")
return None
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма MulenPay меньше минимальной: %s < %s",
amount_kopeks,
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма MulenPay больше максимальной: %s > %s",
amount_kopeks,
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module("app.services.payment_service")
try:
payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}"
amount_rubles = amount_kopeks / 100
items = [
{
"description": description[:128],
"quantity": 1,
"price": round(amount_rubles, 2),
"vat_code": settings.MULENPAY_VAT_CODE,
"payment_subject": settings.MULENPAY_PAYMENT_SUBJECT,
"payment_mode": settings.MULENPAY_PAYMENT_MODE,
}
]
response = await self.mulenpay_service.create_payment(
amount_kopeks=amount_kopeks,
description=description,
uuid=payment_uuid,
items=items,
language=language or settings.MULENPAY_LANGUAGE,
website_url=settings.WEBHOOK_URL,
)
if not response:
logger.error("Ошибка создания MulenPay платежа")
return None
mulen_payment_id = response.get("id")
payment_url = response.get("paymentUrl")
metadata = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
}
local_payment = await payment_module.create_mulenpay_payment(
db=db,
user_id=user_id,
amount_kopeks=amount_kopeks,
uuid=payment_uuid,
description=description,
payment_url=payment_url,
mulen_payment_id=mulen_payment_id,
currency="RUB",
status="created",
metadata=metadata,
)
logger.info(
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
mulen_payment_id,
amount_rubles,
user_id,
)
return {
"local_payment_id": local_payment.id,
"mulen_payment_id": mulen_payment_id,
"payment_url": payment_url,
"amount_kopeks": amount_kopeks,
"uuid": payment_uuid,
"status": "created",
}
except Exception as error:
logger.error("Ошибка создания MulenPay платежа: %s", error)
return None
async def process_mulenpay_callback(
self,
db: AsyncSession,
callback_data: Dict[str, Any],
) -> bool:
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
try:
payment_module = import_module("app.services.payment_service")
uuid_value = callback_data.get("uuid")
payment_status = (callback_data.get("payment_status") or "").lower()
mulen_payment_id_raw = callback_data.get("id")
mulen_payment_id_int: Optional[int] = None
if mulen_payment_id_raw is not None:
try:
mulen_payment_id_int = int(mulen_payment_id_raw)
except (TypeError, ValueError):
mulen_payment_id_int = None
amount_value = callback_data.get("amount")
logger.debug(
"MulenPay callback: uuid=%s, status=%s, amount=%s",
uuid_value,
payment_status,
amount_value,
)
if not uuid_value and mulen_payment_id_raw is None:
logger.error("MulenPay callback без uuid и id")
return False
payment = None
if uuid_value:
payment = await payment_module.get_mulenpay_payment_by_uuid(db, uuid_value)
if not payment and mulen_payment_id_int is not None:
payment = await payment_module.get_mulenpay_payment_by_mulen_id(
db, mulen_payment_id_int
)
if not payment:
logger.error(
"MulenPay платеж не найден (uuid=%s, id=%s)",
uuid_value,
mulen_payment_id_raw,
)
return False
if payment.is_paid:
logger.info(
"MulenPay платеж %s уже обработан, игнорируем повторный callback",
payment.uuid,
)
return True
if payment_status == "success":
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status="success",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
if payment.transaction_id:
logger.info(
"Для MulenPay платежа %s уже создана транзакция",
payment.uuid,
)
return True
payment_description = getattr(
payment,
"description",
f"платеж {payment.uuid}",
)
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через MulenPay: {payment_description}",
payment_method=PaymentMethod.MULENPAY,
external_id=payment.uuid,
is_completed=True,
)
await payment_module.link_mulenpay_payment_to_transaction(
db=db,
payment=payment,
transaction_id=transaction.id,
)
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден при обработке MulenPay",
payment.user_id,
)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
await payment_module.add_user_balance(
db,
user,
payment.amount_kopeks,
f"Пополнение MulenPay: {payment.amount_kopeks // 100}",
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении MulenPay: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: Mulen Pay\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю MulenPay: %s",
error,
)
logger.info(
"✅ Обработан MulenPay платеж %s для пользователя %s",
payment.uuid,
payment.user_id,
)
return True
if payment_status == "cancel":
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status="canceled",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info("MulenPay платеж %s отменен", payment.uuid)
return True
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=payment_status or "unknown",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info(
"Получен MulenPay callback со статусом %s для платежа %s",
payment_status,
payment.uuid,
)
return True
except Exception as error:
logger.error("Ошибка обработки MulenPay callback: %s", error, exc_info=True)
return False
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
"""Приводит числовой статус MulenPay к строковому значению."""
mapping = {
0: "created",
1: "processing",
2: "canceled",
3: "success",
4: "error",
5: "hold",
6: "hold",
}
return mapping.get(status_code, "unknown")
async def get_mulenpay_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
try:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id)
if not payment:
return None
remote_status_code = None
remote_data = None
if getattr(self, "mulenpay_service", None) and payment.mulen_payment_id is not None:
response = await self.mulenpay_service.get_payment(
payment.mulen_payment_id
)
if response and response.get("success"):
remote_data = response.get("payment")
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status_code": remote_status_code,
"remote_data": remote_data,
}
except Exception as error:
logger.error(
"Ошибка получения статуса MulenPay: %s", error, exc_info=True
)
return None

View File

@@ -0,0 +1,431 @@
"""Mixin для интеграции с PayPalych (Pal24)."""
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
import uuid
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class Pal24PaymentMixin:
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
async def create_pal24_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
ttl_seconds: Optional[int] = None,
payer_email: Optional[str] = None,
payment_method: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт счёт в Pal24 и сохраняет локальную запись."""
service = getattr(self, "pal24_service", None)
if not service or not service.is_configured:
logger.error("Pal24 сервис не инициализирован")
return None
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Pal24 меньше минимальной: %s < %s",
amount_kopeks,
settings.PAL24_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Pal24 больше максимальной: %s > %s",
amount_kopeks,
settings.PAL24_MAX_AMOUNT_KOPEKS,
)
return None
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
custom_payload = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"language": language,
}
normalized_payment_method = (payment_method or "SBP").upper()
payment_module = import_module("app.services.payment_service")
try:
response = await service.create_bill(
amount_kopeks=amount_kopeks,
user_id=user_id,
order_id=order_id,
description=description,
ttl_seconds=ttl_seconds,
custom_payload=custom_payload,
payer_email=payer_email,
payment_method=normalized_payment_method,
)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при создании счета: %s", error)
return None
if not response.get("success", True):
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
return None
bill_id = response.get("bill_id")
if not bill_id:
logger.error("Pal24 не вернул bill_id: %s", response)
return None
def _pick_url(*keys: str) -> Optional[str]:
for key in keys:
value = response.get(key)
if value:
return str(value)
return None
transfer_url = _pick_url(
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_url = _pick_url(
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
link_page_url = _pick_url(
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
primary_link = transfer_url or link_page_url or card_url
secondary_link = link_page_url or card_url or transfer_url
metadata_links = {
key: value
for key, value in {
"sbp": transfer_url,
"card": card_url,
"page": link_page_url,
}.items()
if value
}
metadata_payload = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
"links": metadata_links,
"raw_response": response,
}
payment = await payment_module.create_pal24_payment(
db,
user_id=user_id,
bill_id=bill_id,
amount_kopeks=amount_kopeks,
description=description,
status=response.get("status", "NEW"),
type_=response.get("type", "normal"),
currency=response.get("currency", "RUB"),
link_url=transfer_url or card_url,
link_page_url=link_page_url or primary_link,
order_id=order_id,
ttl=ttl_seconds,
metadata=metadata_payload,
)
logger.info(
"Создан Pal24 счет %s для пользователя %s (%s₽)",
bill_id,
user_id,
amount_kopeks / 100,
)
payment_status = getattr(payment, "status", response.get("status", "NEW"))
return {
"local_payment_id": payment.id,
"bill_id": bill_id,
"order_id": order_id,
"amount_kopeks": amount_kopeks,
"primary_url": primary_link,
"secondary_url": secondary_link,
"link_url": transfer_url,
"card_url": card_url,
"payment_method": normalized_payment_method,
"metadata_links": metadata_links,
"status": payment_status,
}
async def process_pal24_postback(
self,
db: AsyncSession,
postback: Dict[str, Any],
) -> bool:
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
try:
payment_module = import_module("app.services.payment_service")
def _first_non_empty(*values: Optional[str]) -> Optional[str]:
for value in values:
if value:
return value
return None
payment_id = _first_non_empty(
postback.get("id"),
postback.get("TrsId"),
postback.get("TrsID"),
)
bill_id = _first_non_empty(
postback.get("bill_id"),
postback.get("billId"),
postback.get("BillId"),
postback.get("BillID"),
)
order_id = _first_non_empty(
postback.get("order_id"),
postback.get("orderId"),
postback.get("InvId"),
postback.get("InvID"),
)
status = (postback.get("status") or postback.get("Status") or "").upper()
if not bill_id and not order_id:
logger.error("Pal24 postback без идентификаторов: %s", postback)
return False
payment = None
if bill_id:
payment = await payment_module.get_pal24_payment_by_bill_id(db, bill_id)
if not payment and order_id:
payment = await payment_module.get_pal24_payment_by_order_id(db, order_id)
if not payment:
logger.error("Pal24 платеж не найден: %s / %s", bill_id, order_id)
return False
if payment.is_paid:
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
return True
if status in {"PAID", "SUCCESS"}:
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для Pal24 платежа",
payment.user_id,
)
return False
await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
postback_payload=postback,
payment_id=payment_id,
)
if payment.transaction_id:
logger.info(
"Для Pal24 платежа %s уже создана транзакция",
payment.bill_id,
)
return True
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через Pal24 ({payment_id})",
payment_method=PaymentMethod.PAL24,
external_id=str(payment_id) if payment_id else payment.bill_id,
is_completed=True,
)
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения Pal24: %s",
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления Pal24: %s", error
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: PayPalych\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю Pal24: %s",
error,
)
logger.info(
"✅ Обработан Pal24 платеж %s для пользователя %s",
payment.bill_id,
payment.user_id,
)
return True
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
postback_payload=postback,
payment_id=payment_id,
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
payment.bill_id,
status,
)
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
return False
async def get_pal24_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Запрашивает актуальный статус платежа у Pal24 и синхронизирует локальную запись."""
try:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_status = None
remote_data = None
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
try:
response = await service.get_bill_status(payment.bill_id)
remote_data = response
remote_status = response.get("status") or response.get(
"bill", {}
).get("status")
if remote_status and remote_status != payment.status:
await payment_module.update_pal24_payment_status(
db,
payment,
status=str(remote_status).upper(),
)
payment = await payment_module.get_pal24_payment_by_id(
db, local_payment_id
)
except Pal24APIError as error:
logger.error(
"Ошибка Pal24 API при получении статуса: %s", error
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status,
"remote_data": remote_data,
}
except Exception as error:
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
return None

View File

@@ -0,0 +1,249 @@
"""Логика Telegram Stars вынесена в отдельный mixin.
Методы здесь отвечают только за работу с звёздами, что позволяет держать
основной сервис компактным и облегчает тестирование конкретных сценариев.
"""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
from typing import Optional
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class TelegramStarsMixin:
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
async def create_stars_invoice(
self,
amount_kopeks: int,
description: str,
payload: Optional[str] = None,
*,
stars_amount: Optional[int] = None,
) -> str:
"""Создаёт invoice в Telegram Stars, автоматически рассчитывая количество звёзд."""
if not self.bot or not getattr(self, "stars_service", None):
raise ValueError("Bot instance required for Stars payments")
try:
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
# Если количество звёзд не задано, вычисляем его из курса.
if stars_amount is None:
rate = Decimal(str(settings.get_stars_rate()))
if rate <= 0:
raise ValueError("Stars rate must be positive")
normalized_stars = (amount_rubles / rate).to_integral_value(
rounding=ROUND_FLOOR
)
stars_amount = int(normalized_stars) or 1
if stars_amount <= 0:
raise ValueError("Stars amount must be positive")
invoice_link = await self.bot.create_invoice_link(
title="Пополнение баланса VPN",
description=f"{description} (≈{stars_amount} ⭐)",
payload=payload or f"balance_topup_{amount_kopeks}",
provider_token="",
currency="XTR",
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)],
)
logger.info(
"Создан Stars invoice на %s звезд (~%s)",
stars_amount,
settings.format_price(amount_kopeks),
)
return invoice_link
except Exception as error:
logger.error("Ошибка создания Stars invoice: %s", error)
raise
async def process_stars_payment(
self,
db: AsyncSession,
user_id: int,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str,
) -> bool:
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
del payload # payload пока не используется, но оставляем аргумент для совместимости.
try:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
stars_amount
)
amount_kopeks = int(
(rubles_amount * Decimal(100)).to_integral_value(
rounding=ROUND_HALF_UP
)
)
transaction = await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=telegram_payment_charge_id,
is_completed=True,
)
user = await get_user_by_id(db, user_id)
if not user:
logger.error(
"Пользователь с ID %s не найден при обработке Stars платежа",
user_id,
)
return False
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Обновляем баланс в БД.
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
description_for_referral = (
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
)
logger.info(
"🔍 Проверка реферальной логики для описания: '%s'",
description_for_referral,
)
lower_description = description_for_referral.lower()
contains_allowed_keywords = any(
word in lower_description
for word in ["пополнение", "stars", "yookassa", "topup"]
)
contains_forbidden_keywords = any(
word in lower_description for word in ["комиссия", "бонус"]
)
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
if allow_referral:
logger.info(
"🔞 Вызов process_referral_topup для пользователя %s",
user_id,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user_id, amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения: %s", error
)
else:
logger.info(
"❌ Описание '%s' не подходит для реферальной логики",
description_for_referral,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
logger.info(
"💰 Баланс пользователя %s изменен: %s%s (Δ +%s)",
user.telegram_id,
old_balance,
user.balance_kopeks,
amount_kopeks,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user_id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
except Exception as error:
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
return False

View File

@@ -0,0 +1,71 @@
"""Mixin для платежей Tribute — простая вспомогательная обвязка."""
from __future__ import annotations
import hashlib
import hmac
import logging
from typing import Dict
from app.config import settings
logger = logging.getLogger(__name__)
class TributePaymentMixin:
"""Содержит методы создания платежей и проверки webhook от Tribute."""
async def create_tribute_payment(
self,
amount_kopeks: int,
user_id: int,
description: str,
) -> str:
"""Формирует URL оплаты для Tribute и логирует результат."""
if not settings.TRIBUTE_ENABLED:
raise ValueError("Tribute payments are disabled")
try:
# Сохраняем полезную информацию для метрик и отладки.
payment_data = {
"amount": amount_kopeks,
"currency": "RUB",
"description": description,
"user_id": user_id,
"callback_url": f"{settings.WEBHOOK_URL}/tribute/callback",
}
del payment_data # данные пока не отправляются вовне, но оставляем структуру для будущего API.
payment_url = (
f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
)
logger.info(
"Создан Tribute платеж на %s₽ для пользователя %s",
amount_kopeks / 100,
user_id,
)
return payment_url
except Exception as error:
logger.error("Ошибка создания Tribute платежа: %s", error)
raise
def verify_tribute_webhook(self, data: Dict[str, object], signature: str) -> bool:
"""Проверяет подпись запроса, присланного Tribute."""
if not settings.TRIBUTE_API_KEY:
return False
try:
message = str(data).encode()
expected_signature = hmac.new(
settings.TRIBUTE_API_KEY.encode(),
message,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
except Exception as error:
logger.error("Ошибка проверки Tribute webhook: %s", error)
return False

View File

@@ -0,0 +1,362 @@
"""Функции работы с YooKassa вынесены в dedicated mixin.
Такое разделение облегчает поддержку и делает очевидным, какая часть
отвечает за конкретного провайдера.
"""
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional, TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.database.models import YooKassaPayment
class YooKassaPaymentMixin:
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
async def create_yookassa_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт обычный платёж в YooKassa и сохраняет локальную запись."""
if not getattr(self, "yookassa_service", None):
logger.error("YooKassa сервис не инициализирован")
return None
payment_module = import_module("app.services.payment_service")
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata.copy() if metadata else {}
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup",
}
)
yookassa_response = await self.yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone,
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(
"Ошибка создания платежа YooKassa: %s", yookassa_response
)
return None
yookassa_created_at: Optional[datetime] = None
if yookassa_response.get("created_at"):
try:
dt_with_tz = datetime.fromisoformat(
yookassa_response["created_at"].replace("Z", "+00:00")
)
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
except Exception as error:
logger.warning("Не удалось распарсить created_at: %s", error)
yookassa_created_at = None
local_payment = await payment_module.create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type=None,
yookassa_created_at=yookassa_created_at,
test_mode=yookassa_response.get("test_mode", False),
)
logger.info(
"Создан платеж YooKassa %s на %s₽ для пользователя %s",
yookassa_response["id"],
amount_rubles,
user_id,
)
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at,
}
except Exception as error:
logger.error("Ошибка создания платежа YooKassa: %s", error)
return None
async def create_yookassa_sbp_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт платёж по СБП через YooKassa."""
if not getattr(self, "yookassa_service", None):
logger.error("YooKassa сервис не инициализирован")
return None
payment_module = import_module("app.services.payment_service")
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata.copy() if metadata else {}
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup_sbp",
}
)
yookassa_response = (
await self.yookassa_service.create_sbp_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone,
)
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(
"Ошибка создания платежа YooKassa СБП: %s",
yookassa_response,
)
return None
local_payment = await payment_module.create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type="bank_card",
yookassa_created_at=None,
test_mode=yookassa_response.get("test_mode", False),
)
logger.info(
"Создан платеж YooKassa СБП %s на %s₽ для пользователя %s",
yookassa_response["id"],
amount_rubles,
user_id,
)
confirmation_token = (
yookassa_response.get("confirmation", {}) or {}
).get("confirmation_token")
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"confirmation_token": confirmation_token,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at,
}
except Exception as error:
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
return None
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
payment: "YooKassaPayment",
) -> bool:
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
try:
payment_module = import_module("app.services.payment_service")
payment_description = getattr(payment, "description", "YooKassa платеж")
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через YooKassa: {payment_description}",
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
old_balance = getattr(user, "balance_kopeks", 0)
was_first_topup = not getattr(user, "has_made_first_topup", False)
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения YooKassa: %s",
error,
)
if was_first_topup and not getattr(user, "has_made_first_topup", False):
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
)
if getattr(self, "bot", None):
try:
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=user,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s", error
)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
return True
except Exception as error:
logger.error(
"Ошибка обработки успешного платежа YooKassa %s: %s",
payment.yookassa_payment_id,
error,
)
return False
async def process_yookassa_webhook(
self,
db: AsyncSession,
event: Dict[str, Any],
) -> bool:
"""Обрабатывает входящий webhook YooKassa и синхронизирует состояние платежа."""
event_object = event.get("object", {})
yookassa_payment_id = event_object.get("id")
if not yookassa_payment_id:
logger.warning("Webhook без payment id: %s", event)
return False
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
if not payment:
logger.warning(
"Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
)
return False
payment.status = event_object.get("status", payment.status)
payment.confirmation_url = event_object.get("confirmation_url")
current_paid = getattr(payment, "paid", False)
payment.paid = event_object.get("paid", current_paid)
await db.commit()
await db.refresh(payment)
if payment.status == "succeeded" and payment.paid:
return await self._process_successful_yookassa_payment(db, payment)
logger.info(
"Webhook YooKassa обновил платеж %s до статуса %s",
yookassa_payment_id,
payment.status,
)
return True

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,9 @@ from app.config import settings
logger = logging.getLogger(__name__)
class YooKassaService:
def __init__(self,
shop_id: Optional[str] = None,
secret_key: Optional[str] = None,
@@ -23,23 +24,33 @@ class YooKassaService:
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
self.configured = False
if not shop_id or not secret_key:
logger.warning(
"YooKassa SHOP_ID или SECRET_KEY не настроены в settings. "
"Функционал платежей будет ОТКЛЮЧЕН.")
self.configured = False
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logger.info(
f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...")
except Exception as e:
logger.error(f"Ошибка конфигурации YooKassa SDK: {e}",
exc_info=True)
except Exception as error:
logger.error(
"Ошибка конфигурации YooKassa SDK: %s",
error,
exc_info=True,
)
self.configured = False
if configured_return_url:
if not self.configured:
self.return_url = "https://t.me/"
logger.warning(
"YooKassa не активна, используем заглушку return_url: %s",
self.return_url,
)
elif configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
@@ -50,7 +61,7 @@ class YooKassaService:
logger.warning(
f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. "
f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.")
logger.info(f"YooKassa Service return_url: {self.return_url}")
async def create_payment(
@@ -62,7 +73,7 @@ class YooKassaService:
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Создает платеж в YooKassa"""
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.")
return None
@@ -156,7 +167,7 @@ class YooKassaService:
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.")
return None
@@ -178,23 +189,23 @@ class YooKassaService:
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({
"type": "sbp"
})
@@ -219,7 +230,7 @@ class YooKassaService:
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
@@ -254,11 +265,11 @@ class YooKassaService:
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.")
return None
try:
logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}")

View File

@@ -19,12 +19,21 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
})
if settings.is_yookassa_enabled():
if getattr(settings, "YOOKASSA_SBP_ENABLED", False):
methods.append({
"id": "yookassa_sbp",
"name": "СБП (YooKassa)",
"icon": "🏦",
"description": "моментальная оплата по QR",
"callback": "topup_yookassa_sbp",
})
methods.append({
"id": "yookassa",
"id": "yookassa",
"name": "Банковская карта",
"icon": "💳",
"description": "через YooKassa",
"callback": "topup_yookassa"
"callback": "topup_yookassa",
})
if settings.TRIBUTE_ENABLED:
@@ -172,4 +181,4 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_cryptobot_enabled():
count += 1
return count
return count

View File

@@ -641,6 +641,18 @@ async def get_payment_methods(
)
if settings.is_yookassa_enabled():
if getattr(settings, "YOOKASSA_SBP_ENABLED", False):
methods.append(
MiniAppPaymentMethod(
id="yookassa_sbp",
icon="🏦",
requires_amount=True,
currency="RUB",
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
)
)
methods.append(
MiniAppPaymentMethod(
id="yookassa",
@@ -702,11 +714,12 @@ async def get_payment_methods(
order_map = {
"stars": 1,
"yookassa": 2,
"mulenpay": 3,
"pal24": 4,
"cryptobot": 5,
"tribute": 6,
"yookassa_sbp": 2,
"yookassa": 3,
"mulenpay": 4,
"pal24": 5,
"cryptobot": 6,
"tribute": 7,
}
methods.sort(key=lambda item: order_map.get(item.id, 99))
@@ -781,6 +794,44 @@ async def create_payment_link(
},
)
if method == "yookassa_sbp":
if not settings.is_yookassa_enabled() or not getattr(settings, "YOOKASSA_SBP_ENABLED", False):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
if amount_kopeks is None or amount_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
payment_service = PaymentService()
result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
)
confirmation_url = result.get("confirmation_url") if result else None
if not result or not confirmation_url:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
extra: dict[str, Any] = {
"local_payment_id": result.get("local_payment_id"),
"payment_id": result.get("yookassa_payment_id"),
"status": result.get("status"),
"requested_at": _current_request_timestamp(),
}
confirmation_token = result.get("confirmation_token")
if confirmation_token:
extra["confirmation_token"] = confirmation_token
return MiniAppPaymentCreateResponse(
method=method,
payment_url=confirmation_url,
amount_kopeks=amount_kopeks,
extra=extra,
)
if method == "yookassa":
if not settings.is_yookassa_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
@@ -4803,4 +4854,3 @@ async def update_subscription_devices_endpoint(
)
return MiniAppSubscriptionUpdateResponse(success=True)

View File

@@ -0,0 +1,825 @@
# База по структуре проекта
Этот документ сгенерирован для быстрой навигации по репозиторию. В нём перечислены основные директории, модули, классы и функции.
## Общая структура корня
- `.dockerignore` — файл
- `.env` — файл
- `.env.example` — файл
- `.gitignore` — файл
- `CONTRIBUTING.md` — файл
- `Dockerfile` — файл
- `LICENSE` — файл
- `README.md` — файл
- `SECURITY.md` — файл
- `__pycache__/` — директория
- `alembic.ini` — файл
- `app/` — директория
- `app-config.json` — файл
- `assets/` — директория
- `data/` — директория
- `docker-compose.local.yml` — файл
- `docker-compose.yml` — файл
- `docs/` — директория
- `install_bot.sh` — файл
- `locales/` — директория
- `logs/` — директория
- `main.py` — файл
- `migrations/` — директория
- `miniapp/` — директория
- `requirements.txt` — файл
- `tests/` — директория
- `venv/` — директория
- `vpn_logo.png` — файл
## app
- `app/bot.py` — Python-модуль
Классы: нет
Функции: нет
- `app/config.py` — Python-модуль
Классы: `Settings` (103 методов)
Функции: `refresh_period_prices` — Rebuild cached period price mapping using the latest settings., `get_traffic_prices`, `refresh_traffic_prices`
- `app/database/`
- `app/external/`
- `app/handlers/`
- `app/keyboards/`
- `app/localization/`
- `app/middlewares/`
- `app/services/`
- `app/states.py` — Python-модуль
Классы: `RegistrationStates`, `SubscriptionStates`, `BalanceStates`, `PromoCodeStates`, `AdminStates`, `SupportStates`, `TicketStates`, `AdminTicketStates`, `SupportSettingsStates`, `BotConfigStates`, `PricingStates`, `AutoPayStates`, `SquadCreateStates`, `SquadRenameStates`, `SquadMigrationStates`, `AdminSubmenuStates`
Функции: нет
- `app/utils/`
- `app/webapi/`
### app/database
- `app/database/crud/`
- `app/database/database.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/models.py` — Python-модуль
Классы: `UserStatus`, `SubscriptionStatus`, `TransactionType`, `PromoCodeType`, `PaymentMethod`, `MainMenuButtonActionType`, `MainMenuButtonVisibility`, `YooKassaPayment` (6 методов), `CryptoBotPayment` (5 методов), `MulenPayPayment` (2 методов), `Pal24Payment` (3 методов), `PromoGroup` (3 методов), `User` (5 методов), `Subscription` (11 методов), `Transaction` (1 методов), `SubscriptionConversion` (2 методов), `PromoCode` (2 методов), `PromoCodeUse`, `ReferralEarning` (1 методов), `Squad` (1 методов), `ServiceRule`, `PrivacyPolicy`, `PublicOffer`, `FaqSetting`, `FaqPage`, `SystemSetting`, `MonitoringLog`, `SentNotification`, `DiscountOffer`, `PromoOfferTemplate`, `SubscriptionTemporaryAccess`, `PromoOfferLog`, `BroadcastHistory`, `ServerSquad` (3 методов), `SubscriptionServer`, `SupportAuditLog`, `UserMessage` (1 методов), `WelcomeText`, `AdvertisingCampaign` (2 методов), `AdvertisingCampaignRegistration` (1 методов), `TicketStatus`, `Ticket` (8 методов), `TicketMessage` (3 методов), `WebApiToken` (1 методов), `MainMenuButton` (3 методов)
Функции: нет
- `app/database/universal_migration.py` — Python-модуль
Классы: нет
Функции: нет
#### app/database/crud
- `app/database/crud/campaign.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/cryptobot.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/discount_offer.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/faq.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/main_menu_button.py` — Python-модуль
Классы: нет
Функции: `_enum_value`
- `app/database/crud/mulenpay.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/notification.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/pal24.py` — CRUD helpers for PayPalych (Pal24) payments.
Классы: нет
Функции: нет
- `app/database/crud/privacy_policy.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/promo_group.py` — Python-модуль
Классы: нет
Функции: `_normalize_period_discounts`
- `app/database/crud/promo_offer_log.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/promo_offer_template.py` — Python-модуль
Классы: нет
Функции: `_format_template_fields`
- `app/database/crud/promocode.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/public_offer.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/referral.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/rules.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/server_squad.py` — Python-модуль
Классы: нет
Функции: `_generate_display_name`, `_extract_country_code`
- `app/database/crud/squad.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/subscription.py` — Python-модуль
Классы: нет
Функции: `_get_discount_percent`
- `app/database/crud/subscription_conversion.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/system_setting.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/ticket.py` — Python-модуль
Классы: `TicketCRUD` — CRUD операции для работы с тикетами, `TicketMessageCRUD` — CRUD операции для работы с сообщениями тикетов
Функции: нет
- `app/database/crud/transaction.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/user.py` — Python-модуль
Классы: нет
Функции: `generate_referral_code`
- `app/database/crud/user_message.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/web_api_token.py` — CRUD операции для токенов административного веб-API.
Классы: нет
Функции: нет
- `app/database/crud/welcome_text.py` — Python-модуль
Классы: нет
Функции: `replace_placeholders`, `get_available_placeholders`
- `app/database/crud/yookassa.py` — Python-модуль
Классы: нет
Функции: нет
### app/external
- `app/external/cryptobot.py` — Python-модуль
Классы: `CryptoBotService` (2 методов)
Функции: нет
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
Функции: нет
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych postbacks.
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 postbacks.
Функции: `_normalize_payload`, `create_pal24_flask_app`
- `app/external/remnawave_api.py` — Python-модуль
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
Функции: `format_bytes`, `parse_bytes`
- `app/external/telegram_stars.py` — Python-модуль
Классы: `TelegramStarsService` (3 методов)
Функции: нет
- `app/external/tribute.py` — Python-модуль
Классы: `TributeService` (2 методов)
Функции: нет
- `app/external/webhook_server.py` — Python-модуль
Классы: `WebhookServer` (3 методов)
Функции: нет
- `app/external/yookassa_webhook.py` — Python-модуль
Классы: `YooKassaWebhookHandler` (3 методов)
Функции: `create_yookassa_webhook_app`
### app/handlers
- `app/handlers/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `app/handlers/admin/`
- `app/handlers/balance.py` — Python-модуль
Классы: нет
Функции: `get_quick_amount_buttons`, `register_handlers`
- `app/handlers/common.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/menu.py` — Python-модуль
Классы: нет
Функции: `_format_rubles`, `_collect_period_discounts`, `_build_group_discount_lines`, `_get_subscription_status`, `_insert_random_message`, `register_handlers`
- `app/handlers/promocode.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/referral.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/server_status.py` — Python-модуль
Классы: нет
Функции: `_build_status_message`, `_split_into_pages`, `_format_server_lines`, `register_handlers`
- `app/handlers/stars_payments.py` — Python-модуль
Классы: нет
Функции: `register_stars_handlers`
- `app/handlers/start.py` — Python-модуль
Классы: нет
Функции: `_get_language_prompt_text`, `_get_subscription_status`, `_get_subscription_status_simple`, `_insert_random_message`, `get_referral_code_keyboard`, `register_handlers`
- `app/handlers/subscription.py` — Python-модуль
Классы: `_SafeFormatDict` (1 методов)
Функции: `_format_text_with_placeholders`, `_get_addon_discount_percent_for_user`, `_apply_addon_discount`, `_get_promo_offer_discount_percent`, `_apply_promo_offer_discount`, `_get_period_hint_from_subscription`, `_apply_discount_to_monthly_component`, `_build_promo_group_discount_text`, `update_traffic_prices`, `format_traffic_display`, `validate_traffic_price`, `load_app_config`, `get_localized_value`, `get_step_description`, `format_additional_section`, `build_redirect_link`, `get_apps_for_device`, `get_device_name`, `create_deep_link`, `get_reset_devices_confirm_keyboard`, `get_traffic_switch_keyboard`, `get_confirm_switch_traffic_keyboard`, `register_handlers`
- `app/handlers/support.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/tickets.py` — Python-модуль
Классы: `TicketStates`
Функции: `_split_text_into_pages`, `register_handlers` — Регистрация обработчиков тикетов
- `app/handlers/webhooks.py` — Python-модуль
Классы: нет
Функции: нет
#### app/handlers/admin
- `app/handlers/admin/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `app/handlers/admin/backup.py` — Python-модуль
Классы: `BackupStates`
Функции: `get_backup_main_keyboard`, `get_backup_list_keyboard`, `get_backup_manage_keyboard`, `get_backup_settings_keyboard`, `register_handlers`
- `app/handlers/admin/bot_configuration.py` — Python-модуль
Классы: `BotConfigInputFilter` (1 методов)
Функции: `_get_group_meta`, `_get_group_description`, `_get_group_icon`, `_get_group_status`, `_get_setting_icon`, `_render_dashboard_overview`, `_build_group_category_index`, `_perform_settings_search`, `_build_search_results_keyboard`, `_parse_env_content`, `_format_preset_preview`, `_chunk`, `_parse_category_payload`, `_parse_group_payload`, `_get_grouped_categories`, `_build_groups_keyboard`, `_build_categories_keyboard`, `_build_settings_keyboard`, `_build_setting_keyboard`, `_render_setting_text`, `register_handlers`
- `app/handlers/admin/campaigns.py` — Python-модуль
Классы: нет
Функции: `_format_campaign_summary`, `_build_campaign_servers_keyboard`, `register_handlers`
- `app/handlers/admin/faq.py` — Python-модуль
Классы: нет
Функции: `_format_timestamp`, `register_handlers`
- `app/handlers/admin/main.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/maintenance.py` — Python-модуль
Классы: `MaintenanceStates`
Функции: `register_handlers`
- `app/handlers/admin/messages.py` — Python-модуль
Классы: нет
Функции: `get_message_buttons_selector_keyboard`, `get_updated_message_buttons_selector_keyboard`, `create_broadcast_keyboard`, `get_target_name`, `get_target_display_name`, `register_handlers`
- `app/handlers/admin/monitoring.py` — Python-модуль
Классы: нет
Функции: `_format_toggle`, `_build_notification_settings_view`, `_build_notification_preview_message`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_back_keyboard`, `register_handlers`
- `app/handlers/admin/pricing.py` — Python-модуль
Классы: `ChoiceOption` (1 методов), `SettingEntry` (2 методов)
Функции: `_traffic_package_sort_key`, `_collect_traffic_packages`, `_serialize_traffic_packages`, `_language_code`, `_format_period_label`, `_format_traffic_label`, `_format_trial_summary`, `_format_core_summary`, `_get_period_items`, `_get_traffic_items`, `_get_extra_items`, `_build_period_summary`, `_build_traffic_summary`, `_build_period_options_summary`, `_build_extra_summary`, `_build_settings_section`, `_build_traffic_options_section`, `_build_period_options_section`, `_build_overview`, `_build_section`, `_build_price_prompt`, `_parse_price_input`, `_resolve_label`, `register_handlers`
- `app/handlers/admin/privacy_policy.py` — Python-модуль
Классы: нет
Функции: `_format_timestamp`, `register_handlers`
- `app/handlers/admin/promo_groups.py` — Python-модуль
Классы: нет
Функции: `_format_discount_lines`, `_format_addon_discounts_line`, `_get_addon_discounts_button_text`, `_normalize_periods_dict`, `_collect_period_discounts`, `_format_period_discounts_lines`, `_format_period_discounts_value`, `_parse_period_discounts_input`, `_format_rubles`, `_format_auto_assign_line`, `_format_auto_assign_value`, `_parse_auto_assign_threshold_input`, `_build_edit_menu_content`, `_get_edit_prompt_keyboard`, `_validate_percent`, `register_handlers`
- `app/handlers/admin/promo_offers.py` — Python-модуль
Классы: нет
Функции: `_render_template_text`, `_build_templates_keyboard`, `_build_offer_detail_keyboard`, `_format_offer_remaining`, `_extract_offer_active_hours`, `_extract_template_id_from_notification`, `_format_promo_offer_log_entry`, `_build_logs_keyboard`, `_build_send_keyboard`, `_build_user_button_label`, `_describe_offer`, `_build_connect_button_rows`, `register_handlers`
- `app/handlers/admin/promocodes.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/public_offer.py` — Python-модуль
Классы: нет
Функции: `_format_timestamp`, `register_handlers`
- `app/handlers/admin/referrals.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/remnawave.py` — Python-модуль
Классы: нет
Функции: `_format_migration_server_label`, `_build_migration_keyboard`, `register_handlers`
- `app/handlers/admin/reports.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/rules.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/servers.py` — Python-модуль
Классы: нет
Функции: `_build_server_edit_view`, `_build_server_promo_groups_keyboard`, `register_handlers`
- `app/handlers/admin/statistics.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/subscriptions.py` — Python-модуль
Классы: нет
Функции: `get_country_flag`, `register_handlers`
- `app/handlers/admin/support_settings.py` — Python-модуль
Классы: `SupportAdvancedStates`
Функции: `_get_support_settings_keyboard`, `register_handlers`
- `app/handlers/admin/system_logs.py` — Python-модуль
Классы: нет
Функции: `_resolve_log_path`, `_format_preview_block`, `_build_logs_message`, `_get_logs_keyboard`, `register_handlers`
- `app/handlers/admin/tickets.py` — Python-модуль
Классы: нет
Функции: `register_handlers` — Регистрация админских обработчиков тикетов
- `app/handlers/admin/updates.py` — Python-модуль
Классы: нет
Функции: `get_updates_keyboard`, `get_version_info_keyboard`, `register_handlers`
- `app/handlers/admin/user_messages.py` — Python-модуль
Классы: `UserMessageStates`
Функции: `get_user_messages_keyboard`, `get_message_actions_keyboard`, `register_handlers`
- `app/handlers/admin/users.py` — Python-модуль
Классы: нет
Функции: `register_handlers`
- `app/handlers/admin/welcome_text.py` — Python-модуль
Классы: нет
Функции: `get_telegram_formatting_info`, `register_welcome_text_handlers`
### app/keyboards
- `app/keyboards/admin.py` — Python-модуль
Классы: нет
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_suffix`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
### app/localization
- `app/localization/default_locales/`
- `app/localization/loader.py` — Python-модуль
Классы: нет
Функции: `_normalize_language_code`, `_resolve_user_locales_dir`, `_locale_file_exists`, `_select_fallback_language`, `_determine_default_language`, `_normalize_key`, `_flatten_locale_dict`, `_normalize_locale_dict`, `ensure_locale_templates`, `_load_default_locale`, `_load_user_locale`, `_load_locale_file`, `_merge_dicts`, `load_locale`, `clear_locale_cache`
- `app/localization/locales/`
- `app/localization/texts.py` — Python-модуль
Классы: `Texts` (8 методов)
Функции: `_get_cached_rules_value`, `_build_dynamic_values`, `get_texts`, `_get_default_rules`, `get_rules_sync`, `clear_rules_cache`, `reload_locales`
#### app/localization/default_locales
- `app/localization/default_locales/en.yml` — файл (.yml)
- `app/localization/default_locales/ru.yml` — файл (.yml)
#### app/localization/locales
- `app/localization/locales/en.json` — файл (.json)
- `app/localization/locales/ru.json` — файл (.json)
### app/middlewares
- `app/middlewares/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `app/middlewares/auth.py` — Python-модуль
Классы: `AuthMiddleware`
Функции: нет
- `app/middlewares/channel_checker.py` — Python-модуль
Классы: `ChannelCheckerMiddleware` (1 методов)
Функции: нет
- `app/middlewares/display_name_restriction.py` — Python-модуль
Классы: `DisplayNameRestrictionMiddleware` (4 методов) — Blocks users whose display name imitates links or official accounts.
Функции: нет
- `app/middlewares/global_error.py` — Python-модуль
Классы: `GlobalErrorMiddleware` (4 методов), `ErrorStatisticsMiddleware` (4 методов)
Функции: нет
- `app/middlewares/logging.py` — Python-модуль
Классы: `LoggingMiddleware`
Функции: нет
- `app/middlewares/maintenance.py` — Python-модуль
Классы: `MaintenanceMiddleware`
Функции: нет
- `app/middlewares/subscription_checker.py` — Python-модуль
Классы: `SubscriptionStatusMiddleware`
Функции: нет
- `app/middlewares/throttling.py` — Python-модуль
Классы: `ThrottlingMiddleware` (1 методов)
Функции: нет
### app/services
- `app/services/__init__.py` — Сервисы бизнес-логики
Классы: нет
Функции: нет
- `app/services/admin_notification_service.py` — Python-модуль
Классы: `AdminNotificationService` (11 методов)
Функции: нет
- `app/services/backup_service.py` — Python-модуль
Классы: `BackupMetadata`, `BackupSettings`, `BackupService` (7 методов)
Функции: нет
- `app/services/broadcast_service.py` — Python-модуль
Классы: `BroadcastMediaConfig`, `BroadcastConfig`, `_BroadcastTask`, `BroadcastService` (4 методов) — Handles broadcast execution triggered from the admin web API.
Функции: нет
- `app/services/campaign_service.py` — Python-модуль
Классы: `CampaignBonusResult`, `AdvertisingCampaignService` (1 методов)
Функции: нет
- `app/services/external_admin_service.py` — Утилиты для синхронизации токена внешней админки.
Классы: нет
Функции: нет
- `app/services/faq_service.py` — Python-модуль
Классы: `FaqService` (3 методов)
Функции: нет
- `app/services/main_menu_button_service.py` — Python-модуль
Классы: `_MainMenuButtonData`, `MainMenuButtonService` (2 методов)
Функции: нет
- `app/services/maintenance_service.py` — Python-модуль
Классы: `MaintenanceStatus`, `MaintenanceService` (6 методов)
Функции: нет
- `app/services/monitoring_service.py` — Python-модуль
Классы: `MonitoringService` (5 методов)
Функции: нет
- `app/services/mulenpay_service.py` — Python-модуль
Классы: `MulenPayService` (4 методов) — Интеграция с Mulen Pay API.
Функции: нет
- `app/services/notification_settings_service.py` — Python-модуль
Классы: `NotificationSettingsService` (32 методов) — Runtime-editable notification settings stored on disk.
Функции: нет
- `app/services/pal24_service.py` — High level integration with PayPalych API.
Классы: `Pal24Service` (5 методов) — Wrapper around :class:`Pal24Client` providing domain helpers.
Функции: нет
- `app/services/payment_service.py` — Python-модуль
Классы: `PaymentService` (3 методов)
Функции: нет
- `app/services/privacy_policy_service.py` — Python-модуль
Классы: `PrivacyPolicyService` (3 методов) — Utility helpers around privacy policy storage and presentation.
Функции: нет
- `app/services/promo_group_assignment.py` — Python-модуль
Классы: нет
Функции: нет
- `app/services/promo_offer_service.py` — Python-модуль
Классы: `PromoOfferService` (1 методов)
Функции: нет
- `app/services/promocode_service.py` — Python-модуль
Классы: `PromoCodeService` (1 методов)
Функции: нет
- `app/services/public_offer_service.py` — Python-модуль
Классы: `PublicOfferService` (4 методов) — Helpers for managing the public offer text and visibility.
Функции: нет
- `app/services/referral_service.py` — Python-модуль
Классы: нет
Функции: нет
- `app/services/remnawave_service.py` — Python-модуль
Классы: `RemnaWaveConfigurationError` — Raised when RemnaWave API configuration is missing., `RemnaWaveService` (7 методов)
Функции: нет
- `app/services/reporting_service.py` — Python-модуль
Классы: `ReportingServiceError` — Base error for the reporting service., `ReportPeriod`, `ReportPeriodRange`, `ReportingService` (7 методов) — Generates admin summary reports and can schedule daily delivery.
Функции: нет
- `app/services/server_status_service.py` — Python-модуль
Классы: `ServerStatusEntry`, `ServerStatusError` — Raised when server status information cannot be fetched or parsed., `ServerStatusService` (6 методов)
Функции: нет
- `app/services/subscription_checkout_service.py` — Python-модуль
Классы: нет
Функции: `should_offer_checkout_resume` — Determine whether checkout resume button should be available for the user.
- `app/services/subscription_purchase_service.py` — Python-модуль
Классы: `PurchaseTrafficOption` (1 методов), `PurchaseTrafficConfig` (1 методов), `PurchaseServerOption` (1 методов), `PurchaseServersConfig` (1 методов), `PurchaseDevicesConfig` (1 методов), `PurchasePeriodConfig` (1 методов), `PurchaseSelection`, `PurchasePricingResult`, `PurchaseOptionsContext`, `PurchaseValidationError` (1 методов), `PurchaseBalanceError` (1 методов), `MiniAppSubscriptionPurchaseService` (5 методов) — Builds configuration and pricing for subscription purchases in the mini app.
Функции: `_apply_percentage_discount`, `_apply_discount_to_monthly_component`, `_get_promo_offer_discount_percent`, `_apply_promo_offer_discount`, `_build_server_option`
- `app/services/subscription_service.py` — Python-модуль
Классы: `SubscriptionService` (7 методов)
Функции: `_resolve_discount_percent`, `_resolve_addon_discount_percent`, `get_traffic_reset_strategy`
- `app/services/support_settings_service.py` — Python-модуль
Классы: `SupportSettingsService` (23 методов) — Runtime editable support settings with JSON persistence.
Функции: нет
- `app/services/system_settings_service.py` — Python-модуль
Классы: `SettingDefinition` (1 методов), `ChoiceOption`, `ReadOnlySettingError` — Исключение, выбрасываемое при попытке изменить настройку только для чтения., `BotConfigurationService` (33 методов)
Функции: `_title_from_key`, `_truncate`
- `app/services/tribute_service.py` — Python-модуль
Классы: `TributeService` (1 методов)
Функции: нет
- `app/services/user_service.py` — Python-модуль
Классы: `UserService`
Функции: нет
- `app/services/version_service.py` — Python-модуль
Классы: `VersionInfo` (5 методов), `VersionService` (5 методов)
Функции: нет
- `app/services/web_api_token_service.py` — Python-модуль
Классы: `WebApiTokenService` (2 методов) — Сервис для управления токенами административного веб-API.
Функции: нет
- `app/services/yookassa_service.py` — Python-модуль
Классы: `YooKassaService` (1 методов)
Функции: нет
### app/utils
- `app/utils/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `app/utils/cache.py` — Python-модуль
Классы: `CacheService` (1 методов), `UserCache`, `SystemCache`, `RateLimitCache`
Функции: `cache_key`
- `app/utils/check_reg_process.py` — Python-модуль
Классы: нет
Функции: `is_registration_process`
- `app/utils/currency_converter.py` — Python-модуль
Классы: `CurrencyConverter` (1 методов)
Функции: нет
- `app/utils/decorators.py` — Python-модуль
Классы: нет
Функции: `admin_required`, `error_handler`, `_extract_event`, `state_cleanup`, `typing_action`, `rate_limit`
- `app/utils/formatters.py` — Python-модуль
Классы: нет
Функции: `format_datetime`, `format_date`, `format_time_ago`, `format_days_declension`, `format_duration`, `format_bytes`, `format_percentage`, `format_number`, `format_price_range`, `truncate_text`, `format_username`, `format_subscription_status`, `format_traffic_usage`, `format_boolean`
- `app/utils/message_patch.py` — Python-модуль
Классы: нет
Функции: `is_qr_message`, `_get_language`, `_default_privacy_hint`, `append_privacy_hint`, `prepare_privacy_safe_kwargs`, `is_privacy_restricted_error`, `patch_message_methods`
- `app/utils/miniapp_buttons.py` — Python-модуль
Классы: нет
Функции: `build_miniapp_or_callback_button` — Create a button that opens the miniapp in text menu mode.
- `app/utils/pagination.py` — Python-модуль
Классы: `PaginationResult` (1 методов)
Функции: `paginate_list`, `get_pagination_info`, `get_page_numbers`
- `app/utils/payment_utils.py` — Python-модуль
Классы: нет
Функции: `get_available_payment_methods` — Возвращает список доступных способов оплаты с их настройками, `get_payment_methods_text` — Генерирует текст с описанием доступных способов оплаты, `is_payment_method_available` — Проверяет, доступен ли конкретный способ оплаты, `get_payment_method_status` — Возвращает статус всех способов оплаты, `get_enabled_payment_methods_count` — Возвращает количество включенных способов оплаты (не считая поддержку)
- `app/utils/photo_message.py` — Python-модуль
Классы: нет
Функции: `_resolve_media`, `_get_language`, `_build_base_kwargs`
- `app/utils/pricing_utils.py` — Python-модуль
Классы: нет
Функции: `calculate_months_from_days`, `get_remaining_months`, `calculate_period_multiplier`, `calculate_prorated_price`, `apply_percentage_discount`, `format_period_description`, `validate_pricing_calculation`, `get_period_info`
- `app/utils/promo_offer.py` — Python-модуль
Классы: нет
Функции: `_escape_format_braces` — Escape braces so str.format treats them as literals., `get_user_active_promo_discount_percent`, `_format_time_left`, `_build_progress_bar`
- `app/utils/security.py` — Утилиты безопасности и генерации ключей.
Классы: нет
Функции: `hash_api_token` — Возвращает хеш токена в формате hex., `generate_api_token` — Генерирует криптографически стойкий токен.
- `app/utils/startup_timeline.py` — Python-модуль
Классы: `StepRecord`, `StageHandle` (6 методов), `StartupTimeline` (6 методов)
Функции: нет
- `app/utils/subscription_utils.py` — Python-модуль
Классы: нет
Функции: `get_display_subscription_link`, `get_happ_cryptolink_redirect_link`, `convert_subscription_link_to_happ_scheme`
- `app/utils/telegram_webapp.py` — Utilities for validating Telegram WebApp initialization data.
Классы: `TelegramWebAppAuthError` — Raised when Telegram WebApp init data fails validation.
Функции: `parse_webapp_init_data` — Validate and parse Telegram WebApp init data.
- `app/utils/user_utils.py` — Python-модуль
Классы: нет
Функции: `format_referrer_info` — Return formatted referrer info for admin notifications.
- `app/utils/validators.py` — Python-модуль
Классы: нет
Функции: `validate_email`, `validate_phone`, `validate_telegram_username`, `validate_promocode`, `validate_amount`, `validate_positive_integer`, `validate_date_string`, `validate_url`, `validate_uuid`, `validate_traffic_amount`, `validate_subscription_period`, `sanitize_html`, `sanitize_telegram_name` — Санитизация Telegram-имени для безопасной вставки в HTML и хранения., `validate_device_count`, `validate_referral_code`, `validate_html_tags`, `validate_html_structure`, `fix_html_tags`, `get_html_help_text`, `validate_rules_content`
### app/webapi
- `app/webapi/__init__.py` — Пакет административного веб-API.
Классы: нет
Функции: нет
- `app/webapi/app.py` — Python-модуль
Классы: нет
Функции: `create_web_api_app`
- `app/webapi/background/`
- `app/webapi/dependencies.py` — Python-модуль
Классы: нет
Функции: нет
- `app/webapi/middleware.py` — Python-модуль
Классы: `RequestLoggingMiddleware` — Логирование входящих запросов в административный API.
Функции: нет
- `app/webapi/routes/`
- `app/webapi/schemas/`
- `app/webapi/server.py` — Python-модуль
Классы: `WebAPIServer` (1 методов) — Асинхронный uvicorn-сервер для административного API.
Функции: нет
#### app/webapi/background
- `app/webapi/background/__init__.py` — Background utilities for Web API.
Классы: нет
Функции: нет
- `app/webapi/background/backup_tasks.py` — Python-модуль
Классы: `BackupTaskState`, `BackupTaskManager` (1 методов)
Функции: нет
#### app/webapi/routes
- `app/webapi/routes/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `app/webapi/routes/backups.py` — Python-модуль
Классы: нет
Функции: `_parse_datetime`, `_to_int`, `_serialize_backup`
- `app/webapi/routes/broadcasts.py` — Python-модуль
Классы: нет
Функции: `_serialize_broadcast`
- `app/webapi/routes/campaigns.py` — Python-модуль
Классы: нет
Функции: `_serialize_campaign`
- `app/webapi/routes/config.py` — Python-модуль
Классы: нет
Функции: `_coerce_value`, `_serialize_definition`
- `app/webapi/routes/health.py` — Python-модуль
Классы: нет
Функции: нет
- `app/webapi/routes/main_menu_buttons.py` — Python-модуль
Классы: нет
Функции: `_serialize`
- `app/webapi/routes/miniapp.py` — Python-модуль
Классы: нет
Функции: `_normalize_autopay_days`, `_get_autopay_day_options`, `_build_autopay_payload`, `_autopay_response_extras`, `_compute_cryptobot_limits`, `_current_request_timestamp`, `_compute_stars_min_amount`, `_normalize_stars_amount`, `_build_balance_invoice_payload`, `_merge_purchase_selection_from_request`, `_parse_client_timestamp`, `_classify_status`, `_format_gb`, `_format_gb_label`, `_format_limit_label`, `_normalize_amount_kopeks`, `_extract_template_id`, `_extract_offer_extra`, `_extract_offer_type`, `_normalize_effect_type`, `_determine_offer_icon`, `_extract_offer_test_squad_uuids`, `_format_offer_message`, `_extract_offer_duration_hours`, `_format_bonus_label`, `_bytes_to_gb`, `_status_label`, `_parse_datetime_string`, `_resolve_display_name`, `_is_remnawave_configured`, `_serialize_transaction`, `_is_trial_available_for_user`, `_safe_int`, `_normalize_period_discounts`, `_extract_promo_discounts`, `_normalize_language_code`, `_build_renewal_status_message`, `_build_promo_offer_payload`, `_build_renewal_period_id`, `_parse_period_identifier`, `_get_addon_discount_percent_for_user`, `_get_period_hint_from_subscription`, `_validate_subscription_id`, `_ensure_paid_subscription`
- `app/webapi/routes/pages.py` — Python-модуль
Классы: нет
Функции: `_serialize_rich_page`, `_serialize_faq_page`, `_serialize_rules`
- `app/webapi/routes/promo_groups.py` — Python-модуль
Классы: нет
Функции: `_normalize_period_discounts`, `_serialize`
- `app/webapi/routes/promo_offers.py` — Python-модуль
Классы: нет
Функции: `_serialize_user`, `_serialize_subscription`, `_serialize_offer`, `_serialize_template`, `_build_log_response`
- `app/webapi/routes/promocodes.py` — Python-модуль
Классы: нет
Функции: `_normalize_datetime`, `_serialize_promocode`, `_serialize_recent_use`, `_validate_create_payload`, `_validate_update_payload`
- `app/webapi/routes/remnawave.py` — Python-модуль
Классы: нет
Функции: `_get_service`, `_ensure_service_configured`, `_serialize_node`, `_parse_last_updated`
- `app/webapi/routes/stats.py` — Python-модуль
Классы: нет
Функции: нет
- `app/webapi/routes/subscriptions.py` — Python-модуль
Классы: нет
Функции: `_serialize_subscription`
- `app/webapi/routes/tickets.py` — Python-модуль
Классы: нет
Функции: `_serialize_message`, `_serialize_ticket`
- `app/webapi/routes/tokens.py` — Python-модуль
Классы: нет
Функции: `_serialize`
- `app/webapi/routes/transactions.py` — Python-модуль
Классы: нет
Функции: `_serialize`
- `app/webapi/routes/users.py` — Python-модуль
Классы: нет
Функции: `_serialize_promo_group`, `_serialize_subscription`, `_serialize_user`, `_apply_search_filter`
#### app/webapi/schemas
- `app/webapi/schemas/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `app/webapi/schemas/backups.py` — Python-модуль
Классы: `BackupCreateResponse`, `BackupInfo`, `BackupListResponse`, `BackupStatusResponse`, `BackupTaskInfo`, `BackupTaskListResponse`
Функции: нет
- `app/webapi/schemas/broadcasts.py` — Python-модуль
Классы: `BroadcastMedia`, `BroadcastCreateRequest` (2 методов), `BroadcastResponse`, `BroadcastListResponse`
Функции: нет
- `app/webapi/schemas/campaigns.py` — Python-модуль
Классы: `CampaignBase` (1 методов), `CampaignCreateRequest` (2 методов), `CampaignResponse`, `CampaignListResponse`, `CampaignUpdateRequest` (3 методов)
Функции: нет
- `app/webapi/schemas/config.py` — Python-модуль
Классы: `SettingCategorySummary` — Краткое описание категории настройки., `SettingCategoryRef` — Ссылка на категорию, к которой относится настройка., `SettingChoice` — Вариант значения для настройки с выбором., `SettingDefinition` — Полное описание настройки и её текущего состояния., `SettingUpdateRequest` — Запрос на обновление значения настройки.
Функции: нет
- `app/webapi/schemas/health.py` — Python-модуль
Классы: `HealthFeatureFlags` — Флаги доступности функций административного API., `HealthCheckResponse` — Ответ на health-check административного API.
Функции: нет
- `app/webapi/schemas/main_menu_buttons.py` — Python-модуль
Классы: `MainMenuButtonResponse`, `MainMenuButtonCreateRequest`, `MainMenuButtonUpdateRequest` (2 методов), `MainMenuButtonListResponse`
Функции: `_clean_text`, `_validate_action_value`
- `app/webapi/schemas/miniapp.py` — Python-модуль
Классы: `MiniAppBranding`, `MiniAppSubscriptionRequest`, `MiniAppSubscriptionUser`, `MiniAppPromoGroup`, `MiniAppAutoPromoGroupLevel`, `MiniAppConnectedServer`, `MiniAppDevice`, `MiniAppDeviceRemovalRequest`, `MiniAppDeviceRemovalResponse`, `MiniAppTransaction`, `MiniAppPromoOffer`, `MiniAppPromoOfferClaimRequest`, `MiniAppPromoOfferClaimResponse`, `MiniAppSubscriptionAutopay`, `MiniAppSubscriptionRenewalPeriod`, `MiniAppSubscriptionRenewalOptionsRequest`, `MiniAppSubscriptionRenewalOptionsResponse`, `MiniAppSubscriptionRenewalRequest`, `MiniAppSubscriptionRenewalResponse`, `MiniAppSubscriptionAutopayRequest`, `MiniAppSubscriptionAutopayResponse`, `MiniAppPromoCode`, `MiniAppPromoCodeActivationRequest`, `MiniAppPromoCodeActivationResponse`, `MiniAppFaqItem`, `MiniAppFaq`, `MiniAppRichTextDocument`, `MiniAppLegalDocuments`, `MiniAppReferralTerms`, `MiniAppReferralStats`, `MiniAppReferralRecentEarning`, `MiniAppReferralItem`, `MiniAppReferralList`, `MiniAppReferralInfo`, `MiniAppPaymentMethodsRequest`, `MiniAppPaymentMethod`, `MiniAppPaymentMethodsResponse`, `MiniAppPaymentCreateRequest`, `MiniAppPaymentCreateResponse`, `MiniAppPaymentStatusQuery`, `MiniAppPaymentStatusRequest`, `MiniAppPaymentStatusResult`, `MiniAppPaymentStatusResponse`, `MiniAppSubscriptionResponse`, `MiniAppSubscriptionServerOption`, `MiniAppSubscriptionTrafficOption`, `MiniAppSubscriptionDeviceOption`, `MiniAppSubscriptionCurrentSettings`, `MiniAppSubscriptionServersSettings`, `MiniAppSubscriptionTrafficSettings`, `MiniAppSubscriptionDevicesSettings`, `MiniAppSubscriptionBillingContext`, `MiniAppSubscriptionSettings`, `MiniAppSubscriptionSettingsResponse`, `MiniAppSubscriptionSettingsRequest` (1 методов), `MiniAppSubscriptionServersUpdateRequest` (1 методов), `MiniAppSubscriptionTrafficUpdateRequest` (1 методов), `MiniAppSubscriptionDevicesUpdateRequest` (1 методов), `MiniAppSubscriptionUpdateResponse`, `MiniAppSubscriptionPurchaseOptionsRequest`, `MiniAppSubscriptionPurchaseOptionsResponse`, `MiniAppSubscriptionPurchasePreviewRequest` (1 методов), `MiniAppSubscriptionPurchasePreviewResponse`, `MiniAppSubscriptionPurchaseRequest`, `MiniAppSubscriptionPurchaseResponse`, `MiniAppSubscriptionTrialRequest`, `MiniAppSubscriptionTrialResponse`
Функции: нет
- `app/webapi/schemas/pages.py` — Python-модуль
Классы: `RichTextPageResponse` — Generic representation for rich text informational pages., `RichTextPageUpdateRequest`, `FaqPageResponse`, `FaqPageListResponse`, `FaqPageCreateRequest`, `FaqPageUpdateRequest`, `FaqReorderItem`, `FaqReorderRequest`, `FaqStatusResponse`, `FaqStatusUpdateRequest`, `ServiceRulesResponse`, `ServiceRulesUpdateRequest`, `ServiceRulesHistoryResponse`
Функции: нет
- `app/webapi/schemas/promo_groups.py` — Python-модуль
Классы: `PromoGroupResponse`, `_PromoGroupBase` (1 методов), `PromoGroupCreateRequest`, `PromoGroupUpdateRequest`, `PromoGroupListResponse`
Функции: `_normalize_period_discounts`
- `app/webapi/schemas/promo_offers.py` — Python-модуль
Классы: `PromoOfferUserInfo`, `PromoOfferSubscriptionInfo`, `PromoOfferResponse`, `PromoOfferListResponse`, `PromoOfferCreateRequest`, `PromoOfferTemplateResponse`, `PromoOfferTemplateListResponse`, `PromoOfferTemplateUpdateRequest`, `PromoOfferLogOfferInfo`, `PromoOfferLogResponse`, `PromoOfferLogListResponse`
Функции: нет
- `app/webapi/schemas/promocodes.py` — Python-модуль
Классы: `PromoCodeResponse`, `PromoCodeListResponse`, `PromoCodeCreateRequest`, `PromoCodeUpdateRequest`, `PromoCodeRecentUse`, `PromoCodeDetailResponse`
Функции: нет
- `app/webapi/schemas/remnawave.py` — Python-модуль
Классы: `RemnaWaveConnectionStatus`, `RemnaWaveStatusResponse`, `RemnaWaveNode`, `RemnaWaveNodeListResponse`, `RemnaWaveNodeActionRequest`, `RemnaWaveNodeActionResponse`, `RemnaWaveNodeStatisticsResponse`, `RemnaWaveNodeUsageResponse`, `RemnaWaveBandwidth`, `RemnaWaveTrafficPeriod`, `RemnaWaveTrafficPeriods`, `RemnaWaveSystemSummary`, `RemnaWaveServerInfo`, `RemnaWaveSystemStatsResponse`, `RemnaWaveSquad`, `RemnaWaveSquadListResponse`, `RemnaWaveSquadCreateRequest`, `RemnaWaveSquadUpdateRequest`, `RemnaWaveSquadActionRequest`, `RemnaWaveOperationResponse`, `RemnaWaveInboundsResponse`, `RemnaWaveUserTrafficResponse`, `RemnaWaveSyncFromPanelRequest`, `RemnaWaveGenericSyncResponse`, `RemnaWaveSquadMigrationPreviewResponse`, `RemnaWaveSquadMigrationRequest`, `RemnaWaveSquadMigrationStats`, `RemnaWaveSquadMigrationResponse`
Функции: нет
- `app/webapi/schemas/subscriptions.py` — Python-модуль
Классы: `SubscriptionResponse`, `SubscriptionCreateRequest`, `SubscriptionExtendRequest`, `SubscriptionTrafficRequest`, `SubscriptionDevicesRequest`, `SubscriptionSquadRequest`
Функции: нет
- `app/webapi/schemas/tickets.py` — Python-модуль
Классы: `TicketMessageResponse`, `TicketResponse`, `TicketStatusUpdateRequest`, `TicketPriorityUpdateRequest`, `TicketReplyBlockRequest`
Функции: нет
- `app/webapi/schemas/tokens.py` — Python-модуль
Классы: `TokenResponse`, `TokenCreateRequest`, `TokenCreateResponse`
Функции: нет
- `app/webapi/schemas/transactions.py` — Python-модуль
Классы: `TransactionResponse`, `TransactionListResponse`
Функции: нет
- `app/webapi/schemas/users.py` — Python-модуль
Классы: `PromoGroupSummary`, `SubscriptionSummary`, `UserResponse`, `UserListResponse`, `UserCreateRequest`, `UserUpdateRequest`, `BalanceUpdateRequest`
Функции: нет
## tests
- `tests/conftest.py` — Глобальные фикстуры и настройки окружения для тестов.
Классы: нет
Функции: `fixed_datetime` — Возвращает фиксированную отметку времени для воспроизводимых проверок.
- `tests/external/`
- `tests/services/`
- `tests/test_miniapp_payments.py` — Python-модуль
Классы: нет
Функции: `anyio_backend`, `test_compute_cryptobot_limits_scale_with_rate`
- `tests/utils/`
### tests/external
- `tests/external/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `tests/external/test_cryptobot_service.py` — Тесты для внешнего клиента CryptoBotService.
Классы: нет
Функции: `anyio_backend`, `_enable_token`, `test_verify_webhook_signature`, `test_verify_webhook_signature_without_secret`
- `tests/external/test_webhook_server.py` — Тестирование хендлеров WebhookServer без запуска реального сервера.
Классы: `DummyBot`
Функции: `anyio_backend`, `webhook_server`, `_mock_request`
### tests/services
- `tests/services/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `tests/services/test_mulenpay_service_adapter.py` — Юнит-тесты MulenPayService.
Классы: нет
Функции: `anyio_backend`, `_enable_service`, `test_is_configured`, `test_format_and_signature`
- `tests/services/test_pal24_service_adapter.py` — Тесты Pal24Service и вспомогательных функций.
Классы: `StubPal24Client` (1 методов)
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_postback_success`, `test_parse_postback_missing_fields`, `test_convert_to_kopeks_and_expiration`
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_mulenpay.py` — Тесты для сценариев MulenPay в PaymentService.
Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubMulenPayService` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_pal24.py` — Тесты Pal24 сценариев PaymentService.
Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubPal24Service` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_stars.py` — Тесты для Telegram Stars-сценариев внутри PaymentService.
Классы: `DummyBot` (1 методов) — Минимальная заглушка aiogram.Bot для тестов.
Функции: `anyio_backend` — Ограничиваем anyio тесты только бэкендом asyncio., `_make_service` — Создаёт экземпляр PaymentService без выполнения полного конструктора.
- `tests/services/test_payment_service_tribute.py` — Тесты Tribute-платежей PaymentService.
Классы: нет
Функции: `anyio_backend`, `_make_service`, `test_verify_tribute_webhook_signature`, `test_verify_tribute_webhook_returns_false_without_key`
- `tests/services/test_payment_service_webhooks.py` — Интеграционные проверки обработки вебхуков PaymentService.
Классы: `DummyBot` (1 методов), `FakeSession` (2 методов)
Функции: `_make_service`, `anyio_backend`
- `tests/services/test_payment_service_yookassa.py` — Тесты для YooKassa-сценариев PaymentService.
Классы: `DummySession` (1 методов) — Простейшая заглушка AsyncSession., `DummyLocalPayment` (1 методов) — Объект, имитирующий локальную запись платежа., `StubYooKassaService` (1 методов) — Заглушка для SDK, сохраняющая вызовы.
Функции: `anyio_backend` — Запускаем async-тесты на asyncio, чтобы избежать зависимостей trio., `_make_service`
- `tests/services/test_yookassa_service_adapter.py` — Тесты низкоуровневого сервиса YooKassaService.
Классы: `DummyLoop`
Функции: `anyio_backend`, `_prepare_config`, `test_init_without_credentials`
### tests/utils
- `tests/utils/__init__.py` — Python-модуль
Классы: нет
Функции: нет
- `tests/utils/test_formatters_basic.py` — Тесты для базовых форматтеров из app.utils.formatters.
Классы: нет
Функции: `test_format_datetime_handles_iso_strings` — ISO-строка должна корректно преобразовываться в отформатированный текст., `test_format_date_uses_custom_format` — Можно задавать собственный шаблон вывода., `test_format_time_ago_returns_human_readable_text` — Разница во времени должна переводиться в человеко-понятную строку., `test_format_days_declension_handles_russian_rules` — Склонение дней в русском языке зависит от числа., `test_format_duration_switches_units`В зависимости от длины интервала выбирается подходящая единица измерения., `test_format_bytes_scales_value` — Размер должен выражаться в наиболее подходящей единице., `test_format_percentage_respects_precision` — Проценты форматируются с нужным количеством знаков., `test_format_number_inserts_separators` — Разделители тысяч должны расставляться корректно как для int, так и для float., `test_truncate_text_appends_suffix` — Строки, превышающие лимит, должны обрезаться и дополняться суффиксом., `test_format_username_prefers_full_name` — Полное имя имеет приоритет, затем username, затем ID., `test_format_subscription_status_handles_active_and_expired` — Статус подписки различается для активных/просроченных случаев., `test_format_traffic_usage_supports_unlimited` — При безлимитном тарифе в строке должна появляться бесконечность., `test_format_boolean_localises_output` — Булевые значения отображаются локализованными словами.
- `tests/utils/test_security.py` — Тесты для функций безопасности из app.utils.security.
Классы: нет
Функции: `test_hash_api_token_default_algorithm_matches_hashlib` — Проверяем, что алгоритм по умолчанию совпадает с hashlib.sha256., `test_hash_api_token_accepts_supported_algorithms` — Каждый поддерживаемый алгоритм должен выдавать корректный результат., `test_hash_api_token_rejects_unknown_algorithm` — Некорректное имя алгоритма должно приводить к ValueError., `test_generate_api_token_respects_length_bounds` — Функция должна ограничивать длину токена безопасным диапазоном., `test_generate_api_token_produces_random_values` — Два последовательных вызова должны выдавать разные токены.
- `tests/utils/test_validators_basic.py` — Базовые тесты для валидаторов из app.utils.validators.
Классы: нет
Функции: `test_validate_email_handles_expected_patterns` — Проверяем типичные корректные и некорректные адреса., `test_validate_phone_strips_formatting_and_checks_pattern` — Телефон должен соответствовать стандарту E.164 после очистки., `test_validate_telegram_username_enforces_length` — Telegram-логин должен быть 5-32 символов и содержать допустимые символы., `test_validate_amount_returns_float_within_bounds` — Числа должны конвертироваться с уважением к диапазону., `test_validate_positive_integer_enforces_upper_bound` — Положительное целое число выходит за пределы — возвращаем None., `test_validate_traffic_amount_supports_units` — Валидатор трафика распознаёт разные единицы измерения и особые значения., `test_validate_subscription_period_accepts_reasonable_range` — Диапазон допустимой длительности от 1 до 3650 дней., `test_validate_uuid_detects_standard_format` — UUID должен соответствовать HEX шаблону версии 4/5., `test_validate_url_recognises_https_links` — Валидатор URL допускает http/https ссылки и отклоняет произвольные строки., `test_validate_html_tags_rejects_unknown_tags` — Неизвестные HTML теги должны приводить к отказу., `test_validate_html_structure_detects_wrong_nesting` — Неправильная вложенность тегов должна сообщаться пользователю., `test_fix_html_tags_repairs_missing_quotes` — Автоисправление должно добавлять кавычки у ссылок., `test_validate_rules_content_detects_structure_error` — При нарушении структуры должны вернуться сообщение и отсутствие подсказки., `test_validate_rules_content_accepts_supported_markup` — Корректный HTML должен проходить проверку без сообщений.
## migrations
- `migrations/alembic/`
### migrations/alembic
- `migrations/alembic/alembic.ini` — файл (.ini)
- `migrations/alembic/env.py` — Python-модуль
Классы: нет
Функции: `run_migrations_offline`, `do_run_migrations`, `run_migrations_online`
- `migrations/alembic/versions/`
#### migrations/alembic/versions
- `migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py` — add promo groups table and link users
Классы: нет
Функции: `_table_exists`, `_column_exists`, `_index_exists`, `_foreign_key_exists`, `upgrade`, `downgrade`
- `migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py` — Python-модуль
Классы: нет
Функции: `upgrade`, `downgrade`
- `migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py` — add advertising campaigns tables
Классы: нет
Функции: `_table_exists`, `_index_exists`, `upgrade`, `downgrade`
- `migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py` — add sent notifications table
Классы: нет
Функции: `_table_exists`, `_unique_constraint_exists`, `upgrade`, `downgrade`
## docs
- `docs/miniapp-setup.md` — файл (.md)
- `docs/web-admin-integration.md` — файл (.md)
## miniapp
- `miniapp/app-config.json` — файл (.json)
- `miniapp/index.html` — файл (.html)
- `miniapp/redirect/`
### miniapp/redirect
- `miniapp/redirect/index.html` — файл (.html)
## assets
- `assets/bedolaga_app3.svg` — файл (.svg)
- `assets/logo2.svg` — файл (.svg)
## locales
- `locales/en.json` — файл (.json)
- `locales/ru.json` — файл (.json)
## data
- `data/backups/`
- `data/bot.db` — файл (.db)
### data/backups
## logs
- `logs/bot.log` — файл (.log)

View File

@@ -0,0 +1,53 @@
"""Проверяем, что PaymentService собирается из mixin-классов."""
from pathlib import Path
import sys
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.services.payment import ( # noqa: E402
CryptoBotPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PaymentCommonMixin,
TelegramStarsMixin,
TributePaymentMixin,
YooKassaPaymentMixin,
)
from app.services.payment_service import PaymentService # noqa: E402
def test_payment_service_mro_contains_all_mixins() -> None:
"""Убеждаемся, что сервис действительно включает все mixin-классы."""
mixins = {
PaymentCommonMixin,
TelegramStarsMixin,
YooKassaPaymentMixin,
TributePaymentMixin,
CryptoBotPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
}
service_mro = set(PaymentService.__mro__)
assert mixins.issubset(service_mro), "PaymentService должен содержать все mixin-классы"
@pytest.mark.parametrize(
"attribute",
[
"build_topup_success_keyboard",
"create_stars_invoice",
"create_yookassa_payment",
"create_tribute_payment",
"create_cryptobot_payment",
"create_mulenpay_payment",
"create_pal24_payment",
],
)
def test_payment_service_exposes_provider_methods(attribute: str) -> None:
"""Каждый mixin обязан добавить публичный метод в PaymentService."""
assert hasattr(PaymentService, attribute), f"Отсутствует метод {attribute}"