Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aae8783ca | |||
| 5997a741fc | |||
| c5d522e6b8 | |||
| fa1d128d76 | |||
| f30516a6c2 | |||
| 1feb0538cf | |||
| 3a791d624f | |||
| fe0f1f7641 | |||
| 212783ae3d | |||
| 7d72fc9555 | |||
| fb4df5cee1 | |||
| 45cd847b44 | |||
| 98fd97dea2 | |||
| e334086ad1 | |||
| 2e792911f1 | |||
| 878b666a0b | |||
| 69c3239469 | |||
| b8d48745c5 | |||
| 6b64e73d6e | |||
| b433a96cd6 | |||
| 0e4c83299d | |||
| c4d1f3eccc | |||
| 22dcf425cb | |||
| edf4132c2d | |||
| a56e14c736 | |||
| 2e3c707745 | |||
| ebd8a10fd9 | |||
| a681a9ff1b | |||
| d11458c4a8 | |||
| 9d6e17b609 | |||
| cb27487021 | |||
| e1e6b40a19 | |||
| 2616292f97 | |||
| 2e1dfa6638 | |||
| 3a730492cb | |||
| c35da4b98d | |||
| 5e98c25020 | |||
| 11a4d5b436 | |||
| 93c733aac0 | |||
| 5b03e7ab8c | |||
| 33912331af | |||
| aacc07835e | |||
| 86df2a1408 | |||
| 018de17c5b | |||
| 66afa2b924 | |||
| 4ee32fe0de | |||
| 5b59ca3c4f | |||
| 0108b0fc22 | |||
| 2e575f82af | |||
| e8af09041d | |||
| 1cf041fb86 | |||
| 63074cb009 | |||
| 89b9a6e04f | |||
| 4efa344e43 | |||
| 2d3d1ba5d3 | |||
| 849d781e92 | |||
| 48a988aa43 | |||
| ec71f733ad | |||
| 051121a366 | |||
| e2c2b9127c | |||
| fdf7263bb0 | |||
| c8dcde0919 | |||
| 9db0cd6328 | |||
| 2375bf51b3 | |||
| 3631b49d6d | |||
| 3938b56802 | |||
| 3c3b793a9f | |||
| 8985a89a9f | |||
| 5750e6a4d2 | |||
| 8d7f73edec | |||
| 4853d5b19b | |||
| 901f5fd90d | |||
| 89e3865019 | |||
| 538b2b52d3 | |||
| f1c9df8471 | |||
| 9e09e97ed8 | |||
| e50355be23 | |||
| bd036fb4a9 | |||
| 0181f8e5fb | |||
| f5eea6798a | |||
| 7c8d0254b2 | |||
| 49c3c1c0c9 | |||
| 38522442d1 | |||
| 0dacd79598 | |||
| 2022654055 | |||
| f3a1de6335 | |||
| 0b5b89ea9a | |||
| 8e21c34c8c | |||
| 400f3b01d6 | |||
| 6a0b2aabaf | |||
| 13a1ddad11 | |||
| ed404a0e54 | |||
| 05acfc0733 | |||
| 969cd6ed90 | |||
| 939c9b9b09 | |||
| 051c0db106 | |||
| e5fbe005f8 | |||
| 63efb31785 | |||
| 32586e177b | |||
| 651dba2648 | |||
| 1bbcf2a95c | |||
| 82af176018 | |||
| c10a4e9f8a | |||
| 9727e07464 | |||
| b780fe2278 | |||
| 947d61f2a8 | |||
| 27725ec1a7 | |||
| 93a29463f4 | |||
| 84cfcf45fa | |||
| 6651e59f63 | |||
| ed46b7c919 | |||
| 88cd9fbc5b | |||
| 46fc5827ca | |||
| 65d386f3b2 | |||
| 9db640931e | |||
| 53e4dec12d | |||
| 174d9f9b84 | |||
| 30ec07f7fe | |||
| dbf1fd8085 | |||
| 32e1f231d3 | |||
| 96b788693d | |||
| a0e00c3735 | |||
| 51e68cf215 | |||
| fa3ed3af81 | |||
| 6e72cbb5b0 | |||
| 5f316f85b3 | |||
| 2abd1fd9df | |||
| fcb5fedf9f | |||
| d35c9ad87e | |||
| 0cc0368b0b | |||
| 00fb154cb9 |
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.5"
|
||||
ARG VERSION="v2.5.6"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
<img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" />
|
||||
|
||||
|
||||
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
|
||||
- ✍️ **Проверка на подписку** - проверяет подписку на канал
|
||||
- 🔄 **Автосинхронизация** - фоновая синхронизация серверов с Remnawave по расписанию
|
||||
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
@@ -747,7 +748,7 @@ LOG_FILE=logs/bot.log
|
||||
- 💸 **Автоматические скидки** при оплате и автопродлении
|
||||
- 👥 Реферальная программа с комиссиями и бонусами
|
||||
- 📊 Аналитика доходов и конверсии рефералов
|
||||
- 🔗 Генерация реферальных ссылок и QR-кодов
|
||||
- 🔗 Генерация реферальных ссылок и QR кодов
|
||||
|
||||
💎 **Промо-группы и скидки**
|
||||
- 🏷️ **Система промогрупп** с индивидуальными скидками
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.handlers.admin import (
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -172,6 +173,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
|
||||
+35
-1
@@ -18,6 +18,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
@@ -182,7 +185,11 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -621,6 +628,15 @@ class Settings(BaseSettings):
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_purchase_after_topup_enabled(self) -> bool:
|
||||
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
@@ -839,6 +855,24 @@ class Settings(BaseSettings):
|
||||
and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
)
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
def get_payment_verification_auto_check_interval(self) -> int:
|
||||
try:
|
||||
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
|
||||
minutes = 10
|
||||
|
||||
if minutes <= 0:
|
||||
logger.warning(
|
||||
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
|
||||
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
)
|
||||
return 10
|
||||
|
||||
return minutes
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
|
||||
@@ -263,3 +263,35 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
|
||||
|
||||
async def get_poll_responses_with_answers(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[PollResponse], int]:
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.user),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
|
||||
)
|
||||
.where(PollResponse.poll_id == poll_id)
|
||||
.order_by(PollResponse.sent_at.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
responses = result.scalars().unique().all()
|
||||
return responses, total
|
||||
|
||||
@@ -333,18 +333,42 @@ class TicketCRUD:
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
action: Optional[str] = None,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
|
||||
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
|
||||
query = select(func.count()).select_from(SupportAuditLog)
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog.action)
|
||||
.where(SupportAuditLog.action.isnot(None))
|
||||
.distinct()
|
||||
.order_by(SupportAuditLog.action)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
|
||||
Vendored
+34
-14
@@ -19,10 +19,10 @@ class CryptoBotService:
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
@@ -37,11 +37,18 @@ class CryptoBotService:
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
request_kwargs: Dict[str, Any] = {"headers": headers}
|
||||
|
||||
if method.upper() == "GET":
|
||||
if data:
|
||||
request_kwargs["params"] = data
|
||||
elif data:
|
||||
request_kwargs["json"] = data
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data if data else None
|
||||
method,
|
||||
url,
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
@@ -95,21 +102,34 @@ class CryptoBotService:
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100
|
||||
count: int = 100,
|
||||
invoice_ids: Optional[list] = None,
|
||||
) -> Optional[list]:
|
||||
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
return await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if invoice_ids:
|
||||
data['invoice_ids'] = invoice_ids
|
||||
|
||||
result = await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if isinstance(result, dict):
|
||||
items = result.get('items')
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
Vendored
+29
-2
@@ -41,7 +41,13 @@ class HeleketService:
|
||||
raw = f"{encoded}{api_key}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
@@ -59,7 +65,12 @@ class HeleketService:
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
|
||||
async with session.post(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.content_type != "application/json":
|
||||
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
|
||||
@@ -104,6 +115,22 @@ class HeleketService:
|
||||
|
||||
return await self._request("payment/info", payload)
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if date_from:
|
||||
payload["date_from"] = date_from
|
||||
if date_to:
|
||||
payload["date_to"] = date_to
|
||||
|
||||
params = {"cursor": cursor} if cursor else None
|
||||
return await self._request("payment/list", payload, params=params)
|
||||
|
||||
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning("Heleket сервис не настроен, подпись пропускается")
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PendingPayment,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
get_payment_record,
|
||||
list_recent_pending_payments,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago, format_username
|
||||
|
||||
|
||||
PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _method_display(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _status_info(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
texts,
|
||||
) -> tuple[str, str]:
|
||||
status = (record.status or "").lower()
|
||||
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
"new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
"created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
"opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
if status in {"paid", "paid_over"}:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
|
||||
return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
"active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status = (record.status or "").lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status in {"new", "process"}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {"active"}
|
||||
return False
|
||||
|
||||
|
||||
def _record_display_number(record: PendingPayment) -> str:
|
||||
if record.identifier:
|
||||
return str(record.identifier)
|
||||
return str(record.local_id)
|
||||
|
||||
|
||||
def _build_list_keyboard(
|
||||
records: list[PendingPayment],
|
||||
*,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
|
||||
for record in records:
|
||||
number = _record_display_number(record)
|
||||
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
|
||||
try:
|
||||
button_text = details_template.format(number=number)
|
||||
except Exception: # pragma: no cover - fallback for broken localization
|
||||
button_text = f"📄 {number}"
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_payments_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data="admin_payments_page_current",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_payments_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
buttons.append(navigation_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_detail_keyboard(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, "payment_url", None)
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
payment_url = payment.link_url or payment.link_page_url or payment_url
|
||||
elif record.method == PaymentMethod.WATA:
|
||||
payment_url = payment.url or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, "confirmation_url", None) or payment_url
|
||||
elif record.method == PaymentMethod.CRYPTOBOT:
|
||||
payment_url = (
|
||||
payment.bot_invoice_url
|
||||
or payment.mini_app_invoice_url
|
||||
or payment.web_app_invoice_url
|
||||
or payment_url
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
|
||||
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _format_user_line(user: User) -> str:
|
||||
username = format_username(user.username, user.telegram_id, user.full_name)
|
||||
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
|
||||
|
||||
|
||||
def _build_record_lines(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
index: int,
|
||||
texts,
|
||||
language: str,
|
||||
) -> list[str]:
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
identifier = (
|
||||
html.escape(str(record.identifier)) if record.identifier else ""
|
||||
)
|
||||
display_number = html.escape(_record_display_number(record))
|
||||
|
||||
lines = [
|
||||
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
|
||||
f" {emoji} {status_text}",
|
||||
f" 🕒 {created} ({age})",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if identifier:
|
||||
lines.append(f" 🆔 ID: <code>{identifier}</code>")
|
||||
else:
|
||||
lines.append(f" 🆔 ID: <code>{display_number}</code>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—"
|
||||
lines = [
|
||||
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
|
||||
"",
|
||||
f"<b>{html.escape(method_name)}</b>",
|
||||
f"{emoji} {status_text}",
|
||||
"",
|
||||
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
|
||||
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
|
||||
f"🆔 ID: <code>{identifier}</code>",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if record.expires_at:
|
||||
expires_at = format_datetime(record.expires_at)
|
||||
lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
|
||||
|
||||
payment = record.payment
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
if getattr(payment, "payment_status", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.payment_status))}"
|
||||
)
|
||||
if getattr(payment, "payment_method", None):
|
||||
lines.append(
|
||||
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method))}"
|
||||
)
|
||||
if getattr(payment, "balance_amount", None):
|
||||
lines.append(
|
||||
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
|
||||
f"{html.escape(str(payment.balance_amount))}"
|
||||
)
|
||||
if getattr(payment, "payer_account", None):
|
||||
lines.append(
|
||||
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
|
||||
f"{html.escape(str(payment.payer_account))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
if getattr(payment, "mulen_payment_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.mulen_payment_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "terminal_public_id", None):
|
||||
lines.append(
|
||||
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
|
||||
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
if getattr(payment, "payment_method_type", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method_type))}"
|
||||
)
|
||||
if getattr(payment, "confirmation_url", None):
|
||||
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
|
||||
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
|
||||
)
|
||||
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
|
||||
lines.append(
|
||||
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
|
||||
)
|
||||
if getattr(payment, "status", None):
|
||||
lines.append(
|
||||
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.status))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
description = getattr(payment, "description", "") or ""
|
||||
if description:
|
||||
lines.append(f"📝 {html.escape(description)}")
|
||||
if getattr(payment, "external_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.external_id))}"
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check."))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
|
||||
suffix = payload[len(prefix) :]
|
||||
try:
|
||||
method_str, identifier = suffix.rsplit("_", 1)
|
||||
method = PaymentMethod(method_str)
|
||||
payment_id = int(identifier)
|
||||
return method, payment_id
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payments_overview(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_payments_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
page_records = records[start_index : start_index + PAGE_SIZE]
|
||||
|
||||
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
|
||||
description = texts.t(
|
||||
"ADMIN_PAYMENTS_DESCRIPTION",
|
||||
"Pending invoices created during the last 24 hours.",
|
||||
)
|
||||
notice = texts.t(
|
||||
"ADMIN_PAYMENTS_NOTICE",
|
||||
"Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
)
|
||||
|
||||
lines = [header, "", description]
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append("")
|
||||
lines.append(notice)
|
||||
else:
|
||||
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
|
||||
lines.append("")
|
||||
lines.append(empty_text)
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
record: PendingPayment,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
|
||||
keyboard = _build_detail_keyboard(record, language=db_user.language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
if not record:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, record)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_check_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
|
||||
return
|
||||
|
||||
if not _is_checkable(record):
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, updated)
|
||||
|
||||
if updated.status != record.status or updated.is_paid != record.is_paid:
|
||||
emoji, status_text = _status_info(updated, texts=texts)
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS",
|
||||
"Status updated: {status}",
|
||||
).format(status=f"{emoji} {status_text}")
|
||||
else:
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
|
||||
"Status is unchanged after the check.",
|
||||
)
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
|
||||
)
|
||||
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
|
||||
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
|
||||
@@ -1,6 +1,7 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -182,15 +183,36 @@ async def show_payment_methods(
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@@ -677,6 +677,7 @@ async def handle_simple_subscription_payment_method(
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
@@ -696,8 +697,23 @@ async def handle_simple_subscription_payment_method(
|
||||
|
||||
if payment_method == "stars":
|
||||
# Оплата через Telegram Stars
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
period_days=subscription_params["period_days"],
|
||||
device_limit=subscription_params["device_limit"],
|
||||
traffic_limit_gb=subscription_params["traffic_limit_gb"],
|
||||
squad_uuid=resolved_squad_uuid,
|
||||
payment_method="telegram_stars",
|
||||
total_price_kopeks=price_kopeks,
|
||||
)
|
||||
|
||||
if not order:
|
||||
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
||||
|
||||
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=f"Подписка на {subscription_params['period_days']} дней",
|
||||
@@ -707,7 +723,9 @@ async def handle_simple_subscription_payment_method(
|
||||
f"Устройства: {subscription_params['device_limit']}\n"
|
||||
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
|
||||
),
|
||||
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
|
||||
payload=(
|
||||
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
|
||||
),
|
||||
provider_token="", # Пустой токен для Telegram Stars
|
||||
currency="XTR", # Telegram Stars
|
||||
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
|
||||
@@ -727,8 +745,6 @@ async def handle_simple_subscription_payment_method(
|
||||
return
|
||||
|
||||
# Создаем заказ на подписку
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -20,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
|
||||
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
@@ -259,6 +260,9 @@ async def handle_subscription_cancel(
|
||||
await state.clear()
|
||||
await clear_subscription_checkout_draft(db_user.id)
|
||||
|
||||
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
|
||||
from app.handlers.menu import show_main_menu
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
|
||||
@@ -1069,12 +1069,16 @@ async def confirm_extend_subscription(
|
||||
|
||||
# Подготовим данные для сохранения в корзину
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': subscription.id,
|
||||
'period_days': days,
|
||||
'total_price': price,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
'return_to_cart': True,
|
||||
'description': f"Продление подписки на {days} дней",
|
||||
'consume_promo_offer': bool(promo_component["discount"] > 0),
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
@@ -2624,12 +2628,19 @@ async def _extend_existing_subscription(
|
||||
# Подготовим данные для сохранения в корзину
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': current_subscription.id,
|
||||
'period_days': period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
'return_to_cart': True,
|
||||
'description': f"Продление подписки на {period_days} дней",
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'consume_promo_offer': False,
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
@@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_submenu_system",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
||||
callback_data="admin_payments",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
|
||||
@@ -164,6 +165,39 @@
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Stop",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
||||
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
||||
"ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
|
||||
"ADMIN_PAYMENT_STATUS_PAID": "Paid",
|
||||
"ADMIN_PAYMENT_STATUS_FAILED": "Failed",
|
||||
"ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
|
||||
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
|
||||
"ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
|
||||
"ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
|
||||
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Payment details</b>",
|
||||
"ADMIN_PAYMENT_AMOUNT": "Amount",
|
||||
"ADMIN_PAYMENT_CREATED": "Created",
|
||||
"ADMIN_PAYMENT_EXPIRES": "Expires",
|
||||
"ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
|
||||
"ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
|
||||
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
|
||||
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
|
||||
"ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
|
||||
"ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
|
||||
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
|
||||
"ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
|
||||
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
|
||||
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
|
||||
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ You can trigger a manual status check.",
|
||||
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
|
||||
"ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
|
||||
"ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Disable",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Enable",
|
||||
"ADMIN_NODE_RESTART": "🔄 Restart",
|
||||
@@ -746,6 +780,10 @@
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the ‘My subscription’ section to access your link and setup instructions.",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
|
||||
@@ -164,6 +165,39 @@
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
||||
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
||||
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
||||
"ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
|
||||
"ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
|
||||
"ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
|
||||
"ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
|
||||
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
|
||||
"ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
|
||||
"ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
|
||||
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Детали платежа</b>",
|
||||
"ADMIN_PAYMENT_AMOUNT": "Сумма",
|
||||
"ADMIN_PAYMENT_CREATED": "Создан",
|
||||
"ADMIN_PAYMENT_EXPIRES": "Истекает",
|
||||
"ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
|
||||
"ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
|
||||
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
|
||||
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
|
||||
"ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
|
||||
"ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
|
||||
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
|
||||
"ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
|
||||
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
|
||||
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
|
||||
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
|
||||
"ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
|
||||
"ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Включить",
|
||||
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
|
||||
@@ -746,6 +780,10 @@
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
|
||||
@@ -1759,26 +1759,43 @@ class MonitoringService:
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
|
||||
return count or 0
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(MonitoringLog.event_type)
|
||||
.where(MonitoringLog.event_type.isnot(None))
|
||||
.distinct()
|
||||
.order_by(MonitoringLog.event_type)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall() if row[0]]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка типов событий мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -17,6 +19,10 @@ class MulenPayService:
|
||||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||||
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
|
||||
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
|
||||
self._max_retries = 3
|
||||
self._retry_delay = 0.5
|
||||
self._retryable_statuses = {500, 502, 503, 504}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -45,31 +51,115 @@ class MulenPayService:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data, raw_text = await self._deserialize_response(response)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s",
|
||||
response.status,
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
if (
|
||||
response.status in self._retryable_statuses
|
||||
and attempt < self._max_retries
|
||||
):
|
||||
await self._sleep_with_backoff(attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
if data is None:
|
||||
if raw_text:
|
||||
logger.warning(
|
||||
"MulenPay returned unexpected payload for %s: %s",
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MulenPay request cancelled: %s %s", method, endpoint)
|
||||
raise
|
||||
except asyncio.TimeoutError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"MulenPay request timeout (%s %s) attempt %s/%s",
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except aiohttp.ClientError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"MulenPay client error (%s %s) attempt %s/%s: %s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
if attempt < self._max_retries:
|
||||
await self._sleep_with_backoff(attempt)
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("MulenPay API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
if isinstance(last_error, asyncio.TimeoutError):
|
||||
logger.error(
|
||||
"MulenPay request timed out after %s attempts: %s %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
)
|
||||
elif last_error is not None:
|
||||
logger.error(
|
||||
"MulenPay request failed after %s attempts (%s %s): %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
last_error,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def _sleep_with_backoff(self, attempt: int) -> None:
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
|
||||
async def _deserialize_response(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
raw_text = await response.text()
|
||||
if not raw_text:
|
||||
return None, ""
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower() or not content_type:
|
||||
try:
|
||||
return json.loads(raw_text), raw_text
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(
|
||||
"Failed to decode MulenPay JSON response %s: %s",
|
||||
response.url,
|
||||
error,
|
||||
)
|
||||
return None, raw_text
|
||||
|
||||
return None, raw_text
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_kopeks: int) -> str:
|
||||
@@ -124,3 +214,21 @@ class MulenPayService:
|
||||
|
||||
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/v2/payments/{payment_id}")
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
uuid: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
params = {
|
||||
"offset": max(0, offset),
|
||||
"limit": max(1, min(limit, 1000)),
|
||||
}
|
||||
if uuid:
|
||||
params["uuid"] = uuid
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
return await self._request("GET", "/v2/payments", params=params)
|
||||
|
||||
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
@@ -294,9 +297,29 @@ class CryptoBotPaymentMixin:
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -326,7 +349,10 @@ class CryptoBotPaymentMixin:
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
@@ -337,3 +363,83 @@ class CryptoBotPaymentMixin:
|
||||
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_cryptobot_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
|
||||
|
||||
cryptobot_crud = import_module("app.database.crud.cryptobot")
|
||||
payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.warning("CryptoBot платеж %s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
|
||||
return {"payment": payment}
|
||||
|
||||
invoice_id = payment.invoice_id
|
||||
try:
|
||||
invoices = await self.cryptobot_service.get_invoices(
|
||||
invoice_ids=[invoice_id]
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.error(
|
||||
"Ошибка запроса статуса CryptoBot invoice %s: %s",
|
||||
invoice_id,
|
||||
error,
|
||||
)
|
||||
return {"payment": payment}
|
||||
|
||||
remote_invoice: Optional[Dict[str, Any]] = None
|
||||
if invoices:
|
||||
for item in invoices:
|
||||
if str(item.get("invoice_id")) == str(invoice_id):
|
||||
remote_invoice = item
|
||||
break
|
||||
|
||||
if not remote_invoice:
|
||||
logger.info(
|
||||
"CryptoBot invoice %s не найден через API при ручной проверке",
|
||||
invoice_id,
|
||||
)
|
||||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
return {"payment": refreshed or payment}
|
||||
|
||||
status = (remote_invoice.get("status") or "").lower()
|
||||
paid_at_str = remote_invoice.get("paid_at")
|
||||
paid_at = None
|
||||
if paid_at_str:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive parsing
|
||||
paid_at = None
|
||||
|
||||
if status == "paid":
|
||||
webhook_payload = {
|
||||
"update_type": "invoice_paid",
|
||||
"payload": {
|
||||
"invoice_id": remote_invoice.get("invoice_id") or invoice_id,
|
||||
"amount": remote_invoice.get("amount") or payment.amount,
|
||||
"asset": remote_invoice.get("asset") or payment.asset,
|
||||
"paid_at": paid_at_str,
|
||||
"payload": remote_invoice.get("payload") or payment.payload,
|
||||
},
|
||||
}
|
||||
await self.process_cryptobot_webhook(db, webhook_payload)
|
||||
else:
|
||||
if status and status != (payment.status or "").lower():
|
||||
await cryptobot_crud.update_cryptobot_payment_status(
|
||||
db,
|
||||
invoice_id,
|
||||
status,
|
||||
paid_at,
|
||||
)
|
||||
|
||||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
return {"payment": refreshed or payment}
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -236,6 +236,9 @@ class HeleketPaymentMixin:
|
||||
except (ValueError, TypeError):
|
||||
paid_at = None
|
||||
|
||||
if paid_at and paid_at.tzinfo is not None:
|
||||
paid_at = paid_at.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
updated_payment = await heleket_crud.update_heleket_payment(
|
||||
db,
|
||||
payment.uuid,
|
||||
@@ -414,27 +417,40 @@ class HeleketPaymentMixin:
|
||||
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
|
||||
uuid=payment.uuid,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error)
|
||||
return payment
|
||||
|
||||
if not response:
|
||||
logger.warning(
|
||||
"Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid
|
||||
logger.exception(
|
||||
"Ошибка получения статуса Heleket платежа %s: %s",
|
||||
payment.uuid,
|
||||
error,
|
||||
)
|
||||
return payment
|
||||
else:
|
||||
if response:
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
if isinstance(result, dict):
|
||||
payload = dict(result)
|
||||
else:
|
||||
logger.error(
|
||||
"Некорректный ответ Heleket API при проверке платежа %s: %s",
|
||||
payment.uuid,
|
||||
response,
|
||||
)
|
||||
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response)
|
||||
return payment
|
||||
if payload is None:
|
||||
fallback = await self._lookup_heleket_payment_history(payment)
|
||||
if not fallback:
|
||||
logger.warning(
|
||||
"Heleket API не вернул информацию по платежу %s",
|
||||
payment.uuid,
|
||||
)
|
||||
return payment
|
||||
payload = dict(fallback)
|
||||
|
||||
payload: Dict[str, Any] = dict(result)
|
||||
payload.setdefault("uuid", payment.uuid)
|
||||
payload.setdefault("order_id", payment.order_id)
|
||||
|
||||
@@ -445,3 +461,58 @@ class HeleketPaymentMixin:
|
||||
)
|
||||
|
||||
return updated_payment or payment
|
||||
|
||||
async def _lookup_heleket_payment_history(
|
||||
self,
|
||||
payment: "HeleketPayment",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service = getattr(self, "heleket_service", None)
|
||||
if not service:
|
||||
return None
|
||||
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
date_from_str: Optional[str] = None
|
||||
date_to_str: Optional[str] = None
|
||||
if isinstance(created_at, datetime):
|
||||
start = created_at - timedelta(days=2)
|
||||
end = created_at + timedelta(days=2)
|
||||
date_from_str = start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
date_to_str = end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cursor: Optional[str] = None
|
||||
for _ in range(10):
|
||||
response = await service.list_payments(
|
||||
date_from=date_from_str,
|
||||
date_to=date_to_str,
|
||||
cursor=cursor,
|
||||
)
|
||||
if not response or not isinstance(response, dict):
|
||||
return None
|
||||
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
order_id = str(item.get("order_id") or "").strip()
|
||||
if uuid and uuid == str(payment.uuid):
|
||||
return item
|
||||
if order_id and order_id == str(payment.order_id):
|
||||
return item
|
||||
|
||||
paginate = result.get("paginate")
|
||||
cursor = None
|
||||
if isinstance(paginate, dict):
|
||||
next_cursor = paginate.get("nextCursor")
|
||||
if isinstance(next_cursor, str) and next_cursor:
|
||||
cursor = next_cursor
|
||||
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -325,9 +328,29 @@ class MulenPayPaymentMixin:
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -359,7 +382,10 @@ class MulenPayPaymentMixin:
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
@@ -440,35 +466,59 @@ class MulenPayPaymentMixin:
|
||||
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 response:
|
||||
if isinstance(response, dict) and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
elif isinstance(response, dict) and "status" in response and "id" in response:
|
||||
remote_data = response
|
||||
if not remote_data and getattr(self, "mulenpay_service", None):
|
||||
list_response = await self.mulenpay_service.list_payments(
|
||||
limit=100,
|
||||
uuid=payment.uuid,
|
||||
)
|
||||
items = []
|
||||
if isinstance(list_response, dict):
|
||||
items = list_response.get("items") or []
|
||||
if items:
|
||||
for candidate in items:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
candidate_id = candidate.get("id")
|
||||
candidate_uuid = candidate.get("uuid")
|
||||
if (
|
||||
(candidate_id is not None and candidate_id == payment.mulen_payment_id)
|
||||
or (candidate_uuid and candidate_uuid == payment.uuid)
|
||||
):
|
||||
remote_data = candidate
|
||||
break
|
||||
|
||||
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
|
||||
)
|
||||
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,
|
||||
|
||||
+219
-44
@@ -6,13 +6,16 @@ import logging
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, 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.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -434,6 +437,25 @@ class Pal24PaymentMixin:
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -475,6 +497,11 @@ class Pal24PaymentMixin:
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"У пользователя %s нет сохраненной корзины или автопокупка выполнена",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
@@ -507,53 +534,135 @@ class Pal24PaymentMixin:
|
||||
return None
|
||||
|
||||
remote_status: Optional[str] = None
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
remote_payloads: Dict[str, Any] = {}
|
||||
payment_info_candidates: List[Dict[str, Optional[str]]] = []
|
||||
|
||||
service = getattr(self, "pal24_service", None)
|
||||
if service and payment.bill_id:
|
||||
bill_id_str = str(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")
|
||||
|
||||
payment_info = self._extract_remote_payment_info(response)
|
||||
|
||||
if remote_status:
|
||||
normalized_remote = str(remote_status).upper()
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"status": normalized_remote,
|
||||
"payment_status": payment_info.get("status") or remote_status,
|
||||
}
|
||||
|
||||
if payment_info.get("id"):
|
||||
update_kwargs["payment_id"] = payment_info["id"]
|
||||
if payment_info.get("method"):
|
||||
update_kwargs["payment_method"] = payment_info["method"]
|
||||
if payment_info.get("balance_amount"):
|
||||
update_kwargs["balance_amount"] = payment_info["balance_amount"]
|
||||
if payment_info.get("balance_currency"):
|
||||
update_kwargs["balance_currency"] = payment_info["balance_currency"]
|
||||
if payment_info.get("account"):
|
||||
update_kwargs["payer_account"] = payment_info["account"]
|
||||
|
||||
if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
|
||||
update_kwargs["is_paid"] = True
|
||||
if not payment.paid_at:
|
||||
update_kwargs["paid_at"] = datetime.utcnow()
|
||||
elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
|
||||
update_kwargs["is_paid"] = False
|
||||
elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
|
||||
update_kwargs.setdefault("is_paid", False)
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
response = await service.get_bill_status(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error(
|
||||
"Ошибка Pal24 API при получении статуса: %s", error
|
||||
)
|
||||
logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
|
||||
else:
|
||||
if response:
|
||||
remote_payloads["bill_status"] = response
|
||||
status_value = response.get("status") or (response.get("bill") or {}).get("status")
|
||||
if status_value:
|
||||
remote_status = str(status_value).upper()
|
||||
extracted = self._extract_remote_payment_info(response)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
if payment.payment_id:
|
||||
payment_id_str = str(payment.payment_id)
|
||||
try:
|
||||
payment_response = await service.get_payment_status(payment_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
|
||||
else:
|
||||
if payment_response:
|
||||
remote_payloads["payment_status"] = payment_response
|
||||
extracted = self._extract_remote_payment_info(payment_response)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
try:
|
||||
payments_response = await service.get_bill_payments(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
|
||||
else:
|
||||
if payments_response:
|
||||
remote_payloads["bill_payments"] = payments_response
|
||||
for candidate in self._collect_payment_candidates(payments_response):
|
||||
extracted = self._extract_remote_payment_info(candidate)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
payment_info = self._select_best_payment_info(payment, payment_info_candidates)
|
||||
if payment_info:
|
||||
remote_payloads.setdefault("selected_payment", payment_info)
|
||||
|
||||
bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
|
||||
bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
|
||||
bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
|
||||
|
||||
update_status = payment.status or "NEW"
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
is_paid_update: Optional[bool] = None
|
||||
|
||||
if remote_status:
|
||||
update_status = remote_status
|
||||
if remote_status in bill_success:
|
||||
is_paid_update = True
|
||||
elif remote_status in bill_failed:
|
||||
is_paid_update = False
|
||||
elif remote_status in bill_pending and is_paid_update is None:
|
||||
is_paid_update = False
|
||||
|
||||
payment_status_code: Optional[str] = None
|
||||
if payment_info:
|
||||
payment_status_code = (payment_info.get("status") or "").upper() or None
|
||||
if payment_status_code:
|
||||
existing_status = (getattr(payment, "payment_status", "") or "").upper()
|
||||
if payment_status_code != existing_status:
|
||||
update_kwargs["payment_status"] = payment_status_code
|
||||
|
||||
payment_id_value = payment_info.get("id")
|
||||
if payment_id_value and payment_id_value != (payment.payment_id or ""):
|
||||
update_kwargs["payment_id"] = payment_id_value
|
||||
|
||||
method_value = payment_info.get("method")
|
||||
if method_value:
|
||||
normalized_method = self._normalize_payment_method(method_value)
|
||||
if normalized_method != (payment.payment_method or ""):
|
||||
update_kwargs["payment_method"] = normalized_method
|
||||
|
||||
balance_amount = payment_info.get("balance_amount")
|
||||
if balance_amount and balance_amount != (payment.balance_amount or ""):
|
||||
update_kwargs["balance_amount"] = balance_amount
|
||||
|
||||
balance_currency = payment_info.get("balance_currency")
|
||||
if balance_currency and balance_currency != (payment.balance_currency or ""):
|
||||
update_kwargs["balance_currency"] = balance_currency
|
||||
|
||||
payer_account = payment_info.get("account")
|
||||
if payer_account and payer_account != (payment.payer_account or ""):
|
||||
update_kwargs["payer_account"] = payer_account
|
||||
|
||||
if payment_status_code:
|
||||
success_states = {"SUCCESS", "OVERPAID"}
|
||||
failed_states = {"FAIL"}
|
||||
pending_states = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
if payment_status_code in success_states:
|
||||
is_paid_update = True
|
||||
elif payment_status_code in failed_states and is_paid_update is not True:
|
||||
is_paid_update = False
|
||||
elif payment_status_code in pending_states and is_paid_update is None:
|
||||
is_paid_update = False
|
||||
|
||||
if not remote_status and payment_status_code:
|
||||
update_status = payment_status_code
|
||||
|
||||
if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
|
||||
update_kwargs["is_paid"] = is_paid_update
|
||||
if is_paid_update and not payment.paid_at:
|
||||
update_kwargs.setdefault("paid_at", datetime.utcnow())
|
||||
|
||||
current_status = payment.status or ""
|
||||
effective_status = update_status or current_status or "NEW"
|
||||
needs_update = bool(update_kwargs) or effective_status != current_status
|
||||
|
||||
if needs_update:
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=effective_status,
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
remote_status_for_return = remote_status or payment_status_code
|
||||
remote_data = remote_payloads or None
|
||||
|
||||
if payment.is_paid and not payment.transaction_id:
|
||||
try:
|
||||
@@ -576,7 +685,7 @@ class Pal24PaymentMixin:
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status,
|
||||
"remote_status": remote_status_for_return,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
@@ -621,11 +730,26 @@ class Pal24PaymentMixin:
|
||||
or candidate.get("payer_account")
|
||||
or candidate.get("AccountNumber")
|
||||
),
|
||||
"bill_id": _stringify(
|
||||
candidate.get("bill_id")
|
||||
or candidate.get("BillId")
|
||||
or candidate.get("billId")
|
||||
),
|
||||
}
|
||||
|
||||
if not isinstance(remote_data, dict):
|
||||
return {}
|
||||
|
||||
lower_keys = {str(key).lower() for key in remote_data.keys()}
|
||||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||||
has_identifier = any(
|
||||
key in lower_keys
|
||||
for key in ("payment_id", "from_card", "account_amount", "id")
|
||||
) or "bill_id" in lower_keys
|
||||
|
||||
if has_status and has_identifier and "bill" not in lower_keys:
|
||||
return _normalize(remote_data)
|
||||
|
||||
search_spaces = [remote_data]
|
||||
bill_section = remote_data.get("bill") or remote_data.get("Bill")
|
||||
if isinstance(bill_section, dict):
|
||||
@@ -641,8 +765,59 @@ class Pal24PaymentMixin:
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
data_section = remote_data.get("data") or remote_data.get("Data")
|
||||
candidate = _pick_candidate(data_section)
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
||||
def _visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
lower_keys = {str(key).lower() for key in value.keys()}
|
||||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||||
has_identifier = any(
|
||||
key in lower_keys
|
||||
for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
|
||||
)
|
||||
if has_status and has_identifier and value not in candidates:
|
||||
candidates.append(value)
|
||||
for nested in value.values():
|
||||
_visit(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_visit(item)
|
||||
|
||||
_visit(remote_data)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _select_best_payment_info(
|
||||
payment: Any,
|
||||
candidates: List[Dict[str, Optional[str]]],
|
||||
) -> Dict[str, Optional[str]]:
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
payment_id = str(getattr(payment, "payment_id", "") or "")
|
||||
bill_id = str(getattr(payment, "bill_id", "") or "")
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_id = str(candidate.get("id") or "")
|
||||
if payment_id and candidate_id == payment_id:
|
||||
return candidate
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_bill = str(candidate.get("bill_id") or "")
|
||||
if bill_id and candidate_bill == bill_id:
|
||||
return candidate
|
||||
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payment_method(payment_method: Optional[str]) -> str:
|
||||
mapping = {
|
||||
|
||||
+507
-171
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
@@ -19,11 +20,22 @@ 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.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SimpleSubscriptionPayload:
|
||||
"""Данные для простой подписки, извлечённые из payload звёздного платежа."""
|
||||
|
||||
subscription_id: Optional[int]
|
||||
period_days: Optional[int]
|
||||
|
||||
|
||||
class TelegramStarsMixin:
|
||||
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
|
||||
|
||||
@@ -85,7 +97,6 @@ class TelegramStarsMixin:
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
|
||||
del payload # payload пока не используется, но оставляем аргумент для совместимости.
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
|
||||
stars_amount
|
||||
@@ -96,12 +107,28 @@ class TelegramStarsMixin:
|
||||
)
|
||||
)
|
||||
|
||||
simple_payload = self._parse_simple_subscription_payload(
|
||||
payload,
|
||||
user_id,
|
||||
)
|
||||
|
||||
transaction_description = (
|
||||
f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)"
|
||||
if simple_payload
|
||||
else f"Пополнение через Telegram Stars ({stars_amount} ⭐)"
|
||||
)
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if simple_payload
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True,
|
||||
@@ -115,178 +142,487 @@ class TelegramStarsMixin:
|
||||
)
|
||||
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 simple_payload:
|
||||
return await self._finalize_simple_subscription_stars_payment(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
payload_data=simple_payload,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
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,
|
||||
return await self._finalize_stars_balance_topup(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
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,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _parse_simple_subscription_payload(
|
||||
payload: str,
|
||||
expected_user_id: int,
|
||||
) -> Optional[_SimpleSubscriptionPayload]:
|
||||
"""Пытается извлечь параметры простой подписки из payload звёздного платежа."""
|
||||
|
||||
prefix = "simple_sub_"
|
||||
if not payload or not payload.startswith(prefix):
|
||||
return None
|
||||
|
||||
tail = payload[len(prefix) :]
|
||||
parts = tail.split("_", 2)
|
||||
if len(parts) < 3:
|
||||
logger.warning(
|
||||
"Payload Stars simple subscription имеет некорректный формат: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
user_part, subscription_part, period_part = parts
|
||||
|
||||
try:
|
||||
payload_user_id = int(user_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать user_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
if payload_user_id != expected_user_id:
|
||||
logger.warning(
|
||||
"Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)",
|
||||
payload_user_id,
|
||||
expected_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
subscription_id = int(subscription_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать subscription_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days: Optional[int] = None
|
||||
try:
|
||||
period_days = int(period_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать период в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
|
||||
return _SimpleSubscriptionPayload(
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
async def _finalize_simple_subscription_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
payload_data: _SimpleSubscriptionPayload,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Активация простой подписки, оплаченной через Telegram Stars."""
|
||||
|
||||
period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
pending_subscription = None
|
||||
|
||||
if payload_data.subscription_id is not None:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.database.models import Subscription
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.id == payload_data.subscription_id,
|
||||
Subscription.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
except Exception as lookup_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка поиска pending подписки %s для пользователя %s: %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
lookup_error,
|
||||
exc_info=True,
|
||||
)
|
||||
pending_subscription = None
|
||||
|
||||
if not pending_subscription:
|
||||
logger.error(
|
||||
"Не найдена pending подписка %s для пользователя %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
if payload_data.period_days is None:
|
||||
start_point = pending_subscription.start_date or datetime.utcnow()
|
||||
end_point = pending_subscription.end_date or start_point
|
||||
computed_days = max(1, (end_point - start_point).days or 0)
|
||||
period_days = max(period_days, computed_days)
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=period_days,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка активации pending подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not subscription:
|
||||
logger.error(
|
||||
"Не удалось активировать pending подписку пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
period_display = period_days
|
||||
if not period_display and getattr(subscription, "start_date", None) and getattr(
|
||||
subscription, "end_date", None
|
||||
):
|
||||
period_display = max(1, (subscription.end_date - subscription.start_date).days or 0)
|
||||
if not period_display:
|
||||
period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0
|
||||
traffic_label = (
|
||||
"Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ"
|
||||
)
|
||||
|
||||
success_message = (
|
||||
"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {period_display} дней\n"
|
||||
f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n"
|
||||
f"📊 Трафик: {traffic_label}\n"
|
||||
f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n"
|
||||
"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📱 Моя подписка",
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(
|
||||
"✅ Пользователь %s получил уведомление об оплате подписки через Stars",
|
||||
user.telegram_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о подписке через Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_display,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка уведомления администраторов о подписке через Stars: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
async def _finalize_stars_balance_topup(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
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: # pragma: no cover - диагностический лог
|
||||
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: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
|
||||
|
||||
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"🆔 Транзакция: {charge_id_short}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
+154
-15
@@ -12,12 +12,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.wata_service import WataAPIError, WataService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Try to find the remote WATA transaction identifier from stored payloads."""
|
||||
|
||||
def _from_mapping(mapping: Any) -> Optional[str]:
|
||||
if isinstance(mapping, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
mapping = json.loads(mapping)
|
||||
except Exception: # pragma: no cover - defensive parsing
|
||||
return None
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
for key in ("id", "transaction_id", "transactionId"):
|
||||
value = mapping.get(key)
|
||||
if not value:
|
||||
continue
|
||||
value_str = str(value)
|
||||
if "-" in value_str:
|
||||
return value_str
|
||||
return None
|
||||
|
||||
candidate = None
|
||||
|
||||
if hasattr(payment, "callback_payload"):
|
||||
candidate = _from_mapping(getattr(payment, "callback_payload"))
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
metadata = getattr(payment, "metadata_json", None)
|
||||
if isinstance(metadata, dict):
|
||||
if "transaction" in metadata:
|
||||
candidate = _from_mapping(metadata.get("transaction"))
|
||||
if candidate:
|
||||
return candidate
|
||||
candidate = _from_mapping(metadata)
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
candidate = _from_mapping(remote_link)
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataPaymentMixin:
|
||||
"""Encapsulates creation and status handling for WATA payment links."""
|
||||
|
||||
@@ -226,6 +275,7 @@ class WataPaymentMixin:
|
||||
|
||||
remote_link: Optional[Dict[str, Any]] = None
|
||||
transaction_payload: Optional[Dict[str, Any]] = None
|
||||
transaction_id: Optional[str] = None
|
||||
|
||||
if getattr(self, "wata_service", None) and payment.payment_link_id:
|
||||
try:
|
||||
@@ -253,29 +303,84 @@ class WataPaymentMixin:
|
||||
|
||||
remote_status_normalized = (remote_status or "").lower()
|
||||
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
|
||||
transaction_id = _extract_transaction_id(payment, remote_link)
|
||||
if transaction_id:
|
||||
try:
|
||||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||||
transaction_id
|
||||
)
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка получения WATA транзакции %s: %s",
|
||||
transaction_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при запросе WATA транзакции %s: %s",
|
||||
transaction_id,
|
||||
error,
|
||||
)
|
||||
if not transaction_payload:
|
||||
try:
|
||||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||||
order_id=payment.order_id,
|
||||
payment_link_id=payment.payment_link_id,
|
||||
status="Paid",
|
||||
limit=5,
|
||||
)
|
||||
items = tx_response.get("items") or []
|
||||
for item in items:
|
||||
if (item or {}).get("status") == "Paid":
|
||||
transaction_payload = item
|
||||
break
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка поиска WATA транзакций для %s: %s",
|
||||
payment.payment_link_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||||
|
||||
if (
|
||||
not transaction_payload
|
||||
and not payment.is_paid
|
||||
and getattr(self, "wata_service", None)
|
||||
):
|
||||
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
|
||||
if fallback_transaction_id:
|
||||
try:
|
||||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||||
order_id=payment.order_id,
|
||||
payment_link_id=payment.payment_link_id,
|
||||
status="Paid",
|
||||
limit=5,
|
||||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||||
fallback_transaction_id
|
||||
)
|
||||
items = tx_response.get("items") or []
|
||||
for item in items:
|
||||
if (item or {}).get("status") == "Paid":
|
||||
transaction_payload = item
|
||||
break
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка поиска WATA транзакций для %s: %s",
|
||||
payment.payment_link_id,
|
||||
"Ошибка повторного запроса WATA транзакции %s: %s",
|
||||
fallback_transaction_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
|
||||
fallback_transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if transaction_payload and not payment.is_paid:
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
normalized_status = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||||
if raw_status:
|
||||
normalized_status = str(raw_status).lower()
|
||||
if normalized_status == "paid":
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
else:
|
||||
logger.debug(
|
||||
"WATA транзакция %s в статусе %s, повторная обработка не требуется",
|
||||
transaction_id or getattr(payment, "payment_link_id", ""),
|
||||
normalized_status or "unknown",
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
@@ -293,7 +398,22 @@ class WataPaymentMixin:
|
||||
) -> Any:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||||
else:
|
||||
paid_status = None
|
||||
if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
|
||||
logger.debug(
|
||||
"Неизвестный статус WATA транзакции %s: %s",
|
||||
getattr(payment, "payment_link_id", ""),
|
||||
paid_status,
|
||||
)
|
||||
|
||||
paid_at = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||||
if not paid_at and getattr(payment, "paid_at", None):
|
||||
paid_at = payment.paid_at
|
||||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
existing_metadata["transaction"] = transaction_payload
|
||||
|
||||
@@ -407,6 +527,25 @@ class WataPaymentMixin:
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -15,6 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -206,6 +210,108 @@ class YooKassaPaymentMixin:
|
||||
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
|
||||
return None
|
||||
|
||||
async def get_yookassa_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Запрашивает статус платежа в YooKassa и синхронизирует локальные данные."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
if getattr(self, "yookassa_service", None):
|
||||
try:
|
||||
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
|
||||
payment.yookassa_payment_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка получения статуса YooKassa %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if remote_data:
|
||||
status = remote_data.get("status") or payment.status
|
||||
paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False)))
|
||||
captured_raw = remote_data.get("captured_at")
|
||||
captured_at = None
|
||||
if captured_raw:
|
||||
try:
|
||||
captured_at = datetime.fromisoformat(
|
||||
str(captured_raw).replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as parse_error: # pragma: no cover - diagnostic log
|
||||
logger.debug(
|
||||
"Не удалось распарсить captured_at %s: %s",
|
||||
captured_raw,
|
||||
parse_error,
|
||||
)
|
||||
captured_at = None
|
||||
|
||||
payment_method_type = remote_data.get("payment_method_type")
|
||||
|
||||
updated_payment = await payment_module.update_yookassa_payment_status(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
status=status,
|
||||
is_paid=paid,
|
||||
is_captured=paid and status == "succeeded",
|
||||
captured_at=captured_at,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
transaction_id = getattr(payment, "transaction_id", None)
|
||||
|
||||
if (
|
||||
payment.status == "succeeded"
|
||||
and getattr(payment, "is_paid", False)
|
||||
):
|
||||
if not transaction_id:
|
||||
try:
|
||||
await db.refresh(payment)
|
||||
transaction_id = getattr(payment, "transaction_id", None)
|
||||
except Exception as refresh_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s",
|
||||
payment.yookassa_payment_id,
|
||||
refresh_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if transaction_id:
|
||||
logger.info(
|
||||
"Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s",
|
||||
payment.yookassa_payment_id,
|
||||
transaction_id,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await self._process_successful_yookassa_payment(db, payment)
|
||||
except Exception as process_error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка обработки успешного платежа YooKassa %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
process_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": getattr(payment, "is_paid", False),
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
async def _process_successful_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -255,12 +361,17 @@ class YooKassaPaymentMixin:
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_yookassa_payment_to_transaction(
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
if is_simple_subscription:
|
||||
@@ -357,7 +468,31 @@ class YooKassaPaymentMixin:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
|
||||
logger.info(
|
||||
"Результат проверки корзины для пользователя %s: %s",
|
||||
user.id,
|
||||
has_saved_cart,
|
||||
)
|
||||
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
@@ -402,7 +537,10 @@ class YooKassaPaymentMixin:
|
||||
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
logger.info(
|
||||
"У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
|
||||
@@ -545,14 +683,40 @@ class YooKassaPaymentMixin:
|
||||
logger.warning(
|
||||
"Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
|
||||
)
|
||||
return False
|
||||
payment = await self._restore_missing_yookassa_payment(db, event_object)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"Не удалось восстановить локальную запись платежа YooKassa %s",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
payment.status = event_object.get("status", payment.status)
|
||||
payment.confirmation_url = event_object.get("confirmation_url")
|
||||
payment.confirmation_url = self._extract_confirmation_url(event_object)
|
||||
|
||||
payment.payment_method_type = (
|
||||
(event_object.get("payment_method") or {}).get("type")
|
||||
or payment.payment_method_type
|
||||
)
|
||||
payment.refundable = event_object.get("refundable", getattr(payment, "refundable", False))
|
||||
|
||||
current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False)))
|
||||
payment.is_paid = bool(event_object.get("paid", current_paid))
|
||||
|
||||
captured_at_raw = event_object.get("captured_at")
|
||||
if captured_at_raw:
|
||||
try:
|
||||
payment.captured_at = datetime.fromisoformat(
|
||||
captured_at_raw.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as error:
|
||||
logger.debug(
|
||||
"Не удалось распарсить captured_at=%s: %s",
|
||||
captured_at_raw,
|
||||
error,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
@@ -565,3 +729,152 @@ class YooKassaPaymentMixin:
|
||||
payment.status,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _restore_missing_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_object: Dict[str, Any],
|
||||
) -> Optional["YooKassaPayment"]:
|
||||
"""Создает локальную запись платежа на основе данных webhook, если она отсутствует."""
|
||||
|
||||
yookassa_payment_id = event_object.get("id")
|
||||
if not yookassa_payment_id:
|
||||
return None
|
||||
|
||||
metadata = self._normalise_yookassa_metadata(event_object.get("metadata"))
|
||||
user_id_raw = metadata.get("user_id") or metadata.get("userId")
|
||||
|
||||
if user_id_raw is None:
|
||||
logger.error(
|
||||
"Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.error(
|
||||
"Webhook YooKassa %s содержит некорректный user_id=%s",
|
||||
yookassa_payment_id,
|
||||
user_id_raw,
|
||||
)
|
||||
return None
|
||||
|
||||
amount_info = event_object.get("amount") or {}
|
||||
amount_value = amount_info.get("value")
|
||||
currency = (amount_info.get("currency") or "RUB").upper()
|
||||
|
||||
if amount_value is None:
|
||||
logger.error(
|
||||
"Webhook YooKassa %s не содержит сумму платежа",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal("1")))
|
||||
except (InvalidOperation, ValueError) as error:
|
||||
logger.error(
|
||||
"Некорректная сумма в webhook YooKassa %s: %s (%s)",
|
||||
yookassa_payment_id,
|
||||
amount_value,
|
||||
error,
|
||||
)
|
||||
return None
|
||||
|
||||
description = event_object.get("description") or metadata.get("description") or "YooKassa платеж"
|
||||
payment_method_type = (event_object.get("payment_method") or {}).get("type")
|
||||
|
||||
yookassa_created_at = None
|
||||
created_at_raw = event_object.get("created_at")
|
||||
if created_at_raw:
|
||||
try:
|
||||
yookassa_created_at = datetime.fromisoformat(
|
||||
created_at_raw.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.debug(
|
||||
"Не удалось распарсить created_at=%s для YooKassa %s: %s",
|
||||
created_at_raw,
|
||||
yookassa_payment_id,
|
||||
error,
|
||||
)
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
local_payment = await payment_module.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=event_object.get("status", "pending"),
|
||||
confirmation_url=self._extract_confirmation_url(event_object),
|
||||
metadata_json=metadata,
|
||||
payment_method_type=payment_method_type,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=bool(event_object.get("test") or event_object.get("test_mode")),
|
||||
)
|
||||
|
||||
if not local_payment:
|
||||
return None
|
||||
|
||||
await payment_module.update_yookassa_payment_status(
|
||||
db=db,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
status=event_object.get("status", local_payment.status),
|
||||
is_paid=bool(event_object.get("paid")),
|
||||
is_captured=event_object.get("status") == "succeeded",
|
||||
captured_at=self._parse_datetime(event_object.get("captured_at")),
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
|
||||
@staticmethod
|
||||
def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]:
|
||||
if isinstance(metadata, dict):
|
||||
return metadata
|
||||
|
||||
if isinstance(metadata, list):
|
||||
normalised: Dict[str, Any] = {}
|
||||
for item in metadata:
|
||||
key = item.get("key") if isinstance(item, dict) else None
|
||||
if key:
|
||||
normalised[key] = item.get("value")
|
||||
return normalised
|
||||
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(metadata)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Не удалось распарсить metadata webhook YooKassa: %s", metadata)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_confirmation_url(event_object: Dict[str, Any]) -> Optional[str]:
|
||||
if "confirmation_url" in event_object:
|
||||
return event_object.get("confirmation_url")
|
||||
|
||||
confirmation = event_object.get("confirmation")
|
||||
if isinstance(confirmation, dict):
|
||||
return confirmation.get("confirmation_url") or confirmation.get("return_url")
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(raw_value: Optional[str]) -> Optional[datetime]:
|
||||
if not raw_value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -40,6 +40,11 @@ async def create_yookassa_payment(*args, **kwargs):
|
||||
return await yk_crud.create_yookassa_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_yookassa_payment_status(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.update_yookassa_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_yookassa_payment_to_transaction(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.link_yookassa_payment_to_transaction(*args, **kwargs)
|
||||
@@ -50,6 +55,11 @@ async def get_yookassa_payment_by_id(*args, **kwargs):
|
||||
return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_local_id(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_transaction(*args, **kwargs):
|
||||
transaction_crud = import_module("app.database.crud.transaction")
|
||||
return await transaction_crud.create_transaction(*args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
"""Helpers for inspecting and manually checking pending top-up payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PENDING_MAX_AGE = timedelta(hours=24)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PendingPayment:
|
||||
"""Normalized representation of a provider specific payment entry."""
|
||||
|
||||
method: PaymentMethod
|
||||
local_id: int
|
||||
identifier: str
|
||||
amount_kopeks: int
|
||||
status: str
|
||||
is_paid: bool
|
||||
created_at: datetime
|
||||
user: User
|
||||
payment: Any
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool:
|
||||
return (datetime.utcnow() - self.created_at) <= max_age
|
||||
|
||||
|
||||
SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
{
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
{
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def method_display_name(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return settings.is_yookassa_enabled()
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.is_mulenpay_enabled()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return settings.is_pal24_enabled()
|
||||
if method == PaymentMethod.WATA:
|
||||
return settings.is_wata_enabled()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return settings.is_heleket_enabled()
|
||||
return False
|
||||
|
||||
|
||||
def get_enabled_auto_methods() -> List[PaymentMethod]:
|
||||
return [
|
||||
method
|
||||
for method in SUPPORTED_AUTO_CHECK_METHODS
|
||||
if _method_is_enabled(method)
|
||||
]
|
||||
|
||||
|
||||
class AutoPaymentVerificationService:
|
||||
"""Background checker that periodically refreshes pending payments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: Optional[asyncio.Task[None]] = None
|
||||
self._payment_service: Optional["PaymentService"] = None
|
||||
|
||||
def set_payment_service(self, payment_service: "PaymentService") -> None:
|
||||
self._payment_service = payment_service
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_payment_verification_auto_check_enabled():
|
||||
logger.info("Автопроверка пополнений отключена настройками")
|
||||
return
|
||||
|
||||
if not self._payment_service:
|
||||
logger.warning(
|
||||
"Автопроверка пополнений не запущена: PaymentService не инициализирован"
|
||||
)
|
||||
return
|
||||
|
||||
methods = get_enabled_auto_methods()
|
||||
if not methods:
|
||||
logger.info(
|
||||
"Автопроверка пополнений не запущена: нет активных провайдеров"
|
||||
)
|
||||
return
|
||||
|
||||
display_names = ", ".join(
|
||||
sorted(method_display_name(method) for method in methods)
|
||||
)
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
|
||||
self._task = asyncio.create_task(self._auto_check_loop())
|
||||
logger.info(
|
||||
"🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s",
|
||||
interval_minutes,
|
||||
display_names,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _auto_check_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
try:
|
||||
if (
|
||||
settings.is_payment_verification_auto_check_enabled()
|
||||
and self._payment_service
|
||||
):
|
||||
methods = get_enabled_auto_methods()
|
||||
if methods:
|
||||
await self._run_checks(methods)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: активных провайдеров нет"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: отключена настройками или сервис не готов"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки
|
||||
logger.error(
|
||||
"Ошибка автопроверки пополнений: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await asyncio.sleep(max(1, interval_minutes) * 60)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Автопроверка пополнений остановлена")
|
||||
raise
|
||||
|
||||
async def _run_checks(self, methods: List[PaymentMethod]) -> None:
|
||||
if not self._payment_service:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
pending = await list_recent_pending_payments(session)
|
||||
candidates = [
|
||||
record
|
||||
for record in pending
|
||||
if record.method in methods and not record.is_paid
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: подходящих ожидающих платежей нет"
|
||||
)
|
||||
return
|
||||
|
||||
counts = Counter(record.method for record in candidates)
|
||||
summary = ", ".join(
|
||||
f"{method_display_name(method)}: {count}"
|
||||
for method, count in sorted(
|
||||
counts.items(), key=lambda item: method_display_name(item[0])
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"🔄 Автопроверка пополнений: найдено %s инвойсов (%s)",
|
||||
len(candidates),
|
||||
summary,
|
||||
)
|
||||
|
||||
for record in candidates:
|
||||
refreshed = await run_manual_check(
|
||||
session,
|
||||
record.method,
|
||||
record.local_id,
|
||||
self._payment_service,
|
||||
)
|
||||
|
||||
if not refreshed:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: не удалось обновить %s %s",
|
||||
method_display_name(record.method),
|
||||
record.identifier,
|
||||
)
|
||||
continue
|
||||
|
||||
if refreshed.is_paid and not record.is_paid:
|
||||
logger.info(
|
||||
"✅ %s %s отмечен как оплаченный после автопроверки",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
)
|
||||
elif refreshed.status != record.status:
|
||||
logger.info(
|
||||
"ℹ️ %s %s обновлён: %s → %s",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
record.status or "—",
|
||||
refreshed.status or "—",
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: %s %s без изменений (%s)",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
refreshed.status or "—",
|
||||
)
|
||||
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
except Exception:
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
auto_payment_verification_service = AutoPaymentVerificationService()
|
||||
|
||||
def _is_pal24_pending(payment: Pal24Payment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").upper()
|
||||
return status in {"NEW", "PROCESS"}
|
||||
|
||||
|
||||
def _is_mulenpay_pending(payment: MulenPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"created", "processing", "hold"}
|
||||
|
||||
|
||||
def _is_wata_pending(payment: WataPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status not in {
|
||||
"paid",
|
||||
"closed",
|
||||
"declined",
|
||||
"canceled",
|
||||
"cancelled",
|
||||
"expired",
|
||||
}
|
||||
|
||||
|
||||
def _is_heleket_pending(payment: HeleketPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"}
|
||||
|
||||
|
||||
def _is_yookassa_pending(payment: YooKassaPayment) -> bool:
|
||||
if getattr(payment, "is_paid", False) and payment.status == "succeeded":
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
|
||||
|
||||
def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool:
|
||||
status = (payment.status or "").lower()
|
||||
return status == "active"
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ""
|
||||
match = re.search(r"_(\d+)$", payload)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _metadata_is_balance(payment: YooKassaPayment) -> bool:
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower()
|
||||
return payment_type.startswith("balance_topup")
|
||||
|
||||
|
||||
def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int,
|
||||
status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]:
|
||||
user = getattr(payment, "user", None)
|
||||
if user is None:
|
||||
logger.debug("Skipping %s payment %s without linked user", method.value, identifier)
|
||||
return None
|
||||
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
if not isinstance(created_at, datetime):
|
||||
logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier)
|
||||
return None
|
||||
|
||||
local_id = getattr(payment, "id", None)
|
||||
if local_id is None:
|
||||
logger.debug("Skipping %s payment without local id", method.value)
|
||||
return None
|
||||
|
||||
return PendingPayment(
|
||||
method=method,
|
||||
local_id=int(local_id),
|
||||
identifier=identifier,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
created_at=created_at,
|
||||
user=user,
|
||||
payment=payment,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Pal24Payment)
|
||||
.options(selectinload(Pal24Payment.user))
|
||||
.where(Pal24Payment.created_at >= cutoff)
|
||||
.order_by(desc(Pal24Payment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_pal24_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.PAL24,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(MulenPayPayment)
|
||||
.options(selectinload(MulenPayPayment.user))
|
||||
.where(MulenPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(MulenPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_mulenpay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.MULENPAY,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(WataPayment)
|
||||
.options(selectinload(WataPayment.user))
|
||||
.where(WataPayment.created_at >= cutoff)
|
||||
.order_by(desc(WataPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_wata_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.WATA,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.created_at >= cutoff)
|
||||
.order_by(desc(HeleketPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_heleket_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.HELEKET,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.created_at >= cutoff)
|
||||
.order_by(desc(YooKassaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if payment.transaction_id:
|
||||
continue
|
||||
if not _metadata_is_balance(payment):
|
||||
continue
|
||||
if not _is_yookassa_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.YOOKASSA,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(getattr(payment, "is_paid", False)),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.created_at >= cutoff)
|
||||
.order_by(desc(CryptoBotPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
status = (payment.status or "").lower()
|
||||
if not _is_cryptobot_pending(payment) and status != "paid":
|
||||
continue
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
record = _build_record(
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(
|
||||
Transaction.created_at >= cutoff,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
|
||||
)
|
||||
.order_by(desc(Transaction.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for transaction in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.TELEGRAM_STARS,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status="paid" if transaction.is_completed else "pending",
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def list_recent_pending_payments(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
max_age: timedelta = PENDING_MAX_AGE,
|
||||
) -> List[PendingPayment]:
|
||||
"""Return pending payments (top-ups) from supported providers within the age window."""
|
||||
|
||||
cutoff = datetime.utcnow() - max_age
|
||||
|
||||
tasks: Iterable[List[PendingPayment]] = (
|
||||
await _fetch_yookassa_payments(db, cutoff),
|
||||
await _fetch_pal24_payments(db, cutoff),
|
||||
await _fetch_mulenpay_payments(db, cutoff),
|
||||
await _fetch_wata_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
records: List[PendingPayment] = []
|
||||
for batch in tasks:
|
||||
records.extend(batch)
|
||||
|
||||
records.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
async def get_payment_record(
|
||||
db: AsyncSession,
|
||||
method: PaymentMethod,
|
||||
local_payment_id: int,
|
||||
) -> Optional[PendingPayment]:
|
||||
"""Load single payment record and normalize it to :class:`PendingPayment`."""
|
||||
|
||||
cutoff = datetime.utcnow() - PENDING_MAX_AGE
|
||||
|
||||
if method == PaymentMethod.PAL24:
|
||||
payment = await db.get(Pal24Payment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
payment = await db.get(MulenPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.WATA:
|
||||
payment = await db.get(WataPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.HELEKET:
|
||||
payment = await db.get(HeleketPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
payment = await db.get(YooKassaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
if payment.created_at < cutoff:
|
||||
logger.debug("YooKassa payment %s is older than cutoff", payment.id)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(getattr(payment, "is_paid", False)),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
payment = await db.get(CryptoBotPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
return None
|
||||
await db.refresh(transaction, attribute_names=["user"])
|
||||
if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value:
|
||||
return None
|
||||
return _build_record(
|
||||
method,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status="paid" if transaction.is_completed else "pending",
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
|
||||
logger.debug("Unsupported payment method requested: %s", method)
|
||||
return None
|
||||
|
||||
|
||||
async def run_manual_check(
|
||||
db: AsyncSession,
|
||||
method: PaymentMethod,
|
||||
local_payment_id: int,
|
||||
payment_service: "PaymentService",
|
||||
) -> Optional[PendingPayment]:
|
||||
"""Trigger provider specific status refresh and return the updated record."""
|
||||
|
||||
try:
|
||||
if method == PaymentMethod.PAL24:
|
||||
result = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.MULENPAY:
|
||||
result = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.WATA:
|
||||
result = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.HELEKET:
|
||||
payment = await payment_service.sync_heleket_payment_status(
|
||||
db, local_payment_id=local_payment_id
|
||||
)
|
||||
elif method == PaymentMethod.YOOKASSA:
|
||||
result = await payment_service.get_yookassa_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.CRYPTOBOT:
|
||||
result = await payment_service.get_cryptobot_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
else:
|
||||
logger.warning("Manual check requested for unsupported method %s", method)
|
||||
return None
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
return await get_payment_record(db, method, local_payment_id)
|
||||
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Manual status check failed for %s payment %s: %s",
|
||||
method.value,
|
||||
local_payment_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
@@ -168,6 +168,8 @@ async def reward_user_for_poll(
|
||||
db: AsyncSession,
|
||||
response: PollResponse,
|
||||
) -> int:
|
||||
await db.refresh(response, with_for_update=True)
|
||||
|
||||
poll = response.poll
|
||||
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
|
||||
return 0
|
||||
@@ -178,6 +180,9 @@ async def reward_user_for_poll(
|
||||
user = response.user
|
||||
description = f"Награда за участие в опросе \"{poll.title}\""
|
||||
|
||||
response.reward_given = True
|
||||
response.reward_amount_kopeks = poll.reward_amount_kopeks
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
@@ -189,9 +194,10 @@ async def reward_user_for_poll(
|
||||
if not success:
|
||||
return 0
|
||||
|
||||
response.reward_given = True
|
||||
response.reward_amount_kopeks = poll.reward_amount_kopeks
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
response,
|
||||
attribute_names=["reward_given", "reward_amount_kopeks"],
|
||||
)
|
||||
|
||||
return poll.reward_amount_kopeks
|
||||
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Automatic subscription purchase from a saved cart after balance top-up."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
|
||||
from app.services.subscription_purchase_service import (
|
||||
PurchaseOptionsContext,
|
||||
PurchasePricingResult,
|
||||
PurchaseSelection,
|
||||
PurchaseValidationError,
|
||||
PurchaseBalanceError,
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutoPurchaseContext:
|
||||
"""Aggregated data prepared for automatic checkout processing."""
|
||||
|
||||
context: PurchaseOptionsContext
|
||||
pricing: PurchasePricingResult
|
||||
selection: PurchaseSelection
|
||||
service: MiniAppSubscriptionPurchaseService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutoExtendContext:
|
||||
"""Data required to automatically extend an existing subscription."""
|
||||
|
||||
subscription: Subscription
|
||||
period_days: int
|
||||
price_kopeks: int
|
||||
description: str
|
||||
device_limit: Optional[int] = None
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
squad_uuid: Optional[str] = None
|
||||
consume_promo_offer: bool = False
|
||||
|
||||
|
||||
async def _prepare_auto_purchase(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoPurchaseContext]:
|
||||
"""Builds purchase context and pricing for a saved cart."""
|
||||
|
||||
period_days = int(cart_data.get("period_days") or 0)
|
||||
if period_days <= 0:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине",
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
miniapp_service = MiniAppSubscriptionPurchaseService()
|
||||
context = await miniapp_service.build_options(db, user)
|
||||
|
||||
period_config = context.period_map.get(f"days:{period_days}")
|
||||
if not period_config:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: период %s дней недоступен для пользователя %s",
|
||||
period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
traffic_value = cart_data.get("traffic_gb")
|
||||
if traffic_value is None:
|
||||
traffic_value = (
|
||||
period_config.traffic.current_value
|
||||
if period_config.traffic.current_value is not None
|
||||
else period_config.traffic.default_value or 0
|
||||
)
|
||||
else:
|
||||
traffic_value = int(traffic_value)
|
||||
|
||||
devices = int(cart_data.get("devices") or period_config.devices.current or 1)
|
||||
servers = list(cart_data.get("countries") or [])
|
||||
if not servers:
|
||||
servers = list(period_config.servers.default_selection)
|
||||
|
||||
selection = PurchaseSelection(
|
||||
period=period_config,
|
||||
traffic_value=traffic_value,
|
||||
servers=servers,
|
||||
devices=devices,
|
||||
)
|
||||
|
||||
pricing = await miniapp_service.calculate_pricing(db, context, selection)
|
||||
return AutoPurchaseContext(
|
||||
context=context,
|
||||
pricing=pricing,
|
||||
selection=selection,
|
||||
service=miniapp_service,
|
||||
)
|
||||
|
||||
|
||||
def _safe_int(value: Optional[object], default: int = 0) -> int:
|
||||
try:
|
||||
return int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoExtendContext]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if subscription is None:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
saved_subscription_id = cart_data.get("subscription_id")
|
||||
if saved_subscription_id is not None:
|
||||
saved_subscription_id = _safe_int(saved_subscription_id, subscription.id)
|
||||
if saved_subscription_id != subscription.id:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s",
|
||||
saved_subscription_id,
|
||||
subscription.id,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days = _safe_int(cart_data.get("period_days"))
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get("total_price")
|
||||
or cart_data.get("price")
|
||||
or cart_data.get("final_price"),
|
||||
)
|
||||
|
||||
if period_days <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s",
|
||||
period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
|
||||
price_kopeks,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
|
||||
|
||||
device_limit = cart_data.get("device_limit")
|
||||
if device_limit is not None:
|
||||
device_limit = _safe_int(device_limit, subscription.device_limit)
|
||||
|
||||
traffic_limit_gb = cart_data.get("traffic_limit_gb")
|
||||
if traffic_limit_gb is not None:
|
||||
traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0)
|
||||
|
||||
squad_uuid = cart_data.get("squad_uuid")
|
||||
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
|
||||
|
||||
return AutoExtendContext(
|
||||
subscription=subscription,
|
||||
period_days=period_days,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
device_limit=device_limit,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
squad_uuid=squad_uuid,
|
||||
consume_promo_offer=consume_promo_offer,
|
||||
)
|
||||
|
||||
|
||||
def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
subscription = context.subscription
|
||||
|
||||
if subscription.is_trial:
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
if context.traffic_limit_gb is not None:
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if context.device_limit is not None:
|
||||
subscription.device_limit = max(subscription.device_limit, context.device_limit)
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
else:
|
||||
if context.traffic_limit_gb not in (None, 0):
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if (
|
||||
context.device_limit is not None
|
||||
and context.device_limit > subscription.device_limit
|
||||
):
|
||||
subscription.device_limit = context.device_limit
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
|
||||
|
||||
async def _auto_extend_subscription(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
prepared = await _prepare_auto_extend_context(user, cart_data)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < prepared.price_kopeks:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)",
|
||||
user.telegram_id,
|
||||
user.balance_kopeks,
|
||||
prepared.price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
prepared.price_kopeks,
|
||||
prepared.description,
|
||||
consume_promo_offer=prepared.consume_promo_offer,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not deducted:
|
||||
logger.warning(
|
||||
"❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
subscription = prepared.subscription
|
||||
old_end_date = subscription.end_date
|
||||
|
||||
_apply_extension_updates(prepared)
|
||||
|
||||
try:
|
||||
updated_subscription = await extend_subscription(
|
||||
db,
|
||||
subscription,
|
||||
prepared.period_days,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = None
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=prepared.price_kopeks,
|
||||
description=prepared.description,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
updated_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
period_label = format_period_description(
|
||||
prepared.period_days,
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
new_end_date = updated_subscription.end_date
|
||||
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
updated_subscription,
|
||||
transaction,
|
||||
prepared.period_days,
|
||||
old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
auto_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
|
||||
"✅ Subscription automatically extended for {period}.",
|
||||
).format(period=period_label)
|
||||
details_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
|
||||
"New expiration date: {date}.",
|
||||
).format(date=end_date_label)
|
||||
hint_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Open the ‘My subscription’ section to access your link.",
|
||||
)
|
||||
|
||||
full_message = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [auto_message, details_message, hint_message]
|
||||
if part and part.strip()
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автопокупка: подписка продлена на %s дней для пользователя %s",
|
||||
prepared.period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def auto_purchase_saved_cart_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
"""Attempts to automatically purchase a subscription from a saved cart."""
|
||||
|
||||
if not settings.is_auto_purchase_after_topup_enabled():
|
||||
return False
|
||||
|
||||
if not user or not getattr(user, "id", None):
|
||||
return False
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
if not cart_data:
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", user.telegram_id
|
||||
)
|
||||
|
||||
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
|
||||
if cart_mode == "extend":
|
||||
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
|
||||
|
||||
try:
|
||||
prepared = await _prepare_auto_purchase(db, user, cart_data)
|
||||
except PurchaseValidationError as error:
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка валидации корзины пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: непредвиденная ошибка при подготовке корзины %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
pricing = prepared.pricing
|
||||
selection = prepared.selection
|
||||
|
||||
if pricing.final_total <= 0:
|
||||
logger.warning(
|
||||
"❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)",
|
||||
user.telegram_id,
|
||||
pricing.final_total,
|
||||
)
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < pricing.final_total:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)",
|
||||
user.telegram_id,
|
||||
user.balance_kopeks,
|
||||
pricing.final_total,
|
||||
)
|
||||
return False
|
||||
|
||||
purchase_service = prepared.service
|
||||
|
||||
try:
|
||||
purchase_result = await purchase_service.submit_purchase(
|
||||
db,
|
||||
prepared.context,
|
||||
pricing,
|
||||
)
|
||||
except PurchaseBalanceError:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
except PurchaseValidationError as error:
|
||||
logger.error(
|
||||
"❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
|
||||
subscription = purchase_result.get("subscription")
|
||||
transaction = purchase_result.get("transaction")
|
||||
was_trial_conversion = purchase_result.get("was_trial_conversion", False)
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
selection.period.days,
|
||||
was_trial_conversion,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
period_label = format_period_description(
|
||||
selection.period.days,
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
auto_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
|
||||
"✅ Subscription purchased automatically after balance top-up ({period}).",
|
||||
).format(period=period_label)
|
||||
|
||||
hint_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Open the ‘My subscription’ section to access your link.",
|
||||
)
|
||||
|
||||
purchase_message = purchase_result.get("message", "")
|
||||
full_message = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [auto_message, purchase_message, hint_message]
|
||||
if part and part.strip()
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автопокупка: подписка на %s дней оформлена для пользователя %s",
|
||||
selection.period.days,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["auto_purchase_saved_cart_after_topup"]
|
||||
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
@@ -1060,7 +1061,24 @@ class MiniAppSubscriptionPurchaseService:
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
subscription = context.subscription
|
||||
if subscription is not None and getattr(subscription, "id", None):
|
||||
try:
|
||||
await db.refresh(subscription)
|
||||
except Exception as refresh_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to refresh existing subscription %s: %s",
|
||||
getattr(subscription, "id", None),
|
||||
refresh_error,
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if subscription is not None:
|
||||
context.subscription = subscription
|
||||
|
||||
was_trial_conversion = False
|
||||
now = datetime.utcnow()
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ class BotConfigurationService:
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
@@ -124,6 +125,7 @@ class BotConfigurationService:
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
@@ -234,6 +236,7 @@ class BotConfigurationService:
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT",
|
||||
"SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION",
|
||||
@@ -292,6 +295,7 @@ class BotConfigurationService:
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION",
|
||||
"WATA_": "WATA",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
|
||||
@@ -453,6 +457,34 @@ class BotConfigurationService:
|
||||
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
|
||||
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
|
||||
},
|
||||
"PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": {
|
||||
"description": (
|
||||
"Запускает фоновую проверку ожидающих пополнений и повторно обращается "
|
||||
"к платёжным провайдерам без участия администратора."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "Включено, чтобы автоматически перепроверять зависшие платежи.",
|
||||
"warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.",
|
||||
},
|
||||
"PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": {
|
||||
"description": (
|
||||
"Интервал между автоматическими проверками ожидающих пополнений в минутах."
|
||||
),
|
||||
"format": "Целое число не меньше 1.",
|
||||
"example": "10",
|
||||
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
|
||||
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
|
||||
},
|
||||
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
|
||||
"description": (
|
||||
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "true",
|
||||
"warning": (
|
||||
"Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена."
|
||||
),
|
||||
},
|
||||
"SUPPORT_TICKET_SLA_MINUTES": {
|
||||
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
|
||||
"format": "Целое число от 1 до 1440.",
|
||||
|
||||
@@ -14,6 +14,9 @@ from app.database.crud.transaction import (
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -267,17 +270,36 @@ class TributeService:
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
session,
|
||||
user,
|
||||
bot=self.bot,
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and self.bot:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(amount_kopeks)
|
||||
)
|
||||
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
@@ -293,13 +315,16 @@ class TributeService:
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user_id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
+15
-1
@@ -13,8 +13,9 @@ from .routes import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
promocodes,
|
||||
miniapp,
|
||||
polls,
|
||||
promocodes,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
pages,
|
||||
@@ -25,6 +26,7 @@ from .routes import (
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
logs,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +71,12 @@ OPENAPI_TAGS = [
|
||||
"name": "promo-offers",
|
||||
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
|
||||
},
|
||||
{
|
||||
"name": "logs",
|
||||
"description": (
|
||||
"Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
@@ -84,6 +92,10 @@ OPENAPI_TAGS = [
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
{
|
||||
"name": "polls",
|
||||
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
|
||||
},
|
||||
{
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
@@ -138,5 +150,7 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
app.include_router(polls.router, prefix="/polls", tags=["polls"])
|
||||
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
||||
|
||||
return app
|
||||
|
||||
@@ -3,6 +3,7 @@ from . import (
|
||||
health,
|
||||
main_menu_buttons,
|
||||
miniapp,
|
||||
polls,
|
||||
promo_offers,
|
||||
pages,
|
||||
promo_groups,
|
||||
@@ -13,6 +14,7 @@ from . import (
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
logs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -20,6 +22,7 @@ __all__ = [
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"miniapp",
|
||||
"polls",
|
||||
"promo_offers",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
@@ -30,4 +33,5 @@ __all__ = [
|
||||
"tokens",
|
||||
"transactions",
|
||||
"users",
|
||||
"logs",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Маршруты административного API для просмотра логов."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.logs import (
|
||||
MonitoringLogEntry,
|
||||
MonitoringLogListResponse,
|
||||
MonitoringLogTypeListResponse,
|
||||
SupportAuditActionsResponse,
|
||||
SupportAuditLogEntry,
|
||||
SupportAuditLogListResponse,
|
||||
SystemLogPreviewResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
|
||||
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
|
||||
|
||||
|
||||
def _resolve_system_log_path() -> Path:
|
||||
path = Path(settings.LOG_FILE)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]:
|
||||
def _read() -> tuple[str, int, float]:
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
stats = path.stat()
|
||||
return content, stats.st_size, stats.st_mtime
|
||||
|
||||
return await run_in_threadpool(_read)
|
||||
|
||||
|
||||
def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]:
|
||||
if timestamp is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||
|
||||
|
||||
@router.get("/system", response_model=SystemLogPreviewResponse)
|
||||
async def get_system_log_preview(
|
||||
_: Any = Security(require_api_token),
|
||||
preview_limit: int = Query(
|
||||
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
|
||||
ge=500,
|
||||
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
|
||||
description="Количество символов предпросмотра от конца файла",
|
||||
),
|
||||
) -> SystemLogPreviewResponse:
|
||||
"""Получить предпросмотр системного лог-файла бота."""
|
||||
|
||||
log_path = _resolve_system_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
return SystemLogPreviewResponse(
|
||||
path=str(log_path),
|
||||
exists=False,
|
||||
updated_at=None,
|
||||
size_bytes=0,
|
||||
size_chars=0,
|
||||
preview="",
|
||||
preview_chars=0,
|
||||
preview_truncated=False,
|
||||
download_url="/logs/system/download",
|
||||
)
|
||||
|
||||
try:
|
||||
content, size_bytes, mtime = await _read_system_log(log_path)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Лог-файл %s исчез во время чтения", log_path)
|
||||
return SystemLogPreviewResponse(
|
||||
path=str(log_path),
|
||||
exists=False,
|
||||
updated_at=None,
|
||||
size_bytes=0,
|
||||
size_chars=0,
|
||||
preview="",
|
||||
preview_chars=0,
|
||||
preview_truncated=False,
|
||||
download_url="/logs/system/download",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
|
||||
|
||||
preview_text = content[-preview_limit:] if preview_limit > 0 else ""
|
||||
truncated = len(content) > len(preview_text)
|
||||
|
||||
return SystemLogPreviewResponse(
|
||||
path=str(log_path),
|
||||
exists=True,
|
||||
updated_at=_format_timestamp(mtime),
|
||||
size_bytes=size_bytes,
|
||||
size_chars=len(content),
|
||||
preview=preview_text,
|
||||
preview_chars=len(preview_text),
|
||||
preview_truncated=truncated,
|
||||
download_url="/logs/system/download",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/download")
|
||||
async def download_system_log(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> FileResponse:
|
||||
"""Скачать полный лог-файл бота."""
|
||||
|
||||
log_path = _resolve_system_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Лог-файл не найден")
|
||||
|
||||
try:
|
||||
return FileResponse(
|
||||
log_path,
|
||||
media_type="text/plain",
|
||||
filename=log_path.name,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
|
||||
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
|
||||
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
||||
|
||||
|
||||
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
||||
async def list_monitoring_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
|
||||
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
|
||||
event_type: Optional[str] = Query(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Фильтр по типу события",
|
||||
),
|
||||
) -> MonitoringLogListResponse:
|
||||
"""Получить список логов мониторинга с пагинацией."""
|
||||
|
||||
per_page = limit
|
||||
page = (offset // per_page) + 1
|
||||
|
||||
raw_logs = await monitoring_service.get_monitoring_logs(
|
||||
db,
|
||||
event_type=event_type,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type)
|
||||
|
||||
return MonitoringLogListResponse(
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
items=[MonitoringLogEntry(**entry) for entry in raw_logs],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse)
|
||||
async def list_monitoring_event_types(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MonitoringLogTypeListResponse:
|
||||
"""Получить список доступных типов событий мониторинга."""
|
||||
|
||||
event_types = await monitoring_service.get_monitoring_event_types(db)
|
||||
return MonitoringLogTypeListResponse(items=event_types)
|
||||
|
||||
|
||||
@router.get("/support", response_model=SupportAuditLogListResponse)
|
||||
async def list_support_audit_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
|
||||
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
|
||||
action: Optional[str] = Query(
|
||||
default=None,
|
||||
max_length=50,
|
||||
description="Фильтр по типу действия модератора",
|
||||
),
|
||||
) -> SupportAuditLogListResponse:
|
||||
"""Получить список аудита действий модераторов поддержки."""
|
||||
|
||||
logs = await TicketCRUD.list_support_audit(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
action=action,
|
||||
)
|
||||
total = await TicketCRUD.count_support_audit(db, action=action)
|
||||
|
||||
return SupportAuditLogListResponse(
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
items=[
|
||||
SupportAuditLogEntry(
|
||||
id=log.id,
|
||||
actor_user_id=log.actor_user_id,
|
||||
actor_telegram_id=log.actor_telegram_id,
|
||||
is_moderator=log.is_moderator,
|
||||
action=log.action,
|
||||
ticket_id=log.ticket_id,
|
||||
target_user_id=log.target_user_id,
|
||||
details=log.details,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
for log in logs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/support/actions", response_model=SupportAuditActionsResponse)
|
||||
async def list_support_audit_actions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SupportAuditActionsResponse:
|
||||
"""Получить список действий, доступных в аудите поддержки."""
|
||||
|
||||
actions = await TicketCRUD.list_support_audit_actions(db)
|
||||
return SupportAuditActionsResponse(items=actions)
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
Response,
|
||||
Security,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.poll import (
|
||||
create_poll,
|
||||
delete_poll as delete_poll_record,
|
||||
get_poll_by_id,
|
||||
get_poll_responses_with_answers,
|
||||
get_poll_statistics,
|
||||
)
|
||||
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.polls import (
|
||||
PollAnswerResponse,
|
||||
PollCreateRequest,
|
||||
PollDetailResponse,
|
||||
PollListResponse,
|
||||
PollOptionStats,
|
||||
PollQuestionOptionResponse,
|
||||
PollQuestionResponse,
|
||||
PollQuestionStats,
|
||||
PollResponsesListResponse,
|
||||
PollStatisticsResponse,
|
||||
PollSummaryResponse,
|
||||
PollUserResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _format_price(kopeks: int) -> float:
|
||||
return round(kopeks / 100, 2)
|
||||
|
||||
|
||||
def _serialize_option(option: PollOption) -> PollQuestionOptionResponse:
|
||||
return PollQuestionOptionResponse(
|
||||
id=option.id,
|
||||
text=option.text,
|
||||
order=option.order,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_question(question: PollQuestion) -> PollQuestionResponse:
|
||||
options = [
|
||||
_serialize_option(option)
|
||||
for option in sorted(question.options, key=lambda item: item.order)
|
||||
]
|
||||
return PollQuestionResponse(
|
||||
id=question.id,
|
||||
text=question.text,
|
||||
order=question.order,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_poll_summary(
|
||||
poll: Poll,
|
||||
responses_count: int | None = None,
|
||||
) -> PollSummaryResponse:
|
||||
questions = getattr(poll, "questions", [])
|
||||
if responses_count is None:
|
||||
responses = getattr(poll, "responses", [])
|
||||
responses_count = len(responses)
|
||||
return PollSummaryResponse(
|
||||
id=poll.id,
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
|
||||
questions_count=len(questions),
|
||||
responses_count=responses_count,
|
||||
created_at=poll.created_at,
|
||||
updated_at=poll.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_poll_detail(poll: Poll) -> PollDetailResponse:
|
||||
questions = [
|
||||
_serialize_question(question)
|
||||
for question in sorted(poll.questions, key=lambda item: item.order)
|
||||
]
|
||||
return PollDetailResponse(
|
||||
id=poll.id,
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
|
||||
questions=questions,
|
||||
created_at=poll.created_at,
|
||||
updated_at=poll.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse:
|
||||
question = getattr(answer, "question", None)
|
||||
option = getattr(answer, "option", None)
|
||||
return PollAnswerResponse(
|
||||
question_id=question.id if question else answer.question_id,
|
||||
question_text=question.text if question else None,
|
||||
option_id=option.id if option else answer.option_id,
|
||||
option_text=option.text if option else None,
|
||||
created_at=answer.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user_response(response: PollResponse) -> PollUserResponse:
|
||||
user = getattr(response, "user", None)
|
||||
answers = [
|
||||
_serialize_answer(answer)
|
||||
for answer in sorted(response.answers, key=lambda item: item.created_at)
|
||||
]
|
||||
return PollUserResponse(
|
||||
id=response.id,
|
||||
user_id=getattr(user, "id", None),
|
||||
user_telegram_id=getattr(user, "telegram_id", None),
|
||||
user_username=getattr(user, "username", None),
|
||||
sent_at=response.sent_at,
|
||||
started_at=response.started_at,
|
||||
completed_at=response.completed_at,
|
||||
reward_given=response.reward_given,
|
||||
reward_amount_kopeks=response.reward_amount_kopeks,
|
||||
reward_amount_rubles=_format_price(response.reward_amount_kopeks),
|
||||
answers=answers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PollListResponse)
|
||||
async def list_polls(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PollListResponse:
|
||||
total_result = await db.execute(select(func.count()).select_from(Poll))
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return PollListResponse(items=[], total=0, limit=limit, offset=offset)
|
||||
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(selectinload(Poll.questions))
|
||||
.order_by(Poll.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
polls = result.scalars().unique().all()
|
||||
|
||||
if not polls:
|
||||
return PollListResponse(items=[], total=total, limit=limit, offset=offset)
|
||||
|
||||
poll_ids = [poll.id for poll in polls]
|
||||
counts_result = await db.execute(
|
||||
select(PollResponse.poll_id, func.count(PollResponse.id))
|
||||
.where(PollResponse.poll_id.in_(poll_ids))
|
||||
.group_by(PollResponse.poll_id)
|
||||
)
|
||||
responses_counts = {poll_id: count for poll_id, count in counts_result.all()}
|
||||
|
||||
return PollListResponse(
|
||||
items=[
|
||||
_serialize_poll_summary(
|
||||
poll, responses_counts.get(poll.id, 0)
|
||||
)
|
||||
for poll in polls
|
||||
],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{poll_id}", response_model=PollDetailResponse)
|
||||
async def get_poll(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollDetailResponse:
|
||||
poll = await get_poll_by_id(db, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
return _serialize_poll_detail(poll)
|
||||
|
||||
|
||||
@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_poll_endpoint(
|
||||
payload: PollCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollDetailResponse:
|
||||
poll = await create_poll(
|
||||
db,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
reward_enabled=payload.reward_enabled,
|
||||
reward_amount_kopeks=payload.reward_amount_kopeks,
|
||||
created_by=None,
|
||||
questions=[
|
||||
{
|
||||
"text": question.text,
|
||||
"options": [option.text for option in question.options],
|
||||
}
|
||||
for question in payload.questions
|
||||
],
|
||||
)
|
||||
|
||||
poll = await get_poll_by_id(db, poll.id)
|
||||
return _serialize_poll_detail(poll)
|
||||
|
||||
|
||||
@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_poll(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
success = await delete_poll_record(db, poll_id)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse)
|
||||
async def get_poll_stats(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollStatisticsResponse:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
stats = await get_poll_statistics(db, poll_id)
|
||||
|
||||
formatted_questions = [
|
||||
PollQuestionStats(
|
||||
id=question_data["id"],
|
||||
text=question_data["text"],
|
||||
order=question_data["order"],
|
||||
options=[
|
||||
PollOptionStats(
|
||||
id=option_data["id"],
|
||||
text=option_data["text"],
|
||||
count=option_data["count"],
|
||||
)
|
||||
for option_data in question_data.get("options", [])
|
||||
],
|
||||
)
|
||||
for question_data in stats.get("questions", [])
|
||||
]
|
||||
|
||||
return PollStatisticsResponse(
|
||||
poll_id=poll.id,
|
||||
poll_title=poll.title,
|
||||
total_responses=stats.get("total_responses", 0),
|
||||
completed_responses=stats.get("completed_responses", 0),
|
||||
reward_sum_kopeks=stats.get("reward_sum_kopeks", 0),
|
||||
reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)),
|
||||
questions=formatted_questions,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse)
|
||||
async def get_poll_responses(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PollResponsesListResponse:
|
||||
poll_exists = await db.get(Poll, poll_id)
|
||||
if not poll_exists:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
responses, total = await get_poll_responses_with_answers(
|
||||
db,
|
||||
poll_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
items = [_serialize_user_response(response) for response in responses]
|
||||
|
||||
return PollResponsesListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Pydantic-схемы для работы с логами административного API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MonitoringLogEntry(BaseModel):
|
||||
"""Запись лога мониторинга."""
|
||||
|
||||
id: int
|
||||
event_type: str = Field(..., description="Тип события мониторинга")
|
||||
message: str = Field(..., description="Краткое описание события")
|
||||
data: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Дополнительные данные события",
|
||||
)
|
||||
is_success: bool = Field(..., description="Флаг успешности выполнения операции")
|
||||
created_at: datetime = Field(..., description="Дата и время создания записи")
|
||||
|
||||
|
||||
class MonitoringLogListResponse(BaseModel):
|
||||
"""Ответ со списком логов мониторинга."""
|
||||
|
||||
total: int = Field(..., ge=0)
|
||||
limit: int = Field(..., ge=1)
|
||||
offset: int = Field(..., ge=0)
|
||||
items: List[MonitoringLogEntry]
|
||||
|
||||
|
||||
class MonitoringLogTypeListResponse(BaseModel):
|
||||
"""Ответ со списком доступных типов событий мониторинга."""
|
||||
|
||||
items: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SupportAuditLogEntry(BaseModel):
|
||||
"""Запись аудита модераторов поддержки."""
|
||||
|
||||
id: int
|
||||
actor_user_id: Optional[int]
|
||||
actor_telegram_id: int
|
||||
is_moderator: bool
|
||||
action: str
|
||||
ticket_id: Optional[int]
|
||||
target_user_id: Optional[int]
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SupportAuditLogListResponse(BaseModel):
|
||||
"""Ответ со списком аудита поддержки."""
|
||||
|
||||
total: int = Field(..., ge=0)
|
||||
limit: int = Field(..., ge=1)
|
||||
offset: int = Field(..., ge=0)
|
||||
items: List[SupportAuditLogEntry]
|
||||
|
||||
|
||||
class SupportAuditActionsResponse(BaseModel):
|
||||
"""Ответ со списком доступных действий аудита поддержки."""
|
||||
|
||||
items: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemLogPreviewResponse(BaseModel):
|
||||
"""Ответ с превью системного лог-файла бота."""
|
||||
|
||||
path: str = Field(..., description="Абсолютный путь до лог-файла")
|
||||
exists: bool = Field(..., description="Флаг наличия лог-файла")
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата и время последнего изменения лог-файла",
|
||||
)
|
||||
size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах")
|
||||
size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле")
|
||||
preview: str = Field(
|
||||
default="",
|
||||
description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра",
|
||||
)
|
||||
preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах")
|
||||
preview_truncated: bool = Field(
|
||||
..., description="Флаг усечения предпросмотра относительно полного файла"
|
||||
)
|
||||
download_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Относительный путь до endpoint для скачивания лог-файла",
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class PollOptionCreate(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def strip_text(cls, value: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("Option text cannot be empty")
|
||||
return text
|
||||
|
||||
|
||||
class PollQuestionCreate(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=1000)
|
||||
options: list[PollOptionCreate] = Field(..., min_length=2)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def strip_question_text(cls, value: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("Question text cannot be empty")
|
||||
return text
|
||||
|
||||
@field_validator("options")
|
||||
@classmethod
|
||||
def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]:
|
||||
seen: set[str] = set()
|
||||
for option in value:
|
||||
normalized = option.text.lower()
|
||||
if normalized in seen:
|
||||
raise ValueError("Option texts must be unique within a question")
|
||||
seen.add(normalized)
|
||||
return value
|
||||
|
||||
|
||||
class PollCreateRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(default=None, max_length=4000)
|
||||
reward_enabled: bool = False
|
||||
reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000)
|
||||
questions: list[PollQuestionCreate] = Field(..., min_length=1)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def strip_title(cls, value: str) -> str:
|
||||
title = value.strip()
|
||||
if not title:
|
||||
raise ValueError("Title cannot be empty")
|
||||
return title
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def normalize_description(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
description = value.strip()
|
||||
return description or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_reward(self) -> "PollCreateRequest":
|
||||
if self.reward_enabled and self.reward_amount_kopeks <= 0:
|
||||
raise ValueError("Reward amount must be positive when rewards are enabled")
|
||||
if not self.reward_enabled:
|
||||
self.reward_amount_kopeks = 0
|
||||
return self
|
||||
|
||||
|
||||
class PollQuestionOptionResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
|
||||
|
||||
class PollQuestionResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: list[PollQuestionOptionResponse]
|
||||
|
||||
|
||||
class PollSummaryResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str]
|
||||
reward_enabled: bool
|
||||
reward_amount_kopeks: int
|
||||
reward_amount_rubles: float
|
||||
questions_count: int
|
||||
responses_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PollDetailResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str]
|
||||
reward_enabled: bool
|
||||
reward_amount_kopeks: int
|
||||
reward_amount_rubles: float
|
||||
questions: list[PollQuestionResponse]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PollListResponse(BaseModel):
|
||||
items: list[PollSummaryResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PollOptionStats(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
count: int
|
||||
|
||||
|
||||
class PollQuestionStats(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: list[PollOptionStats]
|
||||
|
||||
|
||||
class PollStatisticsResponse(BaseModel):
|
||||
poll_id: int
|
||||
poll_title: str
|
||||
total_responses: int
|
||||
completed_responses: int
|
||||
reward_sum_kopeks: int
|
||||
reward_sum_rubles: float
|
||||
questions: list[PollQuestionStats]
|
||||
|
||||
|
||||
class PollAnswerResponse(BaseModel):
|
||||
question_id: Optional[int]
|
||||
question_text: Optional[str]
|
||||
option_id: Optional[int]
|
||||
option_text: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PollUserResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int]
|
||||
user_telegram_id: Optional[int]
|
||||
user_username: Optional[str]
|
||||
sent_at: datetime
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
reward_given: bool
|
||||
reward_amount_kopeks: int
|
||||
reward_amount_rubles: float
|
||||
answers: list[PollAnswerResponse]
|
||||
|
||||
|
||||
class PollResponsesListResponse(BaseModel):
|
||||
items: list[PollUserResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
+45
-7
@@ -36,13 +36,51 @@ curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health
|
||||
1. При необходимости отредактируйте `miniapp/app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты.
|
||||
2. Убедитесь, что файлы доступны для чтения пользователем веб-сервера.
|
||||
|
||||
## 5. Настройка кнопки в Telegram
|
||||
## 5. Размещение статических файлов в Docker
|
||||
|
||||
Если reverse-proxy (nginx или Caddy) работает внутри контейнера, смонтируйте каталог `miniapp` внутрь него по пути `/miniapp`. Та
|
||||
ким образом файлы `index.html` и `app-config.json` будут доступны серверу без копирования образа каждый раз при обновлении.
|
||||
|
||||
### Пример для docker-compose с Caddy
|
||||
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./miniapp:/miniapp:ro # монтируем локальную папку внутрь контейнера
|
||||
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
```
|
||||
|
||||
### Пример для docker-compose с nginx
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./miniapp:/miniapp:ro # общий каталог статических файлов
|
||||
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
```
|
||||
|
||||
Внутри конфигурации прокси укажите путь `/miniapp` как корневой для статических файлов или используйте директиву `alias`.
|
||||
При пересборке проекта структура останется той же, достаточно обновить файлы в локальной папке.
|
||||
|
||||
## 6. Настройка кнопки в Telegram
|
||||
|
||||
1. В дминке бота настраиваем параметры - Конфигурации бота - Прочее - Miniapp
|
||||
2. Перезапустите бота.
|
||||
3. При необходимости задайте кастомную кнопку на миниапп через `@BotFather` (команда `/setmenu` -> Web App URL).
|
||||
|
||||
## 6. Конфигурация nginx
|
||||
## 7. Конфигурация nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -54,7 +92,7 @@ server {
|
||||
ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem;
|
||||
|
||||
# Статические файлы мини-приложения
|
||||
root /var/www/remnawave-miniapp;
|
||||
root /miniapp;
|
||||
index index.html;
|
||||
|
||||
location = /miniapp/app-config.json {
|
||||
@@ -79,12 +117,12 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Конфигурация Caddy
|
||||
## 8. Конфигурация Caddy
|
||||
|
||||
```caddy
|
||||
miniapp.example.com {
|
||||
encode gzip zstd
|
||||
root * /var/www/remnawave-miniapp
|
||||
root * /miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
@@ -98,13 +136,13 @@ miniapp.example.com {
|
||||
```
|
||||
Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер.
|
||||
|
||||
## 8. Проверка работы
|
||||
## 9. Проверка работы
|
||||
|
||||
1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`.
|
||||
2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки.
|
||||
3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок.
|
||||
|
||||
## 9. Диагностика
|
||||
## 10. Диагностика
|
||||
|
||||
| Симптом | Возможная причина | Проверка |
|
||||
|---------|------------------|----------|
|
||||
|
||||
@@ -135,10 +135,94 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
|
||||
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
| `GET` | `/polls` | Список опросов с постраничной навигацией.
|
||||
| `GET` | `/polls/{id}` | Детали опроса с вопросами и вариантами ответов.
|
||||
| `POST` | `/polls` | Создать опрос: заголовок, описание, вопросы и варианты.
|
||||
| `DELETE` | `/polls/{id}` | Удалить опрос целиком.
|
||||
| `GET` | `/polls/{id}/stats` | Сводная статистика по ответам и начисленным наградам.
|
||||
| `GET` | `/polls/{id}/responses` | Ответы пользователей с детализацией по вопросам.
|
||||
| `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события.
|
||||
| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга.
|
||||
| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов).
|
||||
| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки.
|
||||
| `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными.
|
||||
| `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`).
|
||||
|
||||
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
|
||||
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
|
||||
|
||||
### Логи бота
|
||||
|
||||
В административном API появился раздел **logs**. Он позволяет:
|
||||
|
||||
- Просматривать общие логи мониторинга (`GET /logs/monitoring`) с поддержкой пагинации (`limit`, `offset`) и фильтра по типу события (`event_type`).
|
||||
- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке.
|
||||
- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`).
|
||||
- Запрашивать список возможных действий для UI (`GET /logs/support/actions`).
|
||||
- Просматривать системный лог-файл бота (`GET /logs/system`). Endpoint возвращает метаданные (путь, время изменения, размер в байтах/символах) и фрагмент конца файла, размер которого можно регулировать параметром `preview_limit` (от 500 до 20 000 символов).
|
||||
- Скачивать полный системный лог в текстовом формате (`GET /logs/system/download`).
|
||||
|
||||
Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах.
|
||||
|
||||
### Управление опросами
|
||||
|
||||
Раздел **polls** в административном API позволяет создавать и анализировать опросы, которые бот рассылает пользователям.
|
||||
|
||||
#### Список и детали
|
||||
|
||||
- `GET /polls` — возвращает массив объектов с базовой информацией: название, описание, флаги награды, количество вопросов и ответов.
|
||||
- `GET /polls/{id}` — раскрывает структуру конкретного опроса, включая упорядоченные вопросы и варианты ответов. Подходит для предпросмотра перед публикацией.
|
||||
|
||||
#### Создание опроса
|
||||
|
||||
Для создания опроса отправьте JSON, соответствующий схеме:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Оценка нового тарифа",
|
||||
"description": "Помогите улучшить продукт — ответ займет до 2 минут",
|
||||
"reward_enabled": true,
|
||||
"reward_amount_kopeks": 1000,
|
||||
"questions": [
|
||||
{
|
||||
"text": "Насколько вы довольны скоростью соединения?",
|
||||
"options": [
|
||||
{ "text": "Очень доволен" },
|
||||
{ "text": "Скорее доволен" },
|
||||
{ "text": "Нейтрально" },
|
||||
{ "text": "Скорее недоволен" },
|
||||
{ "text": "Очень недоволен" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "Какие улучшения вы ждёте?",
|
||||
"options": [
|
||||
{ "text": "Стабильность" },
|
||||
{ "text": "Скорость" },
|
||||
{ "text": "Поддержка" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Требования валидации:
|
||||
|
||||
- Заголовок (`title`) — от 1 до 255 символов, не пустой после обрезки пробелов.
|
||||
- Описание (`description`) — до 4000 символов, пробелы по краям удаляются.
|
||||
- Если `reward_enabled=true`, сумма вознаграждения (`reward_amount_kopeks`) должна быть положительной. При `false` значение автоматически сбрасывается в `0`.
|
||||
- Каждый вопрос содержит минимум два уникальных варианта ответа.
|
||||
|
||||
В ответ API вернёт созданный опрос с назначенными идентификаторами и полем `reward_amount_rubles`, которое удобно показывать в интерфейсе.
|
||||
|
||||
#### Удаление и статистика
|
||||
|
||||
- `DELETE /polls/{id}` — удаляет опрос и связанные с ним вопросы/ответы. Используйте с осторожностью, операция необратима.
|
||||
- `GET /polls/{id}/stats` — агрегированная статистика: общее количество ответов, завершённых прохождений и сумма выданных наград. Для каждого вопроса возвращается количество выборов по вариантам.
|
||||
- `GET /polls/{id}/responses` — список ответов пользователей с пагинацией (`limit`, `offset`). Каждый элемент содержит временные метки (`sent_at`, `started_at`, `completed_at`), данные пользователя (ID, username, Telegram ID), информацию о выданной награде и массив ответов с текстами вопросов/вариантов.
|
||||
|
||||
Такой формат позволяет без дополнительного запроса показать детализацию на фронтенде или выгрузить данные в CSV.
|
||||
|
||||
### RemnaWave интеграция
|
||||
|
||||
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
|
||||
|
||||
@@ -13,6 +13,14 @@ from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PENDING_MAX_AGE,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
auto_payment_verification_service,
|
||||
get_enabled_auto_methods,
|
||||
method_display_name,
|
||||
)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.services.version_service import version_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.heleket_webhook import start_heleket_webhook_server
|
||||
@@ -214,6 +222,67 @@ async def main():
|
||||
logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
auto_payment_verification_service.set_payment_service(payment_service)
|
||||
|
||||
verification_providers: list[str] = []
|
||||
auto_verification_active = False
|
||||
async with timeline.stage(
|
||||
"Сервис проверки пополнений",
|
||||
"💳",
|
||||
success_message="Ручная проверка активна",
|
||||
) as stage:
|
||||
for method in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled():
|
||||
verification_providers.append("YooKassa")
|
||||
elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled():
|
||||
verification_providers.append(settings.get_mulenpay_display_name())
|
||||
elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled():
|
||||
verification_providers.append("PayPalych")
|
||||
elif method == PaymentMethod.WATA and settings.is_wata_enabled():
|
||||
verification_providers.append("WATA")
|
||||
elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled():
|
||||
verification_providers.append("Heleket")
|
||||
elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled():
|
||||
verification_providers.append("CryptoBot")
|
||||
|
||||
if verification_providers:
|
||||
hours = int(PENDING_MAX_AGE.total_seconds() // 3600)
|
||||
stage.log(
|
||||
"Ожидающие пополнения автоматически отбираются не старше "
|
||||
f"{hours}ч"
|
||||
)
|
||||
stage.log(
|
||||
"Доступна ручная проверка для: "
|
||||
+ ", ".join(sorted(verification_providers))
|
||||
)
|
||||
stage.success(
|
||||
f"Активно провайдеров: {len(verification_providers)}"
|
||||
)
|
||||
else:
|
||||
stage.skip("Нет активных провайдеров для ручной проверки")
|
||||
|
||||
if settings.is_payment_verification_auto_check_enabled():
|
||||
auto_methods = get_enabled_auto_methods()
|
||||
if auto_methods:
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
auto_labels = ", ".join(
|
||||
sorted(method_display_name(method) for method in auto_methods)
|
||||
)
|
||||
stage.log(
|
||||
"Автопроверка каждые "
|
||||
f"{interval_minutes} мин: {auto_labels}"
|
||||
)
|
||||
else:
|
||||
stage.log(
|
||||
"Автопроверка включена, но нет активных провайдеров"
|
||||
)
|
||||
else:
|
||||
stage.log("Автопроверка отключена настройками")
|
||||
|
||||
await auto_payment_verification_service.start()
|
||||
auto_verification_active = auto_payment_verification_service.is_running()
|
||||
if auto_verification_active:
|
||||
stage.log("Фоновая автопроверка запущена")
|
||||
|
||||
async with timeline.stage(
|
||||
"Внешняя админка",
|
||||
@@ -423,6 +492,18 @@ async def main():
|
||||
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
|
||||
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
|
||||
]
|
||||
services_lines.append(
|
||||
"Проверка пополнений: "
|
||||
+ ("Включена" if verification_providers else "Отключена")
|
||||
)
|
||||
services_lines.append(
|
||||
"Автопроверка пополнений: "
|
||||
+ (
|
||||
"Включена"
|
||||
if auto_payment_verification_service.is_running()
|
||||
else "Отключена"
|
||||
)
|
||||
)
|
||||
timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄")
|
||||
|
||||
timeline.log_summary()
|
||||
@@ -484,7 +565,14 @@ async def main():
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("🔄 Перезапуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
|
||||
|
||||
if auto_verification_active and not auto_payment_verification_service.is_running():
|
||||
logger.warning(
|
||||
"Сервис автопроверки пополнений остановился, пробуем перезапустить..."
|
||||
)
|
||||
await auto_payment_verification_service.start()
|
||||
auto_verification_active = auto_payment_verification_service.is_running()
|
||||
|
||||
if polling_task.done():
|
||||
exception = polling_task.exception()
|
||||
if exception:
|
||||
@@ -503,7 +591,15 @@ async def main():
|
||||
timeline.log_summary()
|
||||
summary_logged = True
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса автопроверки пополнений...")
|
||||
try:
|
||||
await auto_payment_verification_service.stop()
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Ошибка остановки сервиса автопроверки пополнений: {error}"
|
||||
)
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
|
||||
yookassa_server_task.cancel()
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +18,57 @@ from app.config import settings # noqa: E402
|
||||
from app.services.mulenpay_service import MulenPayService # noqa: E402
|
||||
|
||||
|
||||
class _DummyResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int,
|
||||
body: str = "{}",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
url: str = "https://mulenpay.test/endpoint",
|
||||
) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = headers or {"Content-Type": "application/json"}
|
||||
self.url = url
|
||||
|
||||
async def __aenter__(self) -> "_DummyResponse":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface
|
||||
return False
|
||||
|
||||
async def text(self) -> str:
|
||||
return self._body
|
||||
|
||||
|
||||
class _DummySession:
|
||||
def __init__(self, result: Any) -> None:
|
||||
self._result = result
|
||||
|
||||
async def __aenter__(self) -> "_DummySession":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface
|
||||
return False
|
||||
|
||||
def request(self, *args: Any, **kwargs: Any) -> Any:
|
||||
if isinstance(self._result, BaseException):
|
||||
raise self._result
|
||||
return self._result
|
||||
|
||||
|
||||
def _session_factory(responses: Sequence[Any]) -> Any:
|
||||
call_state = {"index": 0}
|
||||
|
||||
def _factory(*_args: Any, **_kwargs: Any) -> _DummySession:
|
||||
index = min(call_state["index"], len(responses) - 1)
|
||||
call_state["index"] += 1
|
||||
return _DummySession(responses[index])
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
@@ -105,3 +158,88 @@ async def test_get_payment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(service, "_request", fake_request, raising=False)
|
||||
result = await service.get_payment(123)
|
||||
assert result == {"id": 123, "status": "paid"}
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
|
||||
response_payload = {"ok": True}
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory([
|
||||
_DummyResponse(status=200, body=json.dumps(response_payload)),
|
||||
]),
|
||||
)
|
||||
|
||||
result = await service._request("GET", "/ping")
|
||||
assert result == response_payload
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_retries_on_server_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
service._max_retries = 2
|
||||
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleep_calls.append(delay)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.asyncio.sleep",
|
||||
fake_sleep,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory(
|
||||
[
|
||||
_DummyResponse(status=502, body="{\"error\": \"bad gateway\"}"),
|
||||
_DummyResponse(status=200, body="{\"ok\": true}"),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
result = await service._request("GET", "/retry")
|
||||
assert result == {"ok": True}
|
||||
assert sleep_calls == [service._retry_delay]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_returns_none_after_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
service._max_retries = 2
|
||||
|
||||
async def fake_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.asyncio.sleep",
|
||||
fake_sleep,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory([asyncio.TimeoutError()]),
|
||||
)
|
||||
|
||||
result = await service._request("GET", "/timeout")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_reraises_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory([asyncio.CancelledError()]),
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await service._request("GET", "/cancel")
|
||||
|
||||
@@ -50,6 +50,8 @@ class StubHeleketService:
|
||||
self.info_response = info_response
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.info_calls: list[Dict[str, Optional[str]]] = []
|
||||
self.list_response: Optional[Dict[str, Any]] = None
|
||||
self.list_calls: list[Dict[str, Optional[str]]] = []
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(payload)
|
||||
@@ -64,6 +66,18 @@ class StubHeleketService:
|
||||
self.info_calls.append({"uuid": uuid, "order_id": order_id})
|
||||
return self.info_response
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self.list_calls.append(
|
||||
{"date_from": date_from, "date_to": date_to, "cursor": cursor}
|
||||
)
|
||||
return self.list_response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
@@ -249,3 +263,53 @@ async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.
|
||||
|
||||
assert result is payment
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
assert stub.list_calls # fallback to history should be attempted
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubHeleketService(response=None, info_response=None)
|
||||
stub.list_response = {
|
||||
"state": 0,
|
||||
"result": {
|
||||
"items": [
|
||||
{
|
||||
"uuid": "heleket-uuid",
|
||||
"order_id": "order-123",
|
||||
"status": "paid",
|
||||
"payment_amount": "150.00",
|
||||
}
|
||||
],
|
||||
"paginate": {"nextCursor": None},
|
||||
},
|
||||
}
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=77,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=8,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_process(self, db, payload, *, metadata_key):
|
||||
captured["payload"] = payload
|
||||
captured["metadata_key"] = metadata_key
|
||||
return SimpleNamespace(**payload)
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert captured["payload"]["status"] == "paid"
|
||||
assert stub.list_calls
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -34,7 +35,12 @@ class DummyLocalPayment:
|
||||
|
||||
|
||||
class StubPal24Service:
|
||||
def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
configured: bool = True,
|
||||
response: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.is_configured = configured
|
||||
self.response = response or {
|
||||
"success": True,
|
||||
@@ -45,6 +51,12 @@ class StubPal24Service:
|
||||
}
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.raise_error: Optional[Exception] = None
|
||||
self.status_response: Optional[Dict[str, Any]] = {"status": "NEW"}
|
||||
self.payment_status_response: Optional[Dict[str, Any]] = None
|
||||
self.bill_payments_response: Optional[Dict[str, Any]] = None
|
||||
self.status_calls: list[str] = []
|
||||
self.payment_status_calls: list[str] = []
|
||||
self.bill_payments_calls: list[str] = []
|
||||
|
||||
async def create_bill(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
self.calls.append(kwargs)
|
||||
@@ -52,6 +64,18 @@ class StubPal24Service:
|
||||
raise self.raise_error
|
||||
return self.response
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.status_calls.append(bill_id)
|
||||
return self.status_response
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.payment_status_calls.append(payment_id)
|
||||
return self.payment_status_response
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.bill_payments_calls.append(bill_id)
|
||||
return self.bill_payments_response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
@@ -198,3 +222,110 @@ async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.Monke
|
||||
language="ru",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPal24Service()
|
||||
stub.status_response = {"status": "SUCCESS"}
|
||||
stub.payment_status_response = {
|
||||
"success": True,
|
||||
"id": "PAY-1",
|
||||
"bill_id": "BILL-1",
|
||||
"status": "SUCCESS",
|
||||
"payment_method": "SBP",
|
||||
"account_amount": "700.00",
|
||||
"from_card": "676754******1234",
|
||||
}
|
||||
stub.bill_payments_response = {
|
||||
"data": [
|
||||
{
|
||||
"id": "PAY-1",
|
||||
"bill_id": "BILL-1",
|
||||
"status": "SUCCESS",
|
||||
"from_card": "676754******1234",
|
||||
"payment_method": "SBP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=99,
|
||||
bill_id="BILL-1",
|
||||
payment_id=None,
|
||||
payment_status="NEW",
|
||||
payment_method=None,
|
||||
balance_amount=None,
|
||||
balance_currency=None,
|
||||
payer_account=None,
|
||||
status="NEW",
|
||||
is_paid=False,
|
||||
paid_at=None,
|
||||
transaction_id=None,
|
||||
user_id=1,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db: DummySession, payment_id: int) -> SimpleNamespace:
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
async def fake_update_status(
|
||||
db: DummySession,
|
||||
payment_obj: SimpleNamespace,
|
||||
*,
|
||||
status: str,
|
||||
**kwargs: Any,
|
||||
) -> SimpleNamespace:
|
||||
payment_obj.status = status
|
||||
payment_obj.last_status = status
|
||||
for key, value in kwargs.items():
|
||||
setattr(payment_obj, key, value)
|
||||
if "is_paid" in kwargs:
|
||||
payment_obj.is_paid = kwargs["is_paid"]
|
||||
await db.commit()
|
||||
return payment_obj
|
||||
|
||||
async def fake_finalize(
|
||||
self: PaymentService,
|
||||
db: DummySession,
|
||||
payment_obj: Any,
|
||||
*,
|
||||
payment_id: Optional[str] = None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_pal24_payment_by_id",
|
||||
fake_get_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_pal24_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
PaymentService,
|
||||
"_finalize_pal24_payment",
|
||||
fake_finalize,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await service.get_pal24_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert payment.status == "SUCCESS"
|
||||
assert payment.payment_id == "PAY-1"
|
||||
assert payment.payment_status == "SUCCESS"
|
||||
assert payment.payment_method == "sbp"
|
||||
assert payment.is_paid is True
|
||||
assert stub.status_calls == ["BILL-1"]
|
||||
assert stub.payment_status_calls in ([], ["PAY-1"])
|
||||
assert result["remote_status"] == "SUCCESS"
|
||||
assert result["remote_data"] and "bill_status" in result["remote_data"]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Тесты для Telegram Stars-сценариев внутри PaymentService."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
@@ -25,12 +27,17 @@ class DummyBot:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.sent_messages: list[Dict[str, Any]] = []
|
||||
|
||||
async def create_invoice_link(self, **kwargs: Any) -> str:
|
||||
"""Эмулируем создание платежной ссылки и сохраняем параметры вызова."""
|
||||
self.calls.append(kwargs)
|
||||
return "https://t.me/invoice/stars"
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> None:
|
||||
"""Фиксируем отправленные сообщения пользователю."""
|
||||
self.sent_messages.append(kwargs)
|
||||
|
||||
|
||||
def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
"""Создаёт экземпляр PaymentService без выполнения полного конструктора."""
|
||||
@@ -41,6 +48,80 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
return service
|
||||
|
||||
|
||||
class DummySession:
|
||||
"""Минимальная заглушка AsyncSession для проверки сценариев Stars."""
|
||||
|
||||
def __init__(self, pending_subscription: "DummySubscription") -> None:
|
||||
self.pending_subscription = pending_subscription
|
||||
self.commits: int = 0
|
||||
self.refreshed: list[Any] = []
|
||||
|
||||
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
class _Result:
|
||||
def __init__(self, subscription: "DummySubscription") -> None:
|
||||
self._subscription = subscription
|
||||
|
||||
def scalar_one_or_none(self) -> "DummySubscription":
|
||||
return self._subscription
|
||||
|
||||
return _Result(self.pending_subscription)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def refresh(self, obj: Any) -> None:
|
||||
self.refreshed.append(obj)
|
||||
|
||||
|
||||
class DummySubscription:
|
||||
"""Упрощённая модель подписки для тестов."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_id: int,
|
||||
*,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
period_days: int = 30,
|
||||
) -> None:
|
||||
self.id = subscription_id
|
||||
self.traffic_limit_gb = traffic_limit_gb
|
||||
self.device_limit = device_limit
|
||||
self.status = "pending"
|
||||
self.start_date = datetime(2024, 1, 1)
|
||||
self.end_date = self.start_date + timedelta(days=period_days)
|
||||
|
||||
|
||||
class DummyUser:
|
||||
"""Минимальные данные пользователя для тестов Stars-покупки."""
|
||||
|
||||
def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None:
|
||||
self.id = user_id
|
||||
self.telegram_id = telegram_id
|
||||
self.language = "ru"
|
||||
self.balance_kopeks = 0
|
||||
self.has_made_first_topup = False
|
||||
self.promo_group = None
|
||||
self.subscription = None
|
||||
|
||||
|
||||
class DummyTransaction:
|
||||
"""Локальная транзакция, созданная в тестах."""
|
||||
|
||||
def __init__(self, external_id: str) -> None:
|
||||
self.external_id = external_id
|
||||
|
||||
|
||||
class DummySubscriptionService:
|
||||
"""Заглушка SubscriptionService, запоминающая вызовы."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Any, Any]] = []
|
||||
|
||||
async def create_remnawave_user(self, db: Any, subscription: Any) -> object:
|
||||
self.calls.append((db, subscription))
|
||||
return object()
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1."""
|
||||
@@ -140,3 +221,127 @@ async def test_create_stars_invoice_requires_bot() -> None:
|
||||
amount_kopeks=1000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_stars_payment_simple_subscription_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Оплата простой подписки через Stars активирует pending подписку и уведомляет пользователя."""
|
||||
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
|
||||
pending_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
db = DummySession(pending_subscription)
|
||||
user = DummyUser(user_id=900, telegram_id=123456)
|
||||
activated_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
|
||||
transaction_holder: Dict[str, DummyTransaction] = {}
|
||||
|
||||
async def fake_create_transaction(**kwargs: Any) -> DummyTransaction:
|
||||
transaction = DummyTransaction(external_id=kwargs.get("external_id", ""))
|
||||
transaction_holder["value"] = transaction
|
||||
return transaction
|
||||
|
||||
async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser:
|
||||
return user
|
||||
|
||||
async def fake_activate_pending_subscription(
|
||||
db: Any,
|
||||
user_id: int,
|
||||
period_days: Optional[int] = None,
|
||||
) -> DummySubscription:
|
||||
activated_subscription.start_date = pending_subscription.start_date
|
||||
activated_subscription.end_date = activated_subscription.start_date + timedelta(
|
||||
days=period_days or 30
|
||||
)
|
||||
return activated_subscription
|
||||
|
||||
subscription_service_stub = DummySubscriptionService()
|
||||
admin_calls: list[Dict[str, Any]] = []
|
||||
|
||||
class AdminNotificationStub:
|
||||
def __init__(self, _bot: Any) -> None:
|
||||
self.bot = _bot
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: Any,
|
||||
user_obj: Any,
|
||||
subscription: Any,
|
||||
transaction: Any,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool,
|
||||
) -> None:
|
||||
admin_calls.append(
|
||||
{
|
||||
"user": user_obj,
|
||||
"subscription": subscription,
|
||||
"transaction": transaction,
|
||||
"period": period_days,
|
||||
"was_trial": was_trial_conversion,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.create_transaction",
|
||||
fake_create_transaction,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.get_user_by_id",
|
||||
fake_get_user_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.database.crud.subscription.activate_pending_subscription",
|
||||
fake_activate_pending_subscription,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_service.SubscriptionService",
|
||||
lambda: subscription_service_stub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.admin_notification_service.AdminNotificationService",
|
||||
AdminNotificationStub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(settings),
|
||||
"format_price",
|
||||
lambda self, amount: f"{amount / 100:.0f}₽",
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS",
|
||||
30,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars",
|
||||
lambda stars: Decimal("100"),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = f"simple_sub_{user.id}_{pending_subscription.id}_30"
|
||||
result = await service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=5,
|
||||
payload=payload,
|
||||
telegram_payment_charge_id="charge12345",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки"
|
||||
assert subscription_service_stub.calls == [(db, activated_subscription)]
|
||||
assert len(admin_calls) == 1
|
||||
assert admin_calls[0]["subscription"] is activated_subscription
|
||||
assert admin_calls[0]["period"] == 30
|
||||
assert bot.sent_messages, "Пользователь должен получить уведомление"
|
||||
assert "Подписка успешно активирована" in bot.sent_messages[0]["text"]
|
||||
assert transaction_holder["value"].external_id == "charge12345"
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace, ModuleType
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -496,6 +496,166 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_restores_missing_payment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
|
||||
restored_payment = SimpleNamespace(
|
||||
yookassa_payment_id="yk_456",
|
||||
user_id=21,
|
||||
amount_kopeks=0,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
transaction_id=None,
|
||||
description="",
|
||||
payment_method_type=None,
|
||||
confirmation_url=None,
|
||||
metadata_json=None,
|
||||
test_mode=False,
|
||||
refundable=False,
|
||||
)
|
||||
|
||||
get_calls = {"count": 0}
|
||||
|
||||
async def fake_get_payment(db, payment_id):
|
||||
get_calls["count"] += 1
|
||||
if get_calls["count"] == 1:
|
||||
return None
|
||||
return restored_payment
|
||||
|
||||
async def fake_create_payment(**kwargs: Any):
|
||||
restored_payment.user_id = kwargs["user_id"]
|
||||
restored_payment.amount_kopeks = kwargs["amount_kopeks"]
|
||||
restored_payment.status = kwargs["status"]
|
||||
restored_payment.description = kwargs["description"]
|
||||
restored_payment.payment_method_type = kwargs["payment_method_type"]
|
||||
restored_payment.confirmation_url = kwargs["confirmation_url"]
|
||||
restored_payment.metadata_json = kwargs["metadata_json"]
|
||||
restored_payment.test_mode = kwargs["test_mode"]
|
||||
restored_payment.yookassa_payment_id = kwargs["yookassa_payment_id"]
|
||||
restored_payment.yookassa_created_at = kwargs["yookassa_created_at"]
|
||||
return restored_payment
|
||||
|
||||
async def fake_update_status(
|
||||
db,
|
||||
yookassa_payment_id,
|
||||
status,
|
||||
is_paid,
|
||||
is_captured,
|
||||
captured_at,
|
||||
payment_method_type,
|
||||
):
|
||||
restored_payment.status = status
|
||||
restored_payment.is_paid = is_paid
|
||||
restored_payment.is_captured = is_captured
|
||||
restored_payment.captured_at = captured_at
|
||||
restored_payment.payment_method_type = payment_method_type
|
||||
return restored_payment
|
||||
|
||||
async def fake_link(db, yookassa_payment_id, transaction_id):
|
||||
restored_payment.transaction_id = transaction_id
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment)
|
||||
monkeypatch.setattr(payment_service_module, "create_yookassa_payment", fake_create_payment)
|
||||
monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", fake_update_status)
|
||||
monkeypatch.setattr(payment_service_module, "link_yookassa_payment_to_transaction", fake_link)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=555, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=21,
|
||||
telegram_id=2100,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
return user
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
|
||||
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
|
||||
|
||||
admin_calls: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)))
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
payload = {
|
||||
"object": {
|
||||
"id": "yk_456",
|
||||
"status": "succeeded",
|
||||
"paid": True,
|
||||
"amount": {"value": "150.00", "currency": "RUB"},
|
||||
"metadata": {"user_id": "21", "payment_purpose": "balance_topup"},
|
||||
"description": "Пополнение",
|
||||
"payment_method": {"type": "bank_card"},
|
||||
"created_at": "2024-01-02T12:00:00Z",
|
||||
"captured_at": "2024-01-02T12:05:00Z",
|
||||
"confirmation": {"confirmation_url": "https://pay.example"},
|
||||
}
|
||||
}
|
||||
|
||||
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert get_calls["count"] >= 2 # повторный запрос после восстановления
|
||||
assert restored_payment.amount_kopeks == 15000
|
||||
assert restored_payment.is_paid is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 15000
|
||||
assert restored_payment.transaction_id == 555
|
||||
assert user.balance_kopeks == 15000
|
||||
assert bot.sent_messages
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(DummyBot())
|
||||
db = FakeSession()
|
||||
|
||||
async def fake_get_payment(db_session, payment_id):
|
||||
return None
|
||||
|
||||
create_mock = AsyncMock()
|
||||
update_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment)
|
||||
monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock)
|
||||
monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock)
|
||||
|
||||
payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}}
|
||||
|
||||
result = await service.process_yookassa_webhook(db, payload)
|
||||
|
||||
assert result is False
|
||||
create_mock.assert_not_awaited()
|
||||
update_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
@@ -669,6 +829,21 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
|
||||
},
|
||||
}
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "trs-auto-1",
|
||||
"bill_id": bill_id,
|
||||
"status": "SUCCESS",
|
||||
"payment_method": "SBP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service.pal24_service = DummyPal24Service()
|
||||
|
||||
fake_session = FakeSession()
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
|
||||
from app.services.subscription_purchase_service import (
|
||||
PurchaseDevicesConfig,
|
||||
PurchaseOptionsContext,
|
||||
PurchasePeriodConfig,
|
||||
PurchasePricingResult,
|
||||
PurchaseSelection,
|
||||
PurchaseServersConfig,
|
||||
PurchaseTrafficConfig,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class DummyTexts:
|
||||
def t(self, key: str, default: str):
|
||||
return default
|
||||
|
||||
def format_price(self, value: int) -> str:
|
||||
return f"{value / 100:.0f} ₽"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 42
|
||||
user.telegram_id = 4242
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = None
|
||||
|
||||
cart_data = {
|
||||
"period_days": 30,
|
||||
"countries": ["ru"],
|
||||
"traffic_gb": 0,
|
||||
"devices": 1,
|
||||
}
|
||||
|
||||
traffic_config = PurchaseTrafficConfig(
|
||||
selectable=False,
|
||||
mode="fixed",
|
||||
options=[],
|
||||
default_value=0,
|
||||
current_value=0,
|
||||
)
|
||||
servers_config = PurchaseServersConfig(
|
||||
options=[],
|
||||
min_selectable=0,
|
||||
max_selectable=0,
|
||||
default_selection=["ru"],
|
||||
)
|
||||
devices_config = PurchaseDevicesConfig(
|
||||
minimum=1,
|
||||
maximum=5,
|
||||
default=1,
|
||||
current=1,
|
||||
price_per_device=0,
|
||||
discounted_price_per_device=0,
|
||||
price_label="0 ₽",
|
||||
)
|
||||
|
||||
period_config = PurchasePeriodConfig(
|
||||
id="days:30",
|
||||
days=30,
|
||||
months=1,
|
||||
label="30 дней",
|
||||
base_price=100_000,
|
||||
base_price_label="1000 ₽",
|
||||
base_price_original=100_000,
|
||||
base_price_original_label=None,
|
||||
discount_percent=0,
|
||||
per_month_price=100_000,
|
||||
per_month_price_label="1000 ₽",
|
||||
traffic=traffic_config,
|
||||
servers=servers_config,
|
||||
devices=devices_config,
|
||||
)
|
||||
|
||||
context = PurchaseOptionsContext(
|
||||
user=user,
|
||||
subscription=None,
|
||||
currency="RUB",
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
periods=[period_config],
|
||||
default_period=period_config,
|
||||
period_map={"days:30": period_config},
|
||||
server_uuid_to_id={"ru": 1},
|
||||
payload={},
|
||||
)
|
||||
|
||||
base_pricing = PurchasePricingResult(
|
||||
selection=PurchaseSelection(
|
||||
period=period_config,
|
||||
traffic_value=0,
|
||||
servers=["ru"],
|
||||
devices=1,
|
||||
),
|
||||
server_ids=[1],
|
||||
server_prices_for_period=[100_000],
|
||||
base_original_total=100_000,
|
||||
discounted_total=100_000,
|
||||
promo_discount_value=0,
|
||||
promo_discount_percent=0,
|
||||
final_total=100_000,
|
||||
months=1,
|
||||
details={"servers_individual_prices": [100_000]},
|
||||
)
|
||||
|
||||
class DummyMiniAppService:
|
||||
async def build_options(self, db, user):
|
||||
return context
|
||||
|
||||
async def calculate_pricing(self, db, ctx, selection):
|
||||
return PurchasePricingResult(
|
||||
selection=selection,
|
||||
server_ids=base_pricing.server_ids,
|
||||
server_prices_for_period=base_pricing.server_prices_for_period,
|
||||
base_original_total=base_pricing.base_original_total,
|
||||
discounted_total=base_pricing.discounted_total,
|
||||
promo_discount_value=base_pricing.promo_discount_value,
|
||||
promo_discount_percent=base_pricing.promo_discount_percent,
|
||||
final_total=base_pricing.final_total,
|
||||
months=base_pricing.months,
|
||||
details=base_pricing.details,
|
||||
)
|
||||
|
||||
async def submit_purchase(self, db, prepared_context, pricing):
|
||||
return {
|
||||
"subscription": MagicMock(),
|
||||
"transaction": MagicMock(),
|
||||
"was_trial_conversion": False,
|
||||
"message": "🎉 Subscription purchased",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService",
|
||||
lambda: DummyMiniAppService(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
delete_cart_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
delete_cart_mock,
|
||||
)
|
||||
clear_draft_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
clear_draft_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_purchase_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
bot = AsyncMock()
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
assert result is True
|
||||
delete_cart_mock.assert_awaited_once_with(user.id)
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
bot.send_message.assert_awaited()
|
||||
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 99
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow()
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 100
|
||||
subscription.connected_squads = ["squad-a"]
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 7
|
||||
user.telegram_id = 7007
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 31_000,
|
||||
"description": "Продление подписки на 30 дней",
|
||||
"device_limit": 2,
|
||||
"traffic_limit_gb": 500,
|
||||
"squad_uuid": "squad-b",
|
||||
"consume_promo_offer": True,
|
||||
}
|
||||
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
async def extend_stub(db, current_subscription, days):
|
||||
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
|
||||
return current_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_stub,
|
||||
)
|
||||
|
||||
create_transaction_mock = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.create_transaction",
|
||||
create_transaction_mock,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.update_remnawave_user = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionService",
|
||||
lambda: service_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
delete_cart_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
delete_cart_mock,
|
||||
)
|
||||
clear_draft_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
clear_draft_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_extension_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
bot = AsyncMock()
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
assert result is True
|
||||
subtract_mock.assert_awaited_once_with(
|
||||
db_session,
|
||||
user,
|
||||
cart_data["total_price"],
|
||||
cart_data["description"],
|
||||
consume_promo_offer=True,
|
||||
)
|
||||
assert subscription.device_limit == 2
|
||||
assert subscription.traffic_limit_gb == 500
|
||||
assert "squad-b" in subscription.connected_squads
|
||||
delete_cart_mock.assert_awaited_once_with(user.id)
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
admin_service_mock.send_subscription_extension_notification.assert_awaited()
|
||||
bot.send_message.assert_awaited()
|
||||
service_mock.update_remnawave_user.assert_awaited()
|
||||
create_transaction_mock.assert_awaited()
|
||||
@@ -4,6 +4,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, User as TgUser, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart
|
||||
from app.handlers.subscription.autopay import handle_subscription_cancel
|
||||
from app.database.models import User, Subscription
|
||||
|
||||
@pytest.fixture
|
||||
@@ -207,4 +208,26 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock
|
||||
mock_state.clear.assert_called_once()
|
||||
|
||||
# Проверяем, что вызван answer
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Отмена покупки должна очищать сохраненную корзину"""
|
||||
mock_clear_draft = AsyncMock()
|
||||
mock_show_main_menu = AsyncMock()
|
||||
|
||||
with patch('app.handlers.subscription.autopay.user_cart_service') as mock_cart_service, \
|
||||
patch('app.handlers.subscription.autopay.clear_subscription_checkout_draft', new=mock_clear_draft), \
|
||||
patch('app.localization.texts.get_texts', return_value=MagicMock()) as _, \
|
||||
patch('app.handlers.menu.show_main_menu', new=mock_show_main_menu):
|
||||
|
||||
mock_cart_service.delete_user_cart = AsyncMock(return_value=True)
|
||||
|
||||
await handle_subscription_cancel(mock_callback_query, mock_state, mock_user, mock_db)
|
||||
|
||||
mock_state.clear.assert_called_once()
|
||||
mock_clear_draft.assert_awaited_once_with(mock_user.id)
|
||||
mock_cart_service.delete_user_cart.assert_awaited_once_with(mock_user.id)
|
||||
mock_show_main_menu.assert_awaited_once_with(mock_callback_query, mock_user, mock_db)
|
||||
mock_callback_query.answer.assert_called_once_with("❌ Покупка отменена")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user