Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4e1544fc8 | |||
| 6db5cae013 | |||
| 61089a9f30 | |||
| 3ad1f0fc97 | |||
| 67ac3c933f | |||
| d6f4751274 | |||
| 6fdb02af10 | |||
| ce456c4f7a | |||
| 314b17ce79 | |||
| 598d9ad0a1 | |||
| 734711dab7 | |||
| c4c5d6a018 | |||
| 883cd5a952 | |||
| 8cf7445b99 | |||
| 4c11af4644 | |||
| 47036d5616 | |||
| 025330e052 | |||
| 8d18c78fb2 | |||
| d93a2a0682 | |||
| 0c93516056 | |||
| bf126bdadb | |||
| d421d43522 | |||
| 29cda4dcd7 | |||
| dde90f9ca4 | |||
| 8415c68063 | |||
| 9edde19b8a | |||
| 83a473606f | |||
| 849f06ad2b | |||
| e27e1299f7 | |||
| 03a45e39d4 | |||
| 4663464ea4 | |||
| 338b98bae3 | |||
| 7326d64fbe | |||
| bee94bcad0 | |||
| 414b796700 | |||
| f76abce939 | |||
| 99460665f1 | |||
| 730a6ae185 | |||
| ab8a5ca336 | |||
| d1f0cf5854 | |||
| 1f3c85e182 | |||
| bfcfe0de3d | |||
| f55f745080 | |||
| a26524e6ea | |||
| b5f57d4d0e | |||
| 5aa9c1316e | |||
| 524b4f5e64 | |||
| a2bb0b8341 | |||
| 9cabbf72d2 | |||
| 5f368f84ce | |||
| ed01d2bc6f | |||
| 7107070624 | |||
| c361f0c1d4 | |||
| ab95bc2192 | |||
| bf6a68ae27 | |||
| 1083651591 | |||
| b35fb824ec | |||
| ad839fb63d | |||
| 377a731ca2 | |||
| 3395021fcf | |||
| a4ce1a83be | |||
| 8b2f9e1803 | |||
| a40c07a6af | |||
| 2dbc23fa80 | |||
| 233401c057 | |||
| 66f17f90fd | |||
| 9748cc6b3e | |||
| bcab53cfe3 | |||
| 1af387de2b | |||
| 25c54c588b | |||
| 6ba7c6ed5e | |||
| 222c1a488f | |||
| c1a92adf41 | |||
| 972bb18344 | |||
| de11de4cd0 | |||
| f8c3daac23 | |||
| 91a3b14a1f | |||
| d310896168 | |||
| d4df56b173 | |||
| 3eab0b3848 | |||
| 609a557502 | |||
| 6a4ebad89f | |||
| e229b625f6 | |||
| 3ff5dec4d3 | |||
| 6dfd7db115 | |||
| 782dff4f82 | |||
| 1540d2665f | |||
| 567d0c1a8d | |||
| 6b031d4e9c | |||
| 72046d1ccf | |||
| d41af2cd85 | |||
| 174647c963 | |||
| e5e52b839c | |||
| ab19becdc8 | |||
| f5e9a6f2b9 | |||
| b492437b54 | |||
| 07546da0fd | |||
| fc4423d9de | |||
| c9a0f59e28 | |||
| 2d31bc6f23 | |||
| dac2e1c85f | |||
| f4d9b0d219 | |||
| 9b485bf822 | |||
| af3e8eab4e | |||
| 63d7500398 | |||
| 545f52f207 | |||
| 7fbb4cf2da |
+15
-3
@@ -113,12 +113,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
|
||||
# Лимит трафика в ГБ (0 — безлимит)
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
|
||||
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
|
||||
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_30_DAYS=1000
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
@@ -130,10 +142,10 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
PRICE_PER_DEVICE=10000
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
|
||||
@@ -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.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-$(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.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-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.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-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.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.2"
|
||||
ARG VERSION="v2.5.3"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
)
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
@@ -162,7 +163,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
|
||||
@@ -182,6 +182,13 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
@@ -205,6 +212,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_SHOP_ID: Optional[int] = None
|
||||
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
|
||||
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
|
||||
MULENPAY_DISPLAY_NAME: str = "Mulen Pay"
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
@@ -245,6 +253,11 @@ class Settings(BaseSettings):
|
||||
WATA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
WATA_REQUEST_TIMEOUT: int = 30
|
||||
WATA_WEBHOOK_PATH: str = "/wata-webhook"
|
||||
WATA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
WATA_WEBHOOK_PORT: int = 8085
|
||||
WATA_PUBLIC_KEY_URL: Optional[str] = None
|
||||
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
@@ -781,6 +794,15 @@ class Settings(BaseSettings):
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def get_mulenpay_display_name(self) -> str:
|
||||
name = (self.MULENPAY_DISPLAY_NAME or "").strip()
|
||||
if not name:
|
||||
return "Mulen Pay"
|
||||
return name
|
||||
|
||||
def get_mulenpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_mulenpay_display_name())
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +41,8 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -87,6 +88,7 @@ async def update_pal24_payment_status(
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
@@ -103,6 +105,8 @@ async def update_pal24_payment_status(
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if paid_at is not None:
|
||||
update_values["paid_at"] = paid_at
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
|
||||
@@ -30,6 +30,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
return subscription
|
||||
@@ -149,7 +150,7 @@ async def create_paid_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
@@ -223,6 +224,9 @@ async def extend_subscription(
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
@@ -234,6 +238,12 @@ async def extend_subscription(
|
||||
subscription.id,
|
||||
previous_status,
|
||||
)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.warning(
|
||||
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
|
||||
subscription.id,
|
||||
days
|
||||
)
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
@@ -1117,6 +1127,8 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
@@ -1127,6 +1139,8 @@ async def check_and_update_subscription_status(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
elif subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1183,3 +1197,132 @@ async def create_subscription(
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=False,
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def activate_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int = None
|
||||
) -> Optional[Subscription]:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
from sqlalchemy import and_
|
||||
|
||||
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
if not pending_subscription:
|
||||
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
|
||||
return None
|
||||
|
||||
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
pending_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
if pending_subscription.end_date <= current_time:
|
||||
# Если текущая дата окончания уже прошла, устанавливаем новую
|
||||
pending_subscription.end_date = current_time + timedelta(days=period_days)
|
||||
else:
|
||||
# Если дата окончания в будущем, продляем её
|
||||
pending_subscription.end_date = pending_subscription.end_date + timedelta(days=period_days)
|
||||
|
||||
# Обновляем дату начала, если она не установлена или в прошлом
|
||||
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
|
||||
pending_subscription.start_date = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(pending_subscription)
|
||||
|
||||
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
|
||||
|
||||
return pending_subscription
|
||||
|
||||
@@ -274,6 +274,30 @@ class TicketCRUD:
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def close_all_open_tickets(
|
||||
db: AsyncSession,
|
||||
) -> List[int]:
|
||||
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
|
||||
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
|
||||
result = await db.execute(
|
||||
select(Ticket.id).where(Ticket.status.in_(open_statuses))
|
||||
)
|
||||
ticket_ids = result.scalars().all()
|
||||
|
||||
if not ticket_ids:
|
||||
return []
|
||||
|
||||
now = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(Ticket)
|
||||
.where(Ticket.id.in_(ticket_ids))
|
||||
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ticket_ids
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -54,6 +54,7 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
|
||||
@@ -3427,18 +3427,19 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name)
|
||||
mulenpay_created = await create_mulenpay_payments_table()
|
||||
if mulenpay_created:
|
||||
logger.info("✅ Таблица Mulen Pay payments готова")
|
||||
logger.info("✅ Таблица %s payments готова", mulenpay_name)
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
|
||||
logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name)
|
||||
|
||||
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
|
||||
if mulenpay_schema_ok:
|
||||
logger.info("✅ Схема Mulen Pay payments актуальна")
|
||||
logger.info("✅ Схема %s payments актуальна", mulenpay_name)
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
|
||||
logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name)
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
|
||||
pal24_created = await create_pal24_payments_table()
|
||||
|
||||
Vendored
+12
-5
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -13,7 +14,7 @@ from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
@@ -56,6 +57,8 @@ def create_pal24_flask_app(
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
@@ -68,15 +71,19 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async for db in get_db():
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result()
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({"status": "error", "reason": "timeout"}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
Vendored
+262
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WataPublicKeyProvider:
|
||||
"""Loads and caches the WATA public key used for webhook signature validation."""
|
||||
|
||||
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
|
||||
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
|
||||
self._cached_key: Optional[str] = None
|
||||
self._expires_at: Optional[datetime] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_public_key(self) -> Optional[str]:
|
||||
"""Returns a cached public key or fetches a new one from WATA."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
async with self._lock:
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
key = await self._fetch_public_key()
|
||||
if key:
|
||||
self._cached_key = key
|
||||
if self._cache_seconds > 0:
|
||||
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
|
||||
else:
|
||||
self._expires_at = None
|
||||
logger.debug("Получен и закеширован публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
if self._cached_key:
|
||||
logger.warning("Используем ранее закешированный публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
logger.error("Публичный ключ WATA недоступен")
|
||||
return None
|
||||
|
||||
async def _fetch_public_key(self) -> Optional[str]:
|
||||
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
|
||||
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Ошибка получения публичного ключа WATA %s: %s",
|
||||
response.status,
|
||||
text,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = await response.json()
|
||||
except aiohttp.ContentTypeError:
|
||||
logger.error("Ответ WATA public-key не является JSON: %s", text)
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("value")
|
||||
if value:
|
||||
return value
|
||||
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
|
||||
else:
|
||||
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataWebhookHandler:
|
||||
"""Processes webhook callbacks coming from WATA."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_service: PaymentService,
|
||||
*,
|
||||
public_key_provider: Optional[WataPublicKeyProvider] = None,
|
||||
) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
|
||||
|
||||
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
|
||||
signature = (signature or "").strip()
|
||||
if not signature:
|
||||
logger.error("WATA webhook без подписи")
|
||||
return False
|
||||
|
||||
public_key_pem = await self.public_key_provider.get_public_key()
|
||||
if not public_key_pem:
|
||||
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
|
||||
return False
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(signature)
|
||||
except (ValueError, TypeError):
|
||||
logger.error("Некорректная подпись WATA (не Base64)")
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
|
||||
except ValueError as error:
|
||||
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
logger.warning("Подпись WATA webhook не прошла проверку")
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error("Ошибка проверки подписи WATA: %s", error)
|
||||
return False
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.warning("Получен WATA webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
|
||||
|
||||
raw_body = await request.text()
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
signature = request.headers.get("X-Signature")
|
||||
if not await self._verify_signature(raw_body, signature or ""):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
logger.info(
|
||||
"Получен WATA webhook: order_id=%s, status=%s",
|
||||
payload.get("orderId"),
|
||||
payload.get("transactionStatus"),
|
||||
)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_wata_webhook(db, payload)
|
||||
# Allow the generator to finish naturally so it can commit/rollback.
|
||||
# get_db() yields only once, so exiting the loop body without breaking
|
||||
# triggers the generator cleanup logic on the next iteration attempt.
|
||||
|
||||
if processed is None:
|
||||
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
|
||||
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "wata_webhook",
|
||||
"enabled": settings.is_wata_enabled(),
|
||||
"path": settings.WATA_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
app = web.Application()
|
||||
handler = WataWebhookHandler(payment_service)
|
||||
|
||||
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
|
||||
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
|
||||
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
|
||||
logger.info(
|
||||
"Настроен WATA webhook endpoint на %s",
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.info("WATA отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_wata_webhook_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.WATA_WEBHOOK_HOST,
|
||||
port=settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"WATA webhook сервер запущен на %s:%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"WATA webhook URL: http://%s:%s%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("WATA webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("WATA webhook сервер корректно остановлен")
|
||||
Vendored
+44
-15
@@ -48,7 +48,12 @@ class WebhookServer:
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
" - %s webhook: POST %s",
|
||||
mulenpay_name,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
@@ -84,8 +89,10 @@ class WebhookServer:
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"Mulen Pay webhook URL: http://%s:%s%s",
|
||||
"%s webhook URL: http://%s:%s%s",
|
||||
mulenpay_name,
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
@@ -128,24 +135,37 @@ class WebhookServer:
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"%s webhook: %s %s",
|
||||
mulenpay_name,
|
||||
request.method,
|
||||
request.path,
|
||||
)
|
||||
logger.info(
|
||||
"%s webhook headers: %s",
|
||||
mulenpay_name,
|
||||
dict(request.headers),
|
||||
)
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
logger.warning("Пустой %s webhook", mulenpay_name)
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
|
||||
logger.warning(
|
||||
"%s webhook signature verification failed, but processing anyway for debugging",
|
||||
mulenpay_name,
|
||||
)
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
|
||||
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
@@ -160,7 +180,10 @@ class WebhookServer:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
logger.error(
|
||||
f"Ошибка обработки {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
@@ -169,7 +192,11 @@ class WebhookServer:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.error(
|
||||
f"Критическая ошибка {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
@@ -183,12 +210,13 @@ class WebhookServer:
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
if not secret_key:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
logger.error("%s secret key is not configured", display_name)
|
||||
return False
|
||||
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("MulenPay webhook headers for signature verification:")
|
||||
logger.info("%s webhook headers for signature verification:", display_name)
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
@@ -239,7 +267,7 @@ class WebhookServer:
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
logger.error("Неверная подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
@@ -252,7 +280,7 @@ class WebhookServer:
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
|
||||
logger.error("Неверный %s токен %s webhook", scheme, display_name)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
@@ -270,11 +298,12 @@ class WebhookServer:
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
"%s webhook headers received: %s",
|
||||
display_name,
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
logger.error("Отсутствует подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
@@ -1303,7 +1303,10 @@ def _build_settings_keyboard(
|
||||
label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")])
|
||||
elif category_key == "MULENPAY":
|
||||
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
|
||||
label = texts.t(
|
||||
"PAYMENT_CARD_MULENPAY",
|
||||
"💳 Банковская карта ({mulenpay_name})",
|
||||
).format(mulenpay_name=settings.get_mulenpay_display_name())
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
|
||||
elif category_key == "WATA":
|
||||
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
|
||||
@@ -1834,8 +1837,13 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
if method == "mulenpay":
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ MulenPay отключен", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ {mulenpay_name} отключен",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
amount_kopeks = 1 * 100
|
||||
@@ -1843,18 +1851,21 @@ async def test_payment_provider(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description="Тестовый платеж MulenPay (админ)",
|
||||
description=f"Тестовый платеж {mulenpay_name} (админ)",
|
||||
language=language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж MulenPay", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ Не удалось создать платеж {mulenpay_name}",
|
||||
show_alert=True,
|
||||
)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result["payment_url"]
|
||||
message_text = (
|
||||
"🧪 <b>Тестовый платеж MulenPay</b>\n\n"
|
||||
f"🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID: {payment_result['mulen_payment_id']}"
|
||||
)
|
||||
@@ -1875,7 +1886,10 @@ async def test_payment_provider(
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("✅ Ссылка на платеж MulenPay отправлена", show_alert=True)
|
||||
await callback.answer(
|
||||
f"✅ Ссылка на платеж {mulenpay_name} отправлена",
|
||||
show_alert=True,
|
||||
)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ async def show_support_audit(
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
@@ -215,6 +216,8 @@ async def show_support_audit(
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
elif log.action == 'close_all_tickets' and 'count' in details:
|
||||
extra = f" ({details['count']})"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
|
||||
@@ -461,6 +461,81 @@ async def mark_ticket_as_answered(
|
||||
)
|
||||
|
||||
|
||||
async def close_all_open_admin_tickets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Закрыть все открытые тикеты."""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db)
|
||||
except Exception as error:
|
||||
logger.error("Error closing all open tickets: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
closed_count = len(closed_ticket_ids)
|
||||
|
||||
if closed_count == 0:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
is_moderator = (
|
||||
not settings.is_admin(callback.from_user.id)
|
||||
and SupportSettingsService.is_moderator(callback.from_user.id)
|
||||
)
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=callback.from_user.id,
|
||||
is_moderator=is_moderator,
|
||||
action="close_all_tickets",
|
||||
ticket_id=None,
|
||||
target_user_id=None,
|
||||
details={
|
||||
"count": closed_count,
|
||||
"ticket_ids": closed_ticket_ids,
|
||||
}
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to add support audit for bulk close: %s", audit_error)
|
||||
|
||||
# Обновляем список тикетов
|
||||
await show_admin_tickets(callback, db_user, db)
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS",
|
||||
"✅ Закрыто открытых тикетов: {count}"
|
||||
).format(count=closed_count)
|
||||
|
||||
notification_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(success_text, reply_markup=notification_keyboard)
|
||||
except Exception:
|
||||
# Если не удалось отправить отдельное сообщение, пробуем ответить алертом
|
||||
try:
|
||||
await callback.answer(success_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def close_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -935,7 +1010,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
|
||||
|
||||
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
|
||||
+113
-1
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -1644,6 +1645,107 @@ async def start_balance_edit(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_send_user_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
target_user = await get_user_by_id(db, user_id)
|
||||
if not target_user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(direct_message_user_id=user_id)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = (
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_PROMPT",
|
||||
"✉️ <b>Отправка сообщения пользователю</b>\n\n"
|
||||
"Введите текст, который бот отправит пользователю."
|
||||
"\n\nВы можете отменить действие командой /cancel или кнопкой ниже." )
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")]
|
||||
]
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.sending_user_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_send_user_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
user_id = data.get("direct_message_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь для отправки сообщения не найден"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
target_user = await get_user_by_id(db, int(user_id))
|
||||
if not target_user:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь не найден или был удалён"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_EMPTY", "❌ Пожалуйста, введите непустое сообщение"))
|
||||
return
|
||||
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await message.bot.send_message(target_user.telegram_id, text)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_SUCCESS", "✅ Сообщение отправлено пользователю"),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_FORBIDDEN", "⚠️ Пользователь заблокировал бота или не может получить сообщения."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
except TelegramBadRequest as err:
|
||||
logger.error("Ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_BAD_REQUEST", "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
return
|
||||
except Exception as err:
|
||||
logger.error("Неожиданная ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_ERROR", "❌ Не удалось отправить сообщение. Попробуйте позже."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_balance_edit(
|
||||
@@ -4014,11 +4116,21 @@ def register_handlers(dp: Dispatcher):
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
)
|
||||
|
||||
|
||||
dp.message.register(
|
||||
process_balance_edit,
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_send_user_message,
|
||||
F.data.startswith("admin_user_send_message_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_send_user_message,
|
||||
AdminStates.sending_user_message
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_inactive_users,
|
||||
|
||||
@@ -511,10 +511,24 @@ async def handle_quick_amount_selection(
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
except ValueError:
|
||||
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
|
||||
except Exception as e:
|
||||
@@ -652,6 +666,11 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
from .pal24 import handle_pal24_method_selection
|
||||
dp.callback_query.register(
|
||||
handle_pal24_method_selection,
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
|
||||
@@ -21,19 +21,28 @@ async def start_mulenpay_payment(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} временно недоступна",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
message_template = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу Mulen Pay."
|
||||
"Оплата происходит через защищенную платформу {mulenpay_name}."
|
||||
),
|
||||
)
|
||||
message_text = message_template.format(
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
@@ -63,9 +72,11 @@ async def process_mulenpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
|
||||
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
@@ -96,8 +107,8 @@ async def process_mulenpay_payment_amount(
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
@@ -112,8 +123,8 @@ async def process_mulenpay_payment_amount(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через Mulen Pay",
|
||||
),
|
||||
"💳 Оплатить через {mulenpay_name}",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
@@ -132,11 +143,11 @@ async def process_mulenpay_payment_amount(
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через Mulen Pay'\n"
|
||||
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
@@ -148,6 +159,8 @@ async def process_mulenpay_payment_amount(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
@@ -159,19 +172,20 @@ async def process_mulenpay_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
|
||||
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
|
||||
mulenpay_name,
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -204,8 +218,9 @@ async def check_mulenpay_payment_status(
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:\n\n",
|
||||
f"💳 Статус платежа {mulenpay_name}:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
@@ -234,5 +249,7 @@ async def check_mulenpay_payment_status(
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
|
||||
logger.error(
|
||||
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
|
||||
)
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
+192
-74
@@ -1,10 +1,12 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
@@ -15,81 +17,27 @@ from app.states import BalanceStates
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
def _get_available_pal24_methods() -> list[str]:
|
||||
methods: list[str] = []
|
||||
if settings.is_pal24_sbp_button_visible():
|
||||
methods.append("sbp")
|
||||
if settings.is_pal24_card_button_visible():
|
||||
methods.append("card")
|
||||
if not methods:
|
||||
methods.append("sbp")
|
||||
return methods
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
async def _send_pal24_payment_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
payment_method: str,
|
||||
state: FSMContext,
|
||||
):
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
@@ -98,6 +46,7 @@ async def process_pal24_payment_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
@@ -262,14 +211,15 @@ async def process_pal24_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {error}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
@@ -278,6 +228,168 @@ async def process_pal24_payment_amount(
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ')
|
||||
)
|
||||
return
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
if len(available_methods) == 1:
|
||||
await _send_pal24_payment_message(
|
||||
message,
|
||||
db_user,
|
||||
db,
|
||||
amount_kopeks,
|
||||
available_methods[0],
|
||||
state,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(pal24_amount_kopeks=amount_kopeks)
|
||||
await state.set_state(BalanceStates.waiting_for_pal24_method)
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
if "sbp" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_sbp_button_text(
|
||||
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
|
||||
),
|
||||
callback_data="pal24_method_sbp",
|
||||
)
|
||||
]
|
||||
)
|
||||
if "card" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_card_button_text(
|
||||
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
|
||||
),
|
||||
callback_data="pal24_method_card",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты PayPalych:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_pal24_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
amount_kopeks = data.get("pal24_amount_kopeks")
|
||||
if not amount_kopeks:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
method = "sbp" if callback.data.endswith("_sbp") else "card"
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _send_pal24_payment_message(
|
||||
callback.message,
|
||||
db_user,
|
||||
db,
|
||||
int(amount_kopeks),
|
||||
method,
|
||||
state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
@@ -353,11 +465,17 @@ async def check_pal24_payment_status(
|
||||
])
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
if "message is not modified" in str(error).lower():
|
||||
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
|
||||
@@ -246,34 +246,125 @@ async def process_yookassa_sbp_payment_amount(
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
|
||||
qr_confirmation_data = payment_result.get("qr_confirmation_data")
|
||||
|
||||
if not confirmation_url and not qr_confirmation_data:
|
||||
await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
# Подготовим QR-код для вставки в основное сообщение
|
||||
qr_photo = None
|
||||
if qr_confirmation_data:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Создаем QR-код из полученных данных
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(qr_confirmation_data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода: {e}")
|
||||
|
||||
await message.answer(
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
# Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL
|
||||
if not qr_photo and confirmation_url:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Создаем QR-код из URL
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(confirmation_url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода из URL: {e}")
|
||||
|
||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||
keyboard_buttons = []
|
||||
|
||||
# Добавляем кнопку оплаты, если доступна ссылка
|
||||
if confirmation_url:
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||
else:
|
||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||
|
||||
# Добавляем общие кнопки
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
message_text = (
|
||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
|
||||
f"2. Вас перенаправит в приложение вашего банка\n"
|
||||
f"3. Подтвердите платеж через СБП\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
message_text += (
|
||||
f"📱 <b>Инструкция по оплате:</b>\n"
|
||||
f"1. Откройте приложение вашего банка\n"
|
||||
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
|
||||
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
|
||||
f"4. Подтвердите платеж в приложении банка\n"
|
||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||
)
|
||||
|
||||
message_text += (
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
# Отправляем сообщение с инструкциями и клавиатурой
|
||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||
if qr_photo:
|
||||
# Используем метод отправки медиа-группы или фото с описанием
|
||||
await message.answer_photo(
|
||||
photo=qr_photo,
|
||||
caption=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
@@ -284,6 +375,9 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
+38
-28
@@ -153,7 +153,7 @@ async def show_main_menu(
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
@@ -892,7 +892,7 @@ async def handle_back_to_menu(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
has_active_subscription = db_user.subscription is not None
|
||||
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
@@ -946,64 +946,74 @@ async def handle_back_to_menu(
|
||||
await callback.answer()
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if actual_status == "pending":
|
||||
return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки")
|
||||
|
||||
if actual_status == "disabled":
|
||||
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
|
||||
|
||||
if actual_status == "expired":
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
).format(end_date=end_date_text)
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
end_date=end_date_text,
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left == 1:
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
else:
|
||||
if actual_status == "active":
|
||||
if days_left > 7:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
end_date=end_date_text,
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left > 1:
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
"💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
elif days_left == 1:
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TOMORROW",
|
||||
"💎 Активна\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно")
|
||||
|
||||
|
||||
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
"""Обработчики для простой покупки подписки."""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard, get_happ_download_button_row
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_purchase_service import SubscriptionPurchaseService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.subscription_utils import get_display_subscription_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_simple_subscription_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Начинает процесс простой покупки подписки."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя активная подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if current_subscription and current_subscription.is_active:
|
||||
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
|
||||
return
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
await state.update_data(subscription_params=subscription_params)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
period_days = subscription_params["period_days"]
|
||||
recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks)
|
||||
direct_purchase_min_balance = recorded_price
|
||||
extra_components = []
|
||||
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
|
||||
if traffic_limit and traffic_limit > 0:
|
||||
traffic_price = settings.get_traffic_price(traffic_limit)
|
||||
direct_purchase_min_balance += traffic_price
|
||||
extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}")
|
||||
|
||||
device_limit = subscription_params.get("device_limit", 1)
|
||||
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
direct_purchase_min_balance += devices_price
|
||||
extra_components.append(f"devices+{additional_devices}->{devices_price}")
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s",
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_kopeks,
|
||||
recorded_price,
|
||||
",".join(extra_components) if extra_components else "none",
|
||||
direct_purchase_min_balance,
|
||||
getattr(settings, "PRICE_30_DAYS", None),
|
||||
)
|
||||
|
||||
can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s",
|
||||
db_user.id,
|
||||
user_balance_kopeks,
|
||||
direct_purchase_min_balance,
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
+ (
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
if can_pay_from_balance
|
||||
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
|
||||
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
|
||||
if can_pay_from_balance:
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Оплатить с баланса",
|
||||
callback_data="simple_subscription_pay_with_balance",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.extend(methods_keyboard.inline_keyboard)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
period_days = params.get("period_days", 30)
|
||||
attr_name = f"PRICE_{period_days}_DAYS"
|
||||
attr_value = getattr(settings, attr_name, None)
|
||||
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s",
|
||||
period_days,
|
||||
attr_name,
|
||||
attr_value,
|
||||
settings.BASE_SUBSCRIPTION_PRICE,
|
||||
)
|
||||
|
||||
# Получаем цену для стандартного периода
|
||||
if attr_value is not None:
|
||||
return attr_value
|
||||
else:
|
||||
# Если нет цены для конкретного периода, используем базовую цену
|
||||
return settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для простой подписки."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Добавляем доступные методы оплаты
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="simple_subscription_stars"
|
||||
)])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_methods = []
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="🏦 YooKassa (СБП)",
|
||||
callback_data="simple_subscription_yookassa_sbp"
|
||||
))
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="💳 YooKassa (Карта)",
|
||||
callback_data="simple_subscription_yookassa"
|
||||
))
|
||||
if yookassa_methods:
|
||||
keyboard.append(yookassa_methods)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="🪙 CryptoBot",
|
||||
callback_data="simple_subscription_cryptobot"
|
||||
)])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=f"💳 {mulenpay_name}",
|
||||
callback_data="simple_subscription_mulenpay"
|
||||
)])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 PayPalych",
|
||||
callback_data="simple_subscription_pal24"
|
||||
)])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 WATA",
|
||||
callback_data="simple_subscription_wata"
|
||||
)])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_purchase"
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_simple_subscription_pay_with_balance(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает оплату простой подписки с баланса."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
subscription_params = data.get("subscription_params", {})
|
||||
|
||||
if not subscription_params:
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
|
||||
total_required = recorded_price
|
||||
extras = []
|
||||
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
|
||||
if traffic_limit and traffic_limit > 0:
|
||||
traffic_price = settings.get_traffic_price(traffic_limit)
|
||||
total_required += traffic_price
|
||||
extras.append(f"traffic={traffic_limit}GB->{traffic_price}")
|
||||
device_limit = subscription_params.get("device_limit", 1)
|
||||
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_required += devices_price
|
||||
extras.append(f"devices+{additional_devices}->{devices_price}")
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s",
|
||||
db_user.id,
|
||||
subscription_params["period_days"],
|
||||
price_kopeks,
|
||||
",".join(extras) if extras else "none",
|
||||
total_required,
|
||||
getattr(db_user, "balance_kopeks", 0),
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
|
||||
if user_balance_kopeks < total_required:
|
||||
await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
# Списываем средства с баланса пользователя
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
f"Оплата подписки на {subscription_params['period_days']} дней",
|
||||
consume_promo_offer=False,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя уже подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if existing_subscription:
|
||||
# Если подписка уже существует, продлеваем её
|
||||
subscription = await extend_subscription(
|
||||
db=db,
|
||||
subscription=existing_subscription,
|
||||
days=subscription_params["period_days"]
|
||||
)
|
||||
# Обновляем параметры подписки
|
||||
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
|
||||
subscription.device_limit = subscription_params["device_limit"]
|
||||
if subscription_params["squad_uuid"]:
|
||||
subscription.connected_squads = [subscription_params["squad_uuid"]]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
# Если подписки нет, создаём новую
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=subscription_params["period_days"],
|
||||
traffic_limit_gb=subscription_params["traffic_limit_gb"],
|
||||
device_limit=subscription_params["device_limit"],
|
||||
connected_squads=[subscription_params["squad_uuid"]] if subscription_params["squad_uuid"] else [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
# Возвращаем средства на баланс в случае ошибки
|
||||
from app.services.payment_service import add_user_balance
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней",
|
||||
)
|
||||
await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True)
|
||||
return
|
||||
|
||||
# Обновляем баланс пользователя
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Обновляем или создаём ссылку подписки в RemnaWave
|
||||
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:
|
||||
logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True)
|
||||
|
||||
# Отправляем уведомление об успешной покупке
|
||||
success_message = (
|
||||
f"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
|
||||
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
|
||||
)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться")
|
||||
|
||||
def _fallback_connect_button() -> types.InlineKeyboardButton:
|
||||
return types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
if subscription_link:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
web_app=types.WebAppInfo(url=subscription_link),
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
elif connect_mode == "miniapp_custom":
|
||||
custom_url = settings.MINIAPP_CUSTOM_URL
|
||||
if custom_url:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
web_app=types.WebAppInfo(url=custom_url),
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
elif connect_mode == "link":
|
||||
if subscription_link:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
url=subscription_link,
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
if subscription_link:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
|
||||
keyboard_rows = [connect_row]
|
||||
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard_rows.append(happ_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
None, # transaction
|
||||
subscription_params["period_days"],
|
||||
False, # was_trial_conversion
|
||||
amount_kopeks=price_kopeks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления админам о покупке: {e}")
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}₽")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка оплаты простой подписки с баланса для пользователя %s: %s",
|
||||
db_user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.answer(
|
||||
"❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.",
|
||||
show_alert=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_simple_subscription_pay_with_balance_disabled(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Показывает уведомление, если баланса недостаточно для прямой оплаты."""
|
||||
await callback.answer(
|
||||
"❌ Недостаточно средств на балансе. Пополните баланс или выберите другой способ оплаты.",
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_simple_subscription_other_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает выбор других способов оплаты."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
subscription_params = data.get("subscription_params", {})
|
||||
|
||||
if not subscription_params:
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
|
||||
total_required = recorded_price
|
||||
if subscription_params.get("traffic_limit_gb", 0) > 0:
|
||||
total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"])
|
||||
if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT
|
||||
total_required += additional_devices * settings.PRICE_PER_DEVICE
|
||||
can_pay_from_balance = user_balance_kopeks >= total_required
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s",
|
||||
db_user.id,
|
||||
user_balance_kopeks,
|
||||
price_kopeks,
|
||||
total_required,
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
# Отображаем доступные методы оплаты
|
||||
message_text = (
|
||||
f"💳 <b>Оплата подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
|
||||
+ (
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
|
||||
if can_pay_from_balance
|
||||
else "Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
|
||||
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
|
||||
if can_pay_from_balance:
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Оплатить с баланса",
|
||||
callback_data="simple_subscription_pay_with_balance"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.extend(base_keyboard.inline_keyboard)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_simple_subscription_payment_method(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает выбор метода оплаты для простой подписки."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
subscription_params = data.get("subscription_params", {})
|
||||
|
||||
if not subscription_params:
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
payment_method = callback.data.replace("simple_subscription_", "")
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
if payment_method == "stars":
|
||||
# Оплата через Telegram Stars
|
||||
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']} дней",
|
||||
description=(
|
||||
f"Простая покупка подписки\n"
|
||||
f"Период: {subscription_params['period_days']} дней\n"
|
||||
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']}",
|
||||
provider_token="", # Пустой токен для Telegram Stars
|
||||
currency="XTR", # Telegram Stars
|
||||
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
elif payment_method in ["yookassa", "yookassa_sbp"]:
|
||||
# Оплата через YooKassa
|
||||
if not settings.is_yookassa_enabled():
|
||||
await callback.answer("❌ Оплата через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if payment_method == "yookassa_sbp" and not settings.YOOKASSA_SBP_ENABLED:
|
||||
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Создаем заказ на подписку
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
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=subscription_params["squad_uuid"],
|
||||
payment_method="yookassa_sbp" if payment_method == "yookassa_sbp" else "yookassa",
|
||||
total_price_kopeks=price_kopeks
|
||||
)
|
||||
|
||||
if not order:
|
||||
await callback.answer("❌ Ошибка создания заказа", show_alert=True)
|
||||
return
|
||||
|
||||
# Создаем платеж через YooKassa
|
||||
if payment_method == "yookassa_sbp":
|
||||
payment_result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f"Оплата подписки на {subscription_params['period_days']} дней",
|
||||
receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None,
|
||||
receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"order_id": str(order.id),
|
||||
"subscription_period": str(subscription_params["period_days"]),
|
||||
"payment_purpose": "simple_subscription_purchase"
|
||||
}
|
||||
)
|
||||
else:
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f"Оплата подписки на {subscription_params['period_days']} дней",
|
||||
receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None,
|
||||
receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"order_id": str(order.id),
|
||||
"subscription_period": str(subscription_params["period_days"]),
|
||||
"payment_purpose": "simple_subscription_purchase"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
# Отправляем QR-код и/или ссылку для оплаты
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
qr_confirmation_data = payment_result.get("qr_confirmation_data")
|
||||
|
||||
if not confirmation_url and not qr_confirmation_data:
|
||||
await callback.answer("❌ Ошибка получения данных для оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
# Подготовим QR-код для вставки в основное сообщение
|
||||
qr_photo = None
|
||||
if qr_confirmation_data or confirmation_url:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Используем qr_confirmation_data если доступно, иначе confirmation_url
|
||||
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url
|
||||
|
||||
# Создаем QR-код из полученных данных
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода: {e}")
|
||||
|
||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||
keyboard_buttons = []
|
||||
|
||||
# Добавляем кнопку оплаты, если доступна ссылка
|
||||
if confirmation_url:
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||
else:
|
||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||
|
||||
# Добавляем общие кнопки
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
message_text = (
|
||||
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
)
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
message_text += (
|
||||
f"📱 <b>Инструкция по оплате:</b>\n"
|
||||
f"1. Откройте приложение вашего банка\n"
|
||||
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
|
||||
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
|
||||
f"4. Подтвердите платеж в приложении банка\n"
|
||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||
)
|
||||
|
||||
message_text += (
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
# Отправляем сообщение с инструкциями и клавиатурой
|
||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||
if qr_photo:
|
||||
# Используем метод отправки фото с описанием
|
||||
await callback.message.edit_media(
|
||||
media=types.InputMediaPhoto(
|
||||
media=qr_photo,
|
||||
caption=message_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
elif payment_method == "cryptobot":
|
||||
# Оплата через CryptoBot
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата через CryptoBot временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через CryptoBot
|
||||
await callback.answer("❌ Оплата через CryptoBot пока не реализована", show_alert=True)
|
||||
|
||||
elif payment_method == "mulenpay":
|
||||
# Оплата через MulenPay
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} временно недоступна",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через MulenPay
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} пока не реализована",
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
elif payment_method == "pal24":
|
||||
# Оплата через PayPalych
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через PayPalych
|
||||
await callback.answer("❌ Оплата через PayPalych пока не реализована", show_alert=True)
|
||||
|
||||
elif payment_method == "wata":
|
||||
# Оплата через WATA
|
||||
if not settings.is_wata_enabled():
|
||||
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Здесь должна быть реализация оплаты через WATA
|
||||
await callback.answer("❌ Оплата через WATA пока не реализована", show_alert=True)
|
||||
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки метода оплаты простой подписки: {e}")
|
||||
await callback.answer("❌ Ошибка обработки запроса. Попробуйте позже или обратитесь в поддержку.", show_alert=True)
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_simple_subscription_handlers(dp):
|
||||
"""Регистрирует обработчики простой покупки подписки."""
|
||||
|
||||
dp.callback_query.register(
|
||||
start_simple_subscription_purchase,
|
||||
F.data == "simple_subscription_purchase"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_pay_with_balance,
|
||||
F.data == "simple_subscription_pay_with_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_pay_with_balance_disabled,
|
||||
F.data == "simple_subscription_pay_with_balance_disabled"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_other_payment_methods,
|
||||
F.data == "simple_subscription_other_payment_methods"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_payment_method,
|
||||
F.data.startswith("simple_subscription_")
|
||||
)
|
||||
@@ -527,9 +527,22 @@ async def start_subscription_purchase(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Если включена простая покупка, показываем дополнительную кнопку
|
||||
keyboard = get_subscription_period_keyboard(db_user.language)
|
||||
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
# Добавляем кнопку простой подписки в начало клавиатуры
|
||||
simple_subscription_button = [types.InlineKeyboardButton(
|
||||
text="⚡ Простая покупка",
|
||||
callback_data="simple_subscription_purchase"
|
||||
)]
|
||||
|
||||
# Вставляем кнопку в начало списка кнопок
|
||||
keyboard.inline_keyboard.insert(0, simple_subscription_button)
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@@ -1999,7 +2012,7 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
start_subscription_purchase,
|
||||
F.data.in_(["menu_buy", "subscription_upgrade"])
|
||||
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -2264,3 +2277,163 @@ def register_handlers(dp: Dispatcher):
|
||||
show_device_connection_help,
|
||||
F.data == "device_connection_help"
|
||||
)
|
||||
|
||||
# Регистрируем обработчик для простой покупки
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_purchase,
|
||||
F.data == "simple_subscription_purchase"
|
||||
)
|
||||
|
||||
|
||||
async def handle_simple_subscription_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает простую покупку подписки."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя активная подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if current_subscription and current_subscription.is_active:
|
||||
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
|
||||
return
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
await state.update_data(subscription_params=subscription_params)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
if user_balance_kopeks >= price_kopeks:
|
||||
# Если баланс достаточный, предлагаем оплатить с баланса
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
|
||||
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")]
|
||||
])
|
||||
else:
|
||||
# Если баланс недостаточный, предлагаем внешние способы оплаты
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
f"Выберите способ оплаты:"
|
||||
)
|
||||
|
||||
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
period_days = params.get("period_days", 30)
|
||||
|
||||
# Получаем цену для стандартного периода
|
||||
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
|
||||
return getattr(settings, f'PRICE_{period_days}_DAYS')
|
||||
else:
|
||||
# Если нет цены для конкретного периода, используем базовую цену
|
||||
return settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для простой подписки."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Добавляем доступные методы оплаты
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="simple_subscription_stars"
|
||||
)])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_methods = []
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="🏦 YooKassa (СБП)",
|
||||
callback_data="simple_subscription_yookassa_sbp"
|
||||
))
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="💳 YooKassa (Карта)",
|
||||
callback_data="simple_subscription_yookassa"
|
||||
))
|
||||
if yookassa_methods:
|
||||
keyboard.append(yookassa_methods)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="🪙 CryptoBot",
|
||||
callback_data="simple_subscription_cryptobot"
|
||||
)])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=f"💳 {mulenpay_name}",
|
||||
callback_data="simple_subscription_mulenpay"
|
||||
)])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 PayPalych",
|
||||
callback_data="simple_subscription_pal24"
|
||||
)])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 WATA",
|
||||
callback_data="simple_subscription_wata"
|
||||
)])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_purchase"
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -762,6 +762,13 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
]
|
||||
]
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"),
|
||||
callback_data=f"admin_user_send_message_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if user_status == "active":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
|
||||
+24
-2
@@ -337,12 +337,22 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
|
||||
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
|
||||
)
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
else:
|
||||
elif len(subscription_buttons) == 1:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
elif len(subscription_buttons) > 2:
|
||||
# Если больше 2 кнопок, добавляем по отдельности
|
||||
for button in subscription_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
keyboard.append([
|
||||
@@ -870,6 +880,8 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Простая покупка" была убрана из выбора периода подписки
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
@@ -1089,9 +1101,13 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
|
||||
text=texts.t(
|
||||
"PAYMENT_CARD_MULENPAY",
|
||||
"💳 Банковская карта ({mulenpay_name})",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
callback_data=_build_callback("mulenpay")
|
||||
)
|
||||
])
|
||||
@@ -2300,6 +2316,12 @@ def get_admin_tickets_keyboard(
|
||||
keyboard.append(switch_row)
|
||||
|
||||
if open_rows and scope in ("all", "open"):
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"),
|
||||
callback_data="admin_tickets_close_all_open"
|
||||
)
|
||||
])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
|
||||
keyboard.extend(open_rows)
|
||||
if closed_rows and scope in ("all", "closed"):
|
||||
|
||||
+68
-10
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
@@ -161,6 +163,41 @@ def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return normalized
|
||||
|
||||
|
||||
def _directory_is_writable(directory: Path) -> bool:
|
||||
try:
|
||||
current_user = f"{os.geteuid()}:{os.getegid()}"
|
||||
user_hint = f" (running as UID:GID {current_user})"
|
||||
except Exception: # pragma: no cover - best effort only
|
||||
user_hint = ""
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=directory, prefix=".locale_write_test_", delete=True):
|
||||
pass
|
||||
return True
|
||||
except PermissionError as error:
|
||||
_logger.warning(
|
||||
"Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
except OSError as error:
|
||||
_logger.warning(
|
||||
"Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning(
|
||||
"Unexpected error while checking locale directory %s%s: %s",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_locale_templates() -> None:
|
||||
destination = _resolve_user_locales_dir()
|
||||
try:
|
||||
@@ -169,27 +206,48 @@ def ensure_locale_templates() -> None:
|
||||
_logger.warning("Unable to create locales directory %s: %s", destination, error)
|
||||
return
|
||||
|
||||
if any(destination.glob("*")):
|
||||
return
|
||||
|
||||
if not _DEFAULT_LOCALES_DIR.exists():
|
||||
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
|
||||
return
|
||||
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
target_path = destination / template.name
|
||||
if not _directory_is_writable(destination):
|
||||
return
|
||||
|
||||
destination_has_files = any(destination.glob("*"))
|
||||
|
||||
def _copy_locale(source: Path, target: Path) -> None:
|
||||
try:
|
||||
shutil.copyfile(template, target_path)
|
||||
shutil.copyfile(source, target)
|
||||
except Exception as error:
|
||||
_logger.warning(
|
||||
"Failed to copy default locale %s to %s: %s",
|
||||
template,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
|
||||
if not destination_has_files:
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ("ru", "en"):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f"{locale_code}.json"
|
||||
target_path = destination / f"{locale_code}.json"
|
||||
|
||||
if target_path.exists():
|
||||
continue
|
||||
|
||||
if not source_path.exists():
|
||||
_logger.debug(
|
||||
"Default locale template %s is missing at %s", locale_code, source_path
|
||||
)
|
||||
continue
|
||||
|
||||
_copy_locale(source_path, target_path)
|
||||
|
||||
|
||||
def _load_default_locale(language: str) -> Dict[str, Any]:
|
||||
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
|
||||
|
||||
+1215
-666
File diff suppressed because it is too large
Load Diff
+1108
-593
File diff suppressed because it is too large
Load Diff
@@ -217,9 +217,10 @@ class AdminNotificationService:
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
transaction: Optional[Transaction],
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
@@ -235,11 +236,14 @@ class AdminNotificationService:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -250,9 +254,9 @@ class AdminNotificationService:
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💵 Сумма: {settings.format_price(total_amount)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
🆔 ID транзакции: {transaction_id}
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
@@ -798,11 +802,12 @@ class AdminNotificationService:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
method_names = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'mulenpay': f'💳 {mulenpay_name} (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
|
||||
@@ -28,13 +28,16 @@ class MulenPayPaymentMixin:
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
display_name_html = settings.get_mulenpay_display_name_html()
|
||||
if not getattr(self, "mulenpay_service", None):
|
||||
logger.error("MulenPay сервис не инициализирован")
|
||||
logger.error("%s сервис не инициализирован", display_name)
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay меньше минимальной: %s < %s",
|
||||
"Сумма %s меньше минимальной: %s < %s",
|
||||
display_name,
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
@@ -42,7 +45,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay больше максимальной: %s > %s",
|
||||
"Сумма %s больше максимальной: %s > %s",
|
||||
display_name,
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
@@ -74,7 +78,7 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.error("Ошибка создания MulenPay платежа")
|
||||
logger.error("Ошибка создания %s платежа", display_name)
|
||||
return None
|
||||
|
||||
mulen_payment_id = response.get("id")
|
||||
@@ -100,7 +104,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
|
||||
"Создан %s платеж %s на %s₽ для пользователя %s",
|
||||
display_name,
|
||||
mulen_payment_id,
|
||||
amount_rubles,
|
||||
user_id,
|
||||
@@ -116,7 +121,7 @@ class MulenPayPaymentMixin:
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания MulenPay платежа: %s", error)
|
||||
logger.error("Ошибка создания %s платежа: %s", display_name, error)
|
||||
return None
|
||||
|
||||
async def process_mulenpay_callback(
|
||||
@@ -125,10 +130,17 @@ class MulenPayPaymentMixin:
|
||||
callback_data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
display_name_html = settings.get_mulenpay_display_name_html()
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
uuid_value = callback_data.get("uuid")
|
||||
payment_status = (callback_data.get("payment_status") or "").lower()
|
||||
payment_status_raw = (
|
||||
callback_data.get("payment_status")
|
||||
or callback_data.get("status")
|
||||
or callback_data.get("paymentStatus")
|
||||
)
|
||||
payment_status = (payment_status_raw or "").lower()
|
||||
mulen_payment_id_raw = callback_data.get("id")
|
||||
mulen_payment_id_int: Optional[int] = None
|
||||
if mulen_payment_id_raw is not None:
|
||||
@@ -138,14 +150,15 @@ class MulenPayPaymentMixin:
|
||||
mulen_payment_id_int = None
|
||||
amount_value = callback_data.get("amount")
|
||||
logger.debug(
|
||||
"MulenPay callback: uuid=%s, status=%s, amount=%s",
|
||||
"%s callback: uuid=%s, status=%s, amount=%s",
|
||||
display_name,
|
||||
uuid_value,
|
||||
payment_status,
|
||||
amount_value,
|
||||
)
|
||||
|
||||
if not uuid_value and mulen_payment_id_raw is None:
|
||||
logger.error("MulenPay callback без uuid и id")
|
||||
logger.error("%s callback без uuid и id", display_name)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
@@ -159,7 +172,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"MulenPay платеж не найден (uuid=%s, id=%s)",
|
||||
"%s платеж не найден (uuid=%s, id=%s)",
|
||||
display_name,
|
||||
uuid_value,
|
||||
mulen_payment_id_raw,
|
||||
)
|
||||
@@ -167,7 +181,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"MulenPay платеж %s уже обработан, игнорируем повторный callback",
|
||||
"%s платеж %s уже обработан, игнорируем повторный callback",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
@@ -183,7 +198,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Для MulenPay платежа %s уже создана транзакция",
|
||||
"Для %s платежа %s уже создана транзакция",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
@@ -199,7 +215,7 @@ class MulenPayPaymentMixin:
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через MulenPay: {payment_description}",
|
||||
description=f"Пополнение через {display_name}: {payment_description}",
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=payment.uuid,
|
||||
is_completed=True,
|
||||
@@ -214,8 +230,9 @@ class MulenPayPaymentMixin:
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден при обработке MulenPay",
|
||||
"Пользователь %s не найден при обработке %s",
|
||||
payment.user_id,
|
||||
display_name,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -226,7 +243,7 @@ class MulenPayPaymentMixin:
|
||||
db,
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение MulenPay: {payment.amount_kopeks // 100}₽",
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
@@ -261,7 +278,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении MulenPay: %s",
|
||||
"Ошибка отправки уведомления о пополнении %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -273,7 +291,7 @@ class MulenPayPaymentMixin:
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: Mulen Pay\n"
|
||||
f"🦊 Способ: {display_name_html}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
@@ -282,7 +300,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю MulenPay: %s",
|
||||
"Ошибка отправки уведомления пользователю %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -329,7 +348,8 @@ class MulenPayPaymentMixin:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
"✅ Обработан %s платеж %s для пользователя %s",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
payment.user_id,
|
||||
)
|
||||
@@ -343,7 +363,7 @@ class MulenPayPaymentMixin:
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info("MulenPay платеж %s отменен", payment.uuid)
|
||||
logger.info("%s платеж %s отменен", display_name, payment.uuid)
|
||||
return True
|
||||
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
@@ -354,14 +374,20 @@ class MulenPayPaymentMixin:
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info(
|
||||
"Получен MulenPay callback со статусом %s для платежа %s",
|
||||
"Получен %s callback со статусом %s для платежа %s",
|
||||
display_name,
|
||||
payment_status,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки MulenPay callback: %s", error, exc_info=True)
|
||||
logger.error(
|
||||
"Ошибка обработки %s callback: %s",
|
||||
display_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
|
||||
@@ -383,6 +409,7 @@ class MulenPayPaymentMixin:
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -437,6 +464,9 @@ class MulenPayPaymentMixin:
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка получения статуса MulenPay: %s", error, exc_info=True
|
||||
"Ошибка получения статуса %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
+367
-166
@@ -63,7 +63,8 @@ class Pal24PaymentMixin:
|
||||
"language": language,
|
||||
}
|
||||
|
||||
normalized_payment_method = (payment_method or "SBP").upper()
|
||||
normalized_payment_method = self._normalize_payment_method(payment_method)
|
||||
api_payment_method = self._map_api_payment_method(normalized_payment_method)
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -76,7 +77,7 @@ class Pal24PaymentMixin:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method=normalized_payment_method,
|
||||
payment_method=api_payment_method,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -147,6 +148,7 @@ class Pal24PaymentMixin:
|
||||
"description": description,
|
||||
"links": metadata_links,
|
||||
"raw_response": response,
|
||||
"selected_method": normalized_payment_method,
|
||||
}
|
||||
|
||||
payment = await payment_module.create_pal24_payment(
|
||||
@@ -186,6 +188,10 @@ class Pal24PaymentMixin:
|
||||
"payment_method": normalized_payment_method,
|
||||
"metadata_links": metadata_links,
|
||||
"status": payment_status,
|
||||
"sbp_url": transfer_url,
|
||||
"transfer_url": transfer_url,
|
||||
"link_page_url": link_page_url,
|
||||
"payment_url": primary_link,
|
||||
}
|
||||
|
||||
async def process_pal24_postback(
|
||||
@@ -240,175 +246,58 @@ class Pal24PaymentMixin:
|
||||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||||
return True
|
||||
|
||||
if status in {"PAID", "SUCCESS"}:
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Pal24 платежа",
|
||||
payment.user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or (payment.metadata_json or {}).get("selected_method")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Для Pal24 платежа %s уже создана транзакция",
|
||||
payment.bill_id,
|
||||
)
|
||||
return True
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
payment,
|
||||
payment_id=payment_id,
|
||||
trigger="postback",
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления Pal24: %s", error
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
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(payment.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(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status or "UNKNOWN",
|
||||
is_paid=False,
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
logger.info(
|
||||
"Обновили Pal24 платеж %s до статуса %s",
|
||||
@@ -421,6 +310,189 @@ class Pal24PaymentMixin:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def _finalize_pal24_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
payment_id: Optional[str],
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||||
payment.bill_id,
|
||||
trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Pal24 платежа %s (trigger=%s)",
|
||||
payment.user_id,
|
||||
payment.bill_id,
|
||||
trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
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",
|
||||
"У вас есть незавершенное оформление подписки. Вернуться?",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_pal24_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -434,32 +506,72 @@ class Pal24PaymentMixin:
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status = None
|
||||
remote_data = None
|
||||
remote_status: Optional[str] = None
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
service = getattr(self, "pal24_service", None)
|
||||
if service and payment.bill_id:
|
||||
try:
|
||||
response = await service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = response.get("status") or response.get(
|
||||
"bill", {}
|
||||
).get("status")
|
||||
remote_status = response.get("status") or response.get("bill", {}).get("status")
|
||||
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_pal24_payment_status(
|
||||
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,
|
||||
status=str(remote_status).upper(),
|
||||
)
|
||||
payment = await payment_module.get_pal24_payment_by_id(
|
||||
db, local_payment_id
|
||||
**update_kwargs,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error(
|
||||
"Ошибка Pal24 API при получении статуса: %s", error
|
||||
)
|
||||
|
||||
if payment.is_paid and not payment.transaction_id:
|
||||
try:
|
||||
finalized = await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=getattr(payment, "payment_id", None),
|
||||
trigger="status_check",
|
||||
)
|
||||
if finalized:
|
||||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка автоматического начисления по Pal24 статусу: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
@@ -471,3 +583,92 @@ class Pal24PaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]:
|
||||
"""Извлекает данные о платеже из ответа Pal24."""
|
||||
|
||||
def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
return None
|
||||
|
||||
def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def _stringify(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
return {
|
||||
"id": _stringify(candidate.get("id") or candidate.get("payment_id")),
|
||||
"status": _stringify(candidate.get("status")),
|
||||
"method": _stringify(candidate.get("method") or candidate.get("payment_method")),
|
||||
"balance_amount": _stringify(
|
||||
candidate.get("balance_amount")
|
||||
or candidate.get("amount")
|
||||
or candidate.get("BalanceAmount")
|
||||
),
|
||||
"balance_currency": _stringify(
|
||||
candidate.get("balance_currency") or candidate.get("BalanceCurrency")
|
||||
),
|
||||
"account": _stringify(
|
||||
candidate.get("account")
|
||||
or candidate.get("payer_account")
|
||||
or candidate.get("AccountNumber")
|
||||
),
|
||||
}
|
||||
|
||||
if not isinstance(remote_data, dict):
|
||||
return {}
|
||||
|
||||
search_spaces = [remote_data]
|
||||
bill_section = remote_data.get("bill") or remote_data.get("Bill")
|
||||
if isinstance(bill_section, dict):
|
||||
search_spaces.append(bill_section)
|
||||
|
||||
for space in search_spaces:
|
||||
for key in ("payment", "Payment", "payment_info", "PaymentInfo"):
|
||||
candidate = _pick_candidate(space.get(key))
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
for key in ("payments", "Payments"):
|
||||
candidate = _pick_candidate(space.get(key))
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payment_method(payment_method: Optional[str]) -> str:
|
||||
mapping = {
|
||||
"sbp": "sbp",
|
||||
"fast": "sbp",
|
||||
"fastpay": "sbp",
|
||||
"fast_payment": "sbp",
|
||||
"card": "card",
|
||||
"bank_card": "card",
|
||||
"bankcard": "card",
|
||||
"bank-card": "card",
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return "sbp"
|
||||
|
||||
normalized = payment_method.strip().lower()
|
||||
return mapping.get(normalized, "sbp")
|
||||
|
||||
@staticmethod
|
||||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||||
|
||||
api_mapping = {
|
||||
"sbp": "SBP",
|
||||
"card": "BANK_CARD",
|
||||
}
|
||||
|
||||
return api_mapping.get(normalized_payment_method)
|
||||
|
||||
@@ -120,6 +120,99 @@ class WataPaymentMixin:
|
||||
"order_id": order_id,
|
||||
}
|
||||
|
||||
async def process_wata_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Handles asynchronous webhook notifications from WATA."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("WATA webhook payload не является словарём: %s", payload)
|
||||
return False
|
||||
|
||||
order_id_raw = payload.get("orderId")
|
||||
payment_link_raw = payload.get("paymentLinkId") or payload.get("id")
|
||||
transaction_status_raw = payload.get("transactionStatus")
|
||||
|
||||
order_id = str(order_id_raw) if order_id_raw else None
|
||||
payment_link_id = str(payment_link_raw) if payment_link_raw else None
|
||||
transaction_status = (transaction_status_raw or "").strip()
|
||||
|
||||
if not order_id and not payment_link_id:
|
||||
logger.error(
|
||||
"WATA webhook без orderId и paymentLinkId: %s",
|
||||
payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if not transaction_status:
|
||||
logger.error("WATA webhook без статуса транзакции: %s", payload)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
|
||||
if not payment and payment_link_id:
|
||||
payment = await payment_module.get_wata_payment_by_link_id(db, payment_link_id)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"WATA платеж не найден (order_id=%s, payment_link_id=%s)",
|
||||
order_id,
|
||||
payment_link_id,
|
||||
)
|
||||
return False
|
||||
|
||||
status_lower = transaction_status.lower()
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["last_webhook"] = payload
|
||||
terminal_public_id = (
|
||||
payload.get("terminalPublicId")
|
||||
or payload.get("terminal_public_id")
|
||||
or payload.get("terminalPublicID")
|
||||
)
|
||||
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"metadata": metadata,
|
||||
"callback_payload": payload,
|
||||
"terminal_public_id": terminal_public_id,
|
||||
}
|
||||
|
||||
if transaction_status:
|
||||
update_kwargs["status"] = transaction_status
|
||||
update_kwargs["last_status"] = transaction_status
|
||||
|
||||
if status_lower != "paid" and not payment.is_paid:
|
||||
update_kwargs["is_paid"] = False
|
||||
|
||||
payment = await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
if status_lower == "paid":
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"WATA платеж %s уже помечен как оплачен",
|
||||
payment.payment_link_id,
|
||||
)
|
||||
return True
|
||||
|
||||
await self._finalize_wata_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_lower == "declined":
|
||||
logger.info(
|
||||
"WATA платеж %s отклонён",
|
||||
payment.payment_link_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def get_wata_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
+263
-121
@@ -172,7 +172,7 @@ class YooKassaPaymentMixin:
|
||||
currency="RUB",
|
||||
description=description,
|
||||
status=yookassa_response["status"],
|
||||
confirmation_url=yookassa_response.get("confirmation_url"),
|
||||
confirmation_url=yookassa_response.get("confirmation_url"), # Используем confirmation URL
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type="bank_card",
|
||||
yookassa_created_at=None,
|
||||
@@ -193,7 +193,8 @@ class YooKassaPaymentMixin:
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"yookassa_payment_id": yookassa_response["id"],
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"),
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"), # URL для подтверждения
|
||||
"qr_confirmation_data": yookassa_response.get("qr_confirmation_data"), # Данные для QR-кода
|
||||
"confirmation_token": confirmation_token,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
@@ -216,12 +217,39 @@ class YooKassaPaymentMixin:
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
if hasattr(payment, "metadata_json") and payment.metadata_json:
|
||||
import json
|
||||
|
||||
if isinstance(payment.metadata_json, str):
|
||||
payment_metadata = json.loads(payment.metadata_json)
|
||||
elif isinstance(payment.metadata_json, dict):
|
||||
payment_metadata = payment.metadata_json
|
||||
logger.info(f"Метаданные платежа: {payment_metadata}")
|
||||
except Exception as parse_error:
|
||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||
|
||||
payment_purpose = payment_metadata.get("payment_purpose", "")
|
||||
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
|
||||
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if is_simple_subscription
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
transaction_description = (
|
||||
f"Оплата подписки через YooKassa: {payment_description}"
|
||||
if is_simple_subscription
|
||||
else f"Пополнение через YooKassa: {payment_description}"
|
||||
)
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa: {payment_description}",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
@@ -235,143 +263,257 @@ class YooKassaPaymentMixin:
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
old_balance = getattr(user, "balance_kopeks", 0)
|
||||
was_first_topup = not getattr(user, "has_made_first_topup", False)
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.",
|
||||
payment.yookassa_payment_id,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения YooKassa: %s",
|
||||
error,
|
||||
else:
|
||||
old_balance = getattr(user, "balance_kopeks", 0)
|
||||
was_first_topup = not getattr(user, "has_made_first_topup", False)
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
if was_first_topup and not getattr(user, "has_made_first_topup", False):
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
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,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
"Ошибка обработки реферального пополнения YooKassa: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
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}")
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
if was_first_topup and not getattr(user, "has_made_first_topup", False):
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
error,
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
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}")
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
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(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
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(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(f"Обнаружен платеж простой покупки подписки для пользователя {user.id}")
|
||||
try:
|
||||
# Активируем подписку
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
# Получаем параметры подписки из метаданных
|
||||
subscription_period = int(payment_metadata.get("subscription_period", 30))
|
||||
order_id = payment_metadata.get("order_id")
|
||||
|
||||
logger.info(f"Активация подписки: период={subscription_period} дней, заказ={order_id}")
|
||||
|
||||
# Активируем pending подписку пользователя
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=subscription_period
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
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(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
if subscription:
|
||||
logger.info(f"Подписка успешно активирована для пользователя {user.id}")
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю об активации подписки
|
||||
if getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
success_message = (
|
||||
f"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {subscription_period} дней\n"
|
||||
f"📱 Устройства: 1\n"
|
||||
f"📊 Трафик: Безлимит\n"
|
||||
f"💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n"
|
||||
f"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
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(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
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,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -209,12 +209,13 @@ class PaymentService(
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
|
||||
"MulenPay=%s, Pal24=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, %s=%s, Pal24=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
mulenpay_name,
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.wata_service),
|
||||
|
||||
@@ -1175,5 +1175,38 @@ class MiniAppSubscriptionPurchaseService:
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionPurchaseService:
|
||||
"""Service for handling simple subscription purchases with predefined parameters."""
|
||||
|
||||
async def create_subscription_order(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int,
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str,
|
||||
payment_method: str,
|
||||
total_price_kopeks: int
|
||||
):
|
||||
"""Creates a subscription order with predefined parameters."""
|
||||
from app.database.crud.subscription import create_pending_subscription
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Create a pending subscription
|
||||
subscription = await create_pending_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=period_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
purchase_service = MiniAppSubscriptionPurchaseService()
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class BotConfigurationService:
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"MULENPAY": "💰 {mulenpay_name}",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"WATA": "💠 Wata",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
@@ -124,7 +124,7 @@ class BotConfigurationService:
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"MULENPAY": "Платежи MulenPay и параметры магазина.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
@@ -166,6 +166,14 @@ class BotConfigurationService:
|
||||
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_dynamic_copy(category_key: Optional[str], value: str) -> str:
|
||||
if not value:
|
||||
return value
|
||||
if category_key == "MULENPAY":
|
||||
return value.format(mulenpay_name=settings.get_mulenpay_display_name())
|
||||
return value
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
"DATABASE_URL": "DATABASE",
|
||||
"DATABASE_MODE": "DATABASE",
|
||||
@@ -459,7 +467,8 @@ class BotConfigurationService:
|
||||
|
||||
@classmethod
|
||||
def get_category_description(cls, category_key: str) -> str:
|
||||
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
description = cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
return cls._format_dynamic_copy(category_key, description)
|
||||
|
||||
@classmethod
|
||||
def is_toggle(cls, key: str) -> bool:
|
||||
@@ -598,6 +607,7 @@ class BotConfigurationService:
|
||||
category_key,
|
||||
category_key.capitalize() if category_key else "Прочее",
|
||||
)
|
||||
category_label = cls._format_dynamic_copy(category_key, category_label)
|
||||
|
||||
cls._definitions[key] = SettingDefinition(
|
||||
key=key,
|
||||
|
||||
@@ -66,10 +66,30 @@ class UserService:
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
if getattr(user, "subscription", None) and user.subscription.status in {
|
||||
"active",
|
||||
"expired",
|
||||
"trial",
|
||||
}:
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=get_texts(user.language).t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
])
|
||||
|
||||
reply_markup = (
|
||||
types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
if keyboard_rows
|
||||
else None
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
|
||||
@@ -652,7 +672,10 @@ class UserService:
|
||||
mulenpay_payments = mulenpay_result.scalars().all()
|
||||
|
||||
if mulenpay_payments:
|
||||
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
f"🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей"
|
||||
)
|
||||
await db.execute(
|
||||
update(MulenPayPayment)
|
||||
.where(MulenPayPayment.user_id == user_id)
|
||||
@@ -664,7 +687,9 @@ class UserService:
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
|
||||
logger.error(
|
||||
f"❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
pal24_result = await db.execute(
|
||||
|
||||
@@ -102,8 +102,11 @@ class WataService:
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(value: datetime) -> str:
|
||||
aware = value.astimezone(timezone.utc)
|
||||
return aware.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if value.tzinfo is None:
|
||||
aware = value.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
aware = value.astimezone(timezone.utc)
|
||||
return aware.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(raw: Optional[str]) -> Optional[datetime]:
|
||||
@@ -111,7 +114,10 @@ class WataService:
|
||||
return None
|
||||
try:
|
||||
normalized = raw.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed
|
||||
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug("Failed to parse WATA datetime: %s", raw)
|
||||
return None
|
||||
|
||||
@@ -188,6 +188,8 @@ class YooKassaService:
|
||||
}
|
||||
|
||||
try:
|
||||
# Создаем один платеж с подтверждением через QR
|
||||
# Это позволит получить QR-код для пользователя
|
||||
builder = PaymentRequestBuilder()
|
||||
|
||||
builder.set_amount({
|
||||
@@ -197,6 +199,7 @@ class YooKassaService:
|
||||
|
||||
builder.set_capture(True)
|
||||
|
||||
# Устанавливаем подтверждение через redirect для получения вебхуков
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"return_url": self.return_url
|
||||
@@ -234,7 +237,7 @@ class YooKassaService:
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
|
||||
f"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -242,11 +245,14 @@ class YooKassaService:
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
f"Ответ YooKassa Payment.create (СБП, qr): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
# Возвращаем данные платежа с QR-подтверждением
|
||||
# Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа
|
||||
return {
|
||||
"id": response.id,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
|
||||
"qr_confirmation_data": response.confirmation.confirmation_data if response.confirmation and hasattr(response.confirmation, 'confirmation_data') else None,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation and hasattr(response.confirmation, 'confirmation_url') else None,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
@@ -263,6 +269,105 @@ class YooKassaService:
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _create_sbp_payment_with_confirmation_type(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
customer_contact_for_receipt: Dict[str, str],
|
||||
confirmation_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Создает SBP платеж с указанным типом подтверждения"""
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
|
||||
builder.set_amount({
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
|
||||
builder.set_capture(True)
|
||||
|
||||
if confirmation_type == "qr":
|
||||
builder.set_confirmation({
|
||||
"type": "qr"
|
||||
})
|
||||
else: # redirect
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"return_url": self.return_url
|
||||
})
|
||||
|
||||
builder.set_description(description)
|
||||
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
builder.set_payment_method_data({
|
||||
"type": "sbp"
|
||||
})
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description": description[:128],
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
|
||||
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
"customer": customer_contact_for_receipt,
|
||||
"items": receipt_items_list
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa СБП с подтверждением '{confirmation_type}' (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create (СБП, {confirmation_type}): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
result = {
|
||||
"id": response.id,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
"amount_currency": response.amount.currency,
|
||||
"idempotence_key_used": idempotence_key,
|
||||
"paid": response.paid,
|
||||
"refundable": response.refundable,
|
||||
"created_at": response.created_at.isoformat() if hasattr(
|
||||
response.created_at, 'isoformat') else str(response.created_at),
|
||||
"description_from_yk": response.description,
|
||||
"test_mode": response.test if hasattr(response, 'test') else None
|
||||
}
|
||||
|
||||
# Добавляем данные подтверждения в зависимости от типа
|
||||
if confirmation_type == "qr":
|
||||
if response.confirmation and hasattr(response.confirmation, 'confirmation_data'):
|
||||
result["confirmation_data"] = response.confirmation.confirmation_data
|
||||
else: # redirect
|
||||
if response.confirmation and hasattr(response.confirmation, 'confirmation_url'):
|
||||
result["confirmation_url"] = response.confirmation.confirmation_url
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП с подтверждением '{confirmation_type}': {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ class SubscriptionStates(StatesGroup):
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
cart_saved_for_topup = State()
|
||||
|
||||
# Состояния для простой подписки
|
||||
waiting_for_simple_subscription_payment_method = State()
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_pal24_method = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
@@ -32,6 +36,7 @@ class PromoCodeStates(StatesGroup):
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
sending_user_message = State()
|
||||
editing_user_balance = State()
|
||||
extending_subscription = State()
|
||||
adding_traffic = State()
|
||||
|
||||
@@ -46,11 +46,12 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
})
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
methods.append({
|
||||
"id": "mulenpay",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Mulen Pay",
|
||||
"description": f"через {mulenpay_name}",
|
||||
"callback": "topup_mulenpay"
|
||||
})
|
||||
|
||||
@@ -129,6 +130,12 @@ def get_payment_methods_text(language: str) -> str:
|
||||
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
|
||||
method['description'],
|
||||
)
|
||||
if method_id == "MULENPAY":
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
name = name.format(mulenpay_name=mulenpay_name_html)
|
||||
description = description.format(mulenpay_name=mulenpay_name)
|
||||
|
||||
text += f"{name} - {description}\n"
|
||||
|
||||
text += "\n" + texts.t(
|
||||
|
||||
@@ -668,6 +668,7 @@ async def get_payment_methods(
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="mulenpay",
|
||||
name=settings.get_mulenpay_display_name(),
|
||||
icon="💳",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
@@ -958,7 +959,7 @@ async def create_payment_link(
|
||||
option = (payload.payment_option or "").strip().lower()
|
||||
if option not in {"card", "sbp"}:
|
||||
option = "sbp"
|
||||
provider_method = "CARD" if option == "card" else "SBP"
|
||||
provider_method = "card" if option == "card" else "sbp"
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
@@ -974,7 +975,7 @@ async def create_payment_link(
|
||||
|
||||
preferred_urls: List[Optional[str]] = []
|
||||
if option == "sbp":
|
||||
preferred_urls.append(result.get("sbp_url"))
|
||||
preferred_urls.append(result.get("sbp_url") or result.get("transfer_url"))
|
||||
elif option == "card":
|
||||
preferred_urls.append(result.get("card_url"))
|
||||
preferred_urls.extend(
|
||||
@@ -998,7 +999,7 @@ async def create_payment_link(
|
||||
"bill_id": result.get("bill_id"),
|
||||
"order_id": result.get("order_id"),
|
||||
"payment_method": result.get("payment_method") or provider_method,
|
||||
"sbp_url": result.get("sbp_url"),
|
||||
"sbp_url": result.get("sbp_url") or result.get("transfer_url"),
|
||||
"card_url": result.get("card_url"),
|
||||
"link_url": result.get("link_url"),
|
||||
"link_page_url": result.get("link_page_url"),
|
||||
|
||||
@@ -361,6 +361,7 @@ class MiniAppPaymentMethodsRequest(BaseModel):
|
||||
|
||||
class MiniAppPaymentMethod(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
requires_amount: bool = False
|
||||
currency: str = "RUB"
|
||||
|
||||
+4
-1
@@ -35,7 +35,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
build: .
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -75,6 +75,9 @@ services:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
|
||||
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
|
||||
- "${WATA_WEBHOOK_PORT:-8085}:8085"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
|
||||
-1241
File diff suppressed because it is too large
Load Diff
-1243
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from app.services.version_service import version_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
|
||||
from app.external.wata_webhook import start_wata_webhook_server
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
@@ -72,6 +73,7 @@ async def main():
|
||||
|
||||
webhook_server = None
|
||||
yookassa_server_task = None
|
||||
wata_server_task = None
|
||||
pal24_server: Pal24WebhookServer | None = None
|
||||
monitoring_task = None
|
||||
maintenance_task = None
|
||||
@@ -246,7 +248,7 @@ async def main():
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
enabled_services.append(settings.get_mulenpay_display_name())
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
@@ -255,7 +257,9 @@ async def main():
|
||||
stage.log(f"Активированы: {', '.join(enabled_services)}")
|
||||
stage.success("Webhook сервера запущены")
|
||||
else:
|
||||
stage.skip("Tribute, Mulen Pay и CryptoBot отключены")
|
||||
stage.skip(
|
||||
f"Tribute, {settings.get_mulenpay_display_name()} и CryptoBot отключены"
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"YooKassa webhook",
|
||||
@@ -285,6 +289,21 @@ async def main():
|
||||
else:
|
||||
stage.skip("PayPalych отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"WATA webhook",
|
||||
"💳",
|
||||
success_message="WATA webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_wata_enabled():
|
||||
wata_server_task = asyncio.create_task(
|
||||
start_wata_webhook_server(payment_service)
|
||||
)
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("WATA отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Служба мониторинга",
|
||||
"📈",
|
||||
@@ -355,7 +374,8 @@ async def main():
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
webhook_lines.append(
|
||||
f"Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
f"{settings.get_mulenpay_display_name()}: "
|
||||
f"{settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
webhook_lines.append(
|
||||
@@ -369,6 +389,10 @@ async def main():
|
||||
webhook_lines.append(
|
||||
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_wata_enabled():
|
||||
webhook_lines.append(
|
||||
f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
|
||||
)
|
||||
|
||||
timeline.log_section(
|
||||
"Активные webhook endpoints",
|
||||
@@ -399,7 +423,19 @@ async def main():
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
|
||||
|
||||
if wata_server_task and wata_server_task.done():
|
||||
exception = wata_server_task.exception()
|
||||
if exception:
|
||||
logger.error(f"WATA webhook сервер завершился с ошибкой: {exception}")
|
||||
logger.info("🔄 Перезапуск WATA webhook сервера...")
|
||||
if settings.is_wata_enabled():
|
||||
wata_server_task = asyncio.create_task(
|
||||
start_wata_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
wata_server_task = None
|
||||
|
||||
if monitoring_task.done():
|
||||
exception = monitoring_task.exception()
|
||||
if exception:
|
||||
@@ -446,7 +482,15 @@ async def main():
|
||||
await yookassa_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
if wata_server_task and not wata_server_task.done():
|
||||
logger.info("ℹ️ Остановка WATA webhook сервера...")
|
||||
wata_server_task.cancel()
|
||||
try:
|
||||
await wata_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if monitoring_task and not monitoring_task.done():
|
||||
logger.info("ℹ️ Остановка службы мониторинга...")
|
||||
monitoring_service.stop_monitoring()
|
||||
|
||||
+78
-7
@@ -13224,15 +13224,45 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
const typeRaw = String(userData.subscription_type || '').toLowerCase();
|
||||
if (typeRaw === 'trial') {
|
||||
const missing = coerceBoolean(
|
||||
userData.subscription_missing
|
||||
?? userData.subscriptionMissing
|
||||
?? userData.user.subscription_missing
|
||||
?? userData.user.subscriptionMissing,
|
||||
false,
|
||||
);
|
||||
if (missing) {
|
||||
return false;
|
||||
}
|
||||
if (typeRaw === 'paid') {
|
||||
return true;
|
||||
|
||||
const expiredFlag = coerceBoolean(
|
||||
userData.subscription_expired
|
||||
?? userData.subscriptionExpired
|
||||
?? userData.user.subscription_expired
|
||||
?? userData.user.subscriptionExpired,
|
||||
false,
|
||||
);
|
||||
if (expiredFlag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userData.user.has_active_subscription === false) {
|
||||
const expirationCandidates = [
|
||||
userData.subscription_expires_at,
|
||||
userData.subscriptionExpiresAt,
|
||||
userData.user.subscription_expires_at,
|
||||
userData.user.subscriptionExpiresAt,
|
||||
userData.user.expires_at,
|
||||
userData.user.expiresAt,
|
||||
userData.expires_at,
|
||||
userData.expiresAt,
|
||||
];
|
||||
const isExpiredByDate = expirationCandidates.some(candidate => {
|
||||
const expiresAt = parseDate(candidate);
|
||||
return expiresAt instanceof Date
|
||||
&& !Number.isNaN(expiresAt.getTime())
|
||||
&& expiresAt.getTime() <= Date.now();
|
||||
});
|
||||
if (isExpiredByDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -13241,11 +13271,52 @@
|
||||
|| userData.user.subscription_status
|
||||
|| ''
|
||||
).toLowerCase();
|
||||
if (['trial', 'expired', 'disabled'].includes(statusRaw)) {
|
||||
|
||||
if (statusRaw) {
|
||||
const inactiveStatuses = [
|
||||
'expired',
|
||||
'disabled',
|
||||
'missing',
|
||||
'inactive',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'ended',
|
||||
'paused',
|
||||
'stopped',
|
||||
];
|
||||
if (inactiveStatuses.includes(statusRaw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['trial', 'trialing'].includes(statusRaw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['active', 'paid'].includes(statusRaw)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFlag = userData.user.has_active_subscription;
|
||||
if (hasActiveFlag === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
const typeRaw = String(
|
||||
userData.subscription_type
|
||||
?? userData.user.subscription_type
|
||||
?? ''
|
||||
).toLowerCase();
|
||||
|
||||
if (['trial', 'expired', 'inactive', 'disabled', 'none', 'missing', 'ended'].includes(typeRaw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasActiveFlag === true && typeRaw !== 'trial') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeRaw === 'paid';
|
||||
}
|
||||
|
||||
function normalizeServerEntry(entry) {
|
||||
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for the WATA webhook handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.external.wata_webhook import WataWebhookHandler # noqa: E402
|
||||
|
||||
|
||||
class DummyPaymentService:
|
||||
async def process_wata_webhook(self, *args, **kwargs): # pragma: no cover - not used in tests
|
||||
return True
|
||||
|
||||
|
||||
class StubPublicKeyProvider:
|
||||
def __init__(self, public_key_pem: Optional[str]) -> None:
|
||||
self.public_key_pem = public_key_pem
|
||||
|
||||
async def get_public_key(self) -> Optional[str]:
|
||||
return self.public_key_pem
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature_success() -> None:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
payload = "{\"status\": \"Paid\"}"
|
||||
signature = base64.b64encode(
|
||||
private_key.sign(
|
||||
payload.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
).decode("utf-8")
|
||||
|
||||
handler = WataWebhookHandler(
|
||||
DummyPaymentService(),
|
||||
public_key_provider=StubPublicKeyProvider(public_key),
|
||||
)
|
||||
|
||||
assert await handler._verify_signature(payload, signature) is True
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature_fails_with_invalid_signature() -> None:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
payload = "{\"status\": \"Paid\"}"
|
||||
bad_signature = base64.b64encode(b"not-a-signature").decode("utf-8")
|
||||
|
||||
handler = WataWebhookHandler(
|
||||
DummyPaymentService(),
|
||||
public_key_provider=StubPublicKeyProvider(public_key),
|
||||
)
|
||||
|
||||
assert await handler._verify_signature(payload, bad_signature) is False
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature_fails_without_public_key() -> None:
|
||||
handler = WataWebhookHandler(
|
||||
DummyPaymentService(),
|
||||
public_key_provider=StubPublicKeyProvider(None),
|
||||
)
|
||||
|
||||
assert await handler._verify_signature("{}", "signature") is False
|
||||
@@ -70,13 +70,15 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ttl_seconds=600,
|
||||
custom_payload={"extra": "value"},
|
||||
payer_email="user@example.com",
|
||||
payment_method="CARD",
|
||||
payment_method="BANK_CARD",
|
||||
)
|
||||
|
||||
assert result["bill_id"] == "BILL42"
|
||||
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
|
||||
assert client.calls[0]["shop_id"] == "shop42"
|
||||
assert client.calls[0]["description"] == "Пополнение"
|
||||
assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert client.calls[0]["payment_method"] == "BANK_CARD"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
|
||||
@@ -101,13 +101,46 @@ async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert result is not None
|
||||
assert result["local_payment_id"] == 321
|
||||
assert result["bill_id"] == "BILL-1"
|
||||
assert result["payment_method"] == "CARD"
|
||||
assert result["payment_method"] == "card"
|
||||
assert result["link_url"] == "https://pal24/sbp"
|
||||
assert result["card_url"] == "https://pal24/card"
|
||||
assert stub.calls and stub.calls[0]["payment_method"] == "BANK_CARD"
|
||||
assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000
|
||||
assert "links" in captured_args["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_pal24_payment_default_method(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPal24Service()
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
|
||||
return DummyLocalPayment(payment_id=111)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_pal24_payment",
|
||||
fake_create_pal24_payment,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False)
|
||||
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
|
||||
|
||||
result = await service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=42,
|
||||
amount_kopeks=10_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert stub.calls and stub.calls[0]["payment_method"] == "SBP"
|
||||
assert result["payment_method"] == "sbp"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPal24Service()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -16,6 +16,7 @@ if str(ROOT_DIR) not in sys.path:
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
from app.services.wata_service import WataService # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -47,6 +48,23 @@ class StubWataService:
|
||||
return self.response
|
||||
|
||||
|
||||
class DummyWataPayment:
|
||||
def __init__(self) -> None:
|
||||
self.id = 1
|
||||
self.user_id = 42
|
||||
self.payment_link_id = "link-123"
|
||||
self.order_id = "order-123"
|
||||
self.amount_kopeks = 15_000
|
||||
self.currency = "RUB"
|
||||
self.description = "Пополнение"
|
||||
self.status = "Opened"
|
||||
self.is_paid = False
|
||||
self.metadata_json: Dict[str, Any] = {}
|
||||
self.transaction_id: Optional[int] = None
|
||||
self.callback_payload: Optional[Dict[str, Any]] = None
|
||||
self.terminal_public_id: Optional[str] = None
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubWataService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
service.bot = None
|
||||
@@ -59,6 +77,22 @@ def _make_service(stub: Optional[StubWataService]) -> PaymentService:
|
||||
return service
|
||||
|
||||
|
||||
def test_wata_service_format_datetime_accepts_naive_utc() -> None:
|
||||
value = datetime(2024, 5, 20, 12, 30, 0)
|
||||
formatted = WataService._format_datetime(value)
|
||||
assert formatted == "2024-05-20T12:30:00Z"
|
||||
|
||||
|
||||
def test_wata_service_parse_datetime_returns_naive_utc() -> None:
|
||||
parsed = WataService._parse_datetime("2024-05-20T12:30:00Z")
|
||||
assert parsed == datetime(2024, 5, 20, 12, 30, 0)
|
||||
assert parsed.tzinfo is None
|
||||
|
||||
parsed_with_offset = WataService._parse_datetime("2024-05-20T15:30:00+03:00")
|
||||
assert parsed_with_offset == datetime(2024, 5, 20, 12, 30, 0)
|
||||
assert parsed_with_offset.tzinfo is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
response = {
|
||||
@@ -142,3 +176,197 @@ async def test_create_wata_payment_returns_none_without_service() -> None:
|
||||
description="Пополнение",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
payment = DummyWataPayment()
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
link_lookup_called = False
|
||||
|
||||
async def fake_get_by_order_id(db_arg: Any, order_id: str) -> DummyWataPayment:
|
||||
assert db_arg is db
|
||||
assert order_id == payment.order_id
|
||||
return payment
|
||||
|
||||
async def fake_get_by_link_id(*_: Any, **__: Any) -> Optional[DummyWataPayment]:
|
||||
nonlocal link_lookup_called
|
||||
link_lookup_called = True
|
||||
return None
|
||||
|
||||
async def fake_update_status(
|
||||
db_arg: Any,
|
||||
*,
|
||||
payment: DummyWataPayment,
|
||||
**kwargs: Any,
|
||||
) -> DummyWataPayment:
|
||||
assert db_arg is db
|
||||
update_kwargs.update(kwargs)
|
||||
if "status" in kwargs:
|
||||
payment.status = kwargs["status"]
|
||||
if "is_paid" in kwargs:
|
||||
payment.is_paid = kwargs["is_paid"]
|
||||
if "metadata" in kwargs:
|
||||
payment.metadata_json = kwargs["metadata"]
|
||||
if "callback_payload" in kwargs:
|
||||
payment.callback_payload = kwargs["callback_payload"]
|
||||
if "terminal_public_id" in kwargs:
|
||||
payment.terminal_public_id = kwargs["terminal_public_id"]
|
||||
return payment
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
fake_get_by_link_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": payment.order_id,
|
||||
"transactionStatus": "Declined",
|
||||
"terminalPublicId": "terminal-001",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is True
|
||||
assert link_lookup_called is False
|
||||
assert payment.status == "Declined"
|
||||
assert payment.is_paid is False
|
||||
assert payment.metadata_json.get("last_webhook") == payload
|
||||
assert payment.callback_payload == payload
|
||||
assert payment.terminal_public_id == "terminal-001"
|
||||
assert update_kwargs["status"] == "Declined"
|
||||
assert update_kwargs["is_paid"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
payment = DummyWataPayment()
|
||||
finalize_called = False
|
||||
|
||||
async def fake_get_by_order_id(*_: Any, **__: Any) -> DummyWataPayment:
|
||||
return payment
|
||||
|
||||
async def fake_update_status(
|
||||
db_arg: Any,
|
||||
*,
|
||||
payment: DummyWataPayment,
|
||||
**kwargs: Any,
|
||||
) -> DummyWataPayment:
|
||||
if "metadata" in kwargs:
|
||||
payment.metadata_json = kwargs["metadata"]
|
||||
if "callback_payload" in kwargs:
|
||||
payment.callback_payload = kwargs["callback_payload"]
|
||||
if "status" in kwargs:
|
||||
payment.status = kwargs["status"]
|
||||
return payment
|
||||
|
||||
async def fake_finalize(
|
||||
db_arg: Any,
|
||||
payment_arg: DummyWataPayment,
|
||||
payload_arg: Dict[str, Any],
|
||||
) -> DummyWataPayment:
|
||||
nonlocal finalize_called
|
||||
finalize_called = True
|
||||
payment_arg.is_paid = True
|
||||
return payment_arg
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
lambda *args, **kwargs: None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_finalize_wata_payment",
|
||||
fake_finalize,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": payment.order_id,
|
||||
"transactionStatus": "Paid",
|
||||
"transactionId": "tx-001",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is True
|
||||
assert finalize_called is True
|
||||
assert payment.is_paid is True
|
||||
assert payment.metadata_json.get("last_webhook") == payload
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_returns_false_when_payment_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_get_by_order_id(*_: Any, **__: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_by_link_id(*_: Any, **__: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fail_update(*_: Any, **__: Any) -> None:
|
||||
pytest.fail("update_wata_payment_status should not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
fake_get_by_link_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fail_update,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": "missing-order",
|
||||
"transactionStatus": "Paid",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is False
|
||||
|
||||
@@ -63,7 +63,10 @@ def anyio_backend() -> str:
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.mark.parametrize("status_field", ["payment_status", "status", "paymentStatus"])
|
||||
async def test_process_mulenpay_callback_success(
|
||||
monkeypatch: pytest.MonkeyPatch, status_field: str
|
||||
) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
@@ -142,10 +145,10 @@ async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
payload = {
|
||||
"uuid": "mulen_uuid",
|
||||
"payment_status": "success",
|
||||
"id": 123,
|
||||
"amount": "50.00",
|
||||
}
|
||||
payload[status_field] = "success"
|
||||
|
||||
result = await service.process_mulenpay_callback(fake_session, payload)
|
||||
|
||||
@@ -372,6 +375,9 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
transaction_id=None,
|
||||
is_paid=False,
|
||||
status="NEW",
|
||||
metadata_json={},
|
||||
payment_method=None,
|
||||
paid_at=None,
|
||||
)
|
||||
|
||||
async def fake_get_by_order(db, order_id):
|
||||
@@ -419,6 +425,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
language="ru",
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
@@ -439,7 +446,35 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.services.admin_notification_service",
|
||||
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)),
|
||||
)
|
||||
|
||||
user_cart_stub = SimpleNamespace(
|
||||
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True))
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
|
||||
|
||||
class DummyTypes:
|
||||
class InlineKeyboardMarkup:
|
||||
def __init__(self, inline_keyboard=None, **kwargs):
|
||||
self.inline_keyboard = inline_keyboard or []
|
||||
self.kwargs = kwargs
|
||||
|
||||
class InlineKeyboardButton:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.localization.texts",
|
||||
SimpleNamespace(get_texts=lambda language: SimpleNamespace(t=lambda key, default=None: default)),
|
||||
)
|
||||
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
payload = {
|
||||
@@ -455,9 +490,148 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert payment.transaction_id == 654
|
||||
assert user.balance_kopeks == 5000
|
||||
assert bot.sent_messages
|
||||
saved_cart_message = bot.sent_messages[-1]
|
||||
reply_markup = saved_cart_message["kwargs"].get("reply_markup")
|
||||
assert reply_markup is not None
|
||||
assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "subscription_resume_checkout"
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
|
||||
class DummyPal24Service:
|
||||
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
|
||||
BILL_FAILED_STATES = {"FAIL"}
|
||||
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": "SUCCESS",
|
||||
"bill": {
|
||||
"status": "SUCCESS",
|
||||
"payments": [
|
||||
{
|
||||
"id": "trs-auto-1",
|
||||
"status": "SUCCESS",
|
||||
"method": "SBP",
|
||||
"balance_amount": "50.00",
|
||||
"balance_currency": "RUB",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
service.pal24_service = DummyPal24Service()
|
||||
|
||||
fake_session = FakeSession()
|
||||
payment = SimpleNamespace(
|
||||
id=77,
|
||||
bill_id="BILL-AUTO",
|
||||
order_id="order-auto",
|
||||
amount_kopeks=5000,
|
||||
user_id=91,
|
||||
transaction_id=None,
|
||||
is_paid=False,
|
||||
status="NEW",
|
||||
metadata_json={},
|
||||
payment_id=None,
|
||||
payment_method=None,
|
||||
paid_at=None,
|
||||
)
|
||||
|
||||
async def fake_get_payment_by_id(db, local_id):
|
||||
return payment
|
||||
|
||||
async def fake_update_payment(db, payment_obj, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(payment, key, value)
|
||||
return payment
|
||||
|
||||
async def fake_link_payment(db, payment_obj, transaction_id):
|
||||
payment.transaction_id = transaction_id
|
||||
return payment
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id)
|
||||
monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment)
|
||||
monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
payment.transaction_id = 999
|
||||
return SimpleNamespace(id=999, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=91,
|
||||
telegram_id=9100,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
language="ru",
|
||||
)
|
||||
|
||||
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_stub = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
|
||||
|
||||
admin_notifications: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_notifications.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.services.admin_notification_service",
|
||||
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
|
||||
)
|
||||
|
||||
user_cart_stub = SimpleNamespace(
|
||||
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
|
||||
|
||||
class DummyTypes:
|
||||
class InlineKeyboardMarkup:
|
||||
def __init__(self, inline_keyboard=None, **kwargs):
|
||||
self.inline_keyboard = inline_keyboard or []
|
||||
self.kwargs = kwargs
|
||||
|
||||
class InlineKeyboardButton:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
|
||||
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
result = await service.get_pal24_payment_status(fake_session, payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert payment.transaction_id == 999
|
||||
assert user.balance_kopeks == 5000
|
||||
assert bot.sent_messages
|
||||
assert admin_notifications
|
||||
assert transactions and transactions[0]["user_id"] == 91
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
|
||||
@@ -86,8 +86,8 @@ async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
||||
|
||||
assert response.payment_url == 'https://card'
|
||||
assert response.extra['selected_option'] == 'card'
|
||||
assert response.extra['payment_method'] == 'CARD'
|
||||
assert captured_calls and captured_calls[0]['payment_method'] == 'CARD'
|
||||
assert response.extra['payment_method'] == 'card'
|
||||
assert captured_calls and captured_calls[0]['payment_method'] == 'card'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
@@ -265,7 +265,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
|
||||
transaction_id=777,
|
||||
bill_id='BILL99',
|
||||
order_id='ORD99',
|
||||
payment_method='SBP',
|
||||
payment_method='sbp',
|
||||
)
|
||||
|
||||
class StubPal24Service:
|
||||
@@ -297,7 +297,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
|
||||
assert result.extra['local_payment_id'] == 321
|
||||
assert result.extra['bill_id'] == 'BILL99'
|
||||
assert result.extra['order_id'] == 'ORD99'
|
||||
assert result.extra['payment_method'] == 'SBP'
|
||||
assert result.extra['payment_method'] == 'sbp'
|
||||
assert result.extra['payload'] == 'pal24_payload'
|
||||
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
|
||||
assert result.extra['remote_status'] == 'PAID'
|
||||
|
||||
Reference in New Issue
Block a user