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