diff --git a/.env.example b/.env.example
index 0c040c5e..6fb22015 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/app/bot.py b/app/bot.py
index 34af9400..f21b7534 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -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()
diff --git a/app/config.py b/app/config.py
index 3518e15f..a3a5c6df 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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 = "Интернет-сервис"
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index dcbf861a..9cc8885a 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -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
diff --git a/app/database/models.py b/app/database/models.py
index 459eec12..ff79a16f 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -54,6 +54,7 @@ class SubscriptionStatus(Enum):
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
+ PENDING = "pending"
class TransactionType(Enum):
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 3d92231b..a1f44f7c 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -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",
+ "✉️ Отправка сообщения пользователю\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,
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index a87f602f..6b791efe 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -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:
diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py
new file mode 100644
index 00000000..8a055af0
--- /dev/null
+++ b/app/handlers/simple_subscription.py
@@ -0,0 +1,836 @@
+"""Обработчики для простой покупки подписки."""
+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"⚡ Простая покупка подписки\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():
+ keyboard.append([types.InlineKeyboardButton(
+ text="💳 MulenPay",
+ 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"✅ Подписка успешно активирована!\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"💳 Оплата подписки\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"💳 Оплата подписки через YooKassa\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"📱 Инструкция по оплате:\n"
+ f"1. Откройте приложение вашего банка\n"
+ f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
+ f"3. Введите ID платежа: {payment_result['yookassa_payment_id']}\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
+ if not settings.is_mulenpay_enabled():
+ await callback.answer("❌ Оплата через MulenPay временно недоступна", show_alert=True)
+ return
+
+ # Здесь должна быть реализация оплаты через MulenPay
+ await callback.answer("❌ Оплата через MulenPay пока не реализована", 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_")
+ )
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 67771981..cd188cd0 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -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,162 @@ 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"⚡ Простая покупка подписки\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"⚡ Простая покупка подписки\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():
+ keyboard.append([types.InlineKeyboardButton(
+ text="💳 MulenPay",
+ 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)
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index a1d9e3a8..75e72bc0 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -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(
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 43f018c2..bd38cabe 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -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")
])
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 62cae4a9..16eb50dc 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -695,6 +695,14 @@
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
"ADMIN_USER_TRANSACTIONS": "📋 Transactions",
+ "ADMIN_USER_SEND_MESSAGE": "✉️ Send message",
+ "ADMIN_USER_SEND_MESSAGE_PROMPT": "✉️ Send a message to the user\n\nType the text that the bot should send.\n\nYou can cancel with /cancel or the button below.",
+ "ADMIN_USER_SEND_MESSAGE_SUCCESS": "✅ Message sent to the user",
+ "ADMIN_USER_SEND_MESSAGE_FORBIDDEN": "⚠️ The user blocked the bot or cannot receive messages.",
+ "ADMIN_USER_SEND_MESSAGE_BAD_REQUEST": "❌ Telegram rejected the message. Check the text and try again.",
+ "ADMIN_USER_SEND_MESSAGE_ERROR": "❌ Couldn't send the message. Please try again later.",
+ "ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND": "❌ User not found",
+ "ADMIN_USER_SEND_MESSAGE_EMPTY": "❌ Please enter a non-empty message",
"ADMIN_USER_UNBLOCK": "✅ Unblock",
"ADMIN_USER_USERNAME_NOT_SET": "not set",
"ADMIN_WELCOME_DISABLE": "🔴 Disable",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index a57c7c3d..efc3367a 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -695,6 +695,14 @@
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
"ADMIN_USER_TRANSACTIONS": "📋 Транзакции",
+ "ADMIN_USER_SEND_MESSAGE": "✉️ Отправить сообщение",
+ "ADMIN_USER_SEND_MESSAGE_PROMPT": "✉️ Отправка сообщения пользователю\n\nВведите текст, который бот отправит пользователю.\n\nМожно отменить командой /cancel или кнопкой ниже.",
+ "ADMIN_USER_SEND_MESSAGE_SUCCESS": "✅ Сообщение отправлено пользователю",
+ "ADMIN_USER_SEND_MESSAGE_FORBIDDEN": "⚠️ Пользователь заблокировал бота или не может получать сообщения.",
+ "ADMIN_USER_SEND_MESSAGE_BAD_REQUEST": "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз.",
+ "ADMIN_USER_SEND_MESSAGE_ERROR": "❌ Не удалось отправить сообщение. Попробуйте позже.",
+ "ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND": "❌ Пользователь не найден",
+ "ADMIN_USER_SEND_MESSAGE_EMPTY": "❌ Пожалуйста, введите непустое сообщение",
"ADMIN_USER_UNBLOCK": "✅ Разблокировать",
"ADMIN_USER_USERNAME_NOT_SET": "не указан",
"ADMIN_WELCOME_DISABLE": "🔴 Отключить",
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index b53ba141..fd3bad98 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -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"""💎 {event_type}
👤 Пользователь: {user.full_name}
@@ -250,9 +254,9 @@ class AdminNotificationService:
{promo_block}
💰 Платеж:
-💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
+💵 Сумма: {settings.format_price(total_amount)}
💳 Способ: {payment_method}
-🆔 ID транзакции: {transaction.id}
+🆔 ID транзакции: {transaction_id}
📱 Параметры подписки:
📅 Период: {period_days} дней
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
index 9bb8bd8f..18200aa6 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -217,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,
@@ -236,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"✅ Подписка успешно активирована!\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
diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py
index 886c7d45..3ef4831a 100644
--- a/app/services/subscription_purchase_service.py
+++ b/app/services/subscription_purchase_service.py
@@ -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()
diff --git a/app/services/user_service.py b/app/services/user_service.py
index 9dc4012f..a16e0b10 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -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}")
diff --git a/app/states.py b/app/states.py
index 98343446..549e314f 100644
--- a/app/states.py
+++ b/app/states.py
@@ -18,6 +18,9 @@ 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()
@@ -33,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()