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()