diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 72a9b97a..3669895d 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -527,6 +527,33 @@ async def deactivate_subscription( return subscription +async def reactivate_subscription( + db: AsyncSession, + subscription: Subscription +) -> Subscription: + """Реактивация подписки (например, после повторной подписки на канал). + + Активирует только если подписка была DISABLED и ещё не истекла. + Не логирует если реактивация не требуется. + """ + now = datetime.utcnow() + + # Тихо выходим если реактивация не нужна + if subscription.status != SubscriptionStatus.DISABLED.value: + return subscription + + if subscription.end_date and subscription.end_date <= now: + return subscription + + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = now + + await db.commit() + await db.refresh(subscription) + + return subscription + + async def get_expiring_subscriptions( db: AsyncSession, days_before: int = 3 diff --git a/app/database/models.py b/app/database/models.py index 291db5af..db2f0507 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -993,6 +993,7 @@ class PromoCode(Base): valid_until = Column(DateTime, nullable=True) is_active = Column(Boolean, default=True) + first_purchase_only = Column(Boolean, default=False) # Только для первой покупки created_by = Column(Integer, ForeignKey("users.id"), nullable=True) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 7f91521b..07fdddcc 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -4630,6 +4630,38 @@ async def add_promocode_promo_group_column() -> bool: return False +async def add_promocode_first_purchase_only_column() -> bool: + """Добавляет колонку first_purchase_only в таблицу promocodes.""" + column_exists = await check_column_exists('promocodes', 'first_purchase_only') + if column_exists: + logger.info("Колонка first_purchase_only уже существует в promocodes") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute( + text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT 0") + ) + elif db_type == 'postgresql': + await conn.execute( + text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE") + ) + elif db_type == 'mysql': + await conn.execute( + text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE") + ) + + logger.info("✅ Добавлена колонка first_purchase_only в promocodes") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления first_purchase_only в promocodes: {error}") + return False + + async def migrate_contest_templates_prize_columns() -> bool: """Миграция contest_templates: prize_days -> prize_type + prize_value.""" try: @@ -5085,6 +5117,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes") + logger.info("=== ДОБАВЛЕНИЕ FIRST_PURCHASE_ONLY В PROMOCODES ===") + first_purchase_ready = await add_promocode_first_purchase_only_column() + if first_purchase_ready: + logger.info("✅ Колонка first_purchase_only в promocodes готова") + else: + logger.warning("⚠️ Проблемы с добавлением first_purchase_only в promocodes") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===") main_menu_buttons_created = await create_main_menu_buttons_table() if main_menu_buttons_created: diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py index 73aef95c..13ea48a4 100644 --- a/app/handlers/admin/promocodes.py +++ b/app/handlers/admin/promocodes.py @@ -130,6 +130,21 @@ async def show_promocodes_list( await callback.answer() +@admin_required +@error_handler +async def show_promocodes_list_page( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Обработчик пагинации списка промокодов.""" + try: + page = int(callback.data.split('_')[-1]) + except (ValueError, IndexError): + page = 1 + await show_promocodes_list(callback, db_user, db, page=page) + + @admin_required @error_handler async def show_promocode_management( @@ -172,27 +187,39 @@ async def show_promocode_management( if promo.valid_until: text += f"⏰ Действует до: {format_datetime(promo.valid_until)}\n" - + + first_purchase_only = getattr(promo, 'first_purchase_only', False) + first_purchase_emoji = "✅" if first_purchase_only else "❌" + text += f"🆕 Только первая покупка: {first_purchase_emoji}\n" + text += f"📅 Создан: {format_datetime(promo.created_at)}\n" - + + first_purchase_btn_text = "🆕 Первая покупка: ✅" if first_purchase_only else "🆕 Первая покупка: ❌" + keyboard = [ [ types.InlineKeyboardButton( - text="✏️ Редактировать", + text="✏️ Редактировать", callback_data=f"promo_edit_{promo.id}" ), types.InlineKeyboardButton( - text="🔄 Переключить статус", + text="🔄 Переключить статус", callback_data=f"promo_toggle_{promo.id}" ) ], [ types.InlineKeyboardButton( - text="📊 Статистика", + text=first_purchase_btn_text, + callback_data=f"promo_toggle_first_{promo.id}" + ) + ], + [ + types.InlineKeyboardButton( + text="📊 Статистика", callback_data=f"promo_stats_{promo.id}" ), types.InlineKeyboardButton( - text="🗑️ Удалить", + text="🗑️ Удалить", callback_data=f"promo_delete_{promo.id}" ) ], @@ -931,7 +958,31 @@ async def toggle_promocode_status( status_text = "активирован" if new_status else "деактивирован" await callback.answer(f"✅ Промокод {status_text}", show_alert=True) - + + await show_promocode_management(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_promocode_first_purchase( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Переключает режим 'только для первой покупки'.""" + promo_id = int(callback.data.split('_')[-1]) + + promo = await get_promocode_by_id(db, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + new_status = not getattr(promo, 'first_purchase_only', False) + await update_promocode(db, promo, first_purchase_only=new_status) + + status_text = "включён" if new_status else "выключен" + await callback.answer(f"✅ Режим 'первая покупка' {status_text}", show_alert=True) + await show_promocode_management(callback, db_user, db) @@ -1103,11 +1154,13 @@ async def show_general_promocode_stats( def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes") dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list") + dp.callback_query.register(show_promocodes_list_page, F.data.startswith("admin_promo_list_page_")) dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create") dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_")) dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_")) dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_")) + dp.callback_query.register(toggle_promocode_first_purchase, F.data.startswith("promo_toggle_first_")) dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_")) dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_")) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 72fcc793..092b40cc 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1291,7 +1291,7 @@ async def handle_activate_button( server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else [] balance = db_user.balance_kopeks - available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True) + available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) subscription_service = SubscriptionService() diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index f8303acb..79c377ed 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -125,6 +125,10 @@ async def process_promocode( "expired": texts.PROMOCODE_EXPIRED, "used": texts.PROMOCODE_USED, "already_used_by_user": texts.PROMOCODE_USED, + "not_first_purchase": texts.t( + "PROMOCODE_NOT_FIRST_PURCHASE", + "❌ Этот промокод доступен только для первой покупки" + ), "server_error": texts.ERROR } diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 250f96ef..a59145e9 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -62,6 +62,15 @@ async def start_simple_subscription_purchase( device_limit = resolve_simple_subscription_device_limit() + # При продлении учитываем количество устройств из текущей подписки + if current_subscription and settings.is_devices_selection_enabled(): + current_device_limit = current_subscription.device_limit or device_limit + # Модем добавляет +1 к device_limit, но оплачивается отдельно + if getattr(current_subscription, 'modem_enabled', False): + current_device_limit = max(1, current_device_limit - 1) + # Используем максимум из текущего и дефолтного + device_limit = max(device_limit, current_device_limit) + # Подготовим параметры простой подписки subscription_params = { "period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 43a68d47..cac399fb 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -9,7 +9,7 @@ from aiogram.enums import ChatMemberStatus from app.config import settings from app.database.database import get_db from app.database.crud.campaign import get_campaign_by_start_parameter -from app.database.crud.subscription import deactivate_subscription +from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id from app.database.models import SubscriptionStatus from app.keyboards.inline import get_channel_sub_keyboard @@ -104,12 +104,15 @@ class ChannelCheckerMiddleware(BaseMiddleware): member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id) if member.status in self.GOOD_MEMBER_STATUS: + # Реактивируем подписку если была отключена из-за отписки от канала + if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): + await self._reactivate_subscription_on_subscribe(telegram_id, bot) return await handler(event, data) elif member.status in self.BAD_MEMBER_STATUS: logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})") if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): - await self._deactivate_subscription_on_unsubscribe(telegram_id) + await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link) await self._capture_start_payload(state, event, bot) @@ -253,7 +256,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): finally: break - async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None: + async def _deactivate_subscription_on_unsubscribe( + self, telegram_id: int, bot: Bot, channel_link: Optional[str] + ) -> None: if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: logger.debug( "ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено", @@ -308,6 +313,24 @@ class ChannelCheckerMiddleware(BaseMiddleware): user.remnawave_uuid, api_error, ) + + # Уведомляем пользователя о деактивации + try: + texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) + notification_text = texts.t( + "SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE", + "🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n" + "Подпишитесь на канал снова, чтобы восстановить доступ к VPN." + ) + channel_kb = get_channel_sub_keyboard(channel_link, language=user.language) + await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) + logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}") + except Exception as notify_error: + logger.error( + "❌ Не удалось отправить уведомление о деактивации пользователю %s: %s", + telegram_id, + notify_error, + ) except Exception as db_error: logger.error( "❌ Ошибка деактивации подписки пользователя %s после отписки: %s", @@ -317,6 +340,77 @@ class ChannelCheckerMiddleware(BaseMiddleware): finally: break + async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None: + """Реактивация подписки после повторной подписки на канал. + + Вызывается только если подписка в статусе DISABLED. + """ + if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: + return + + async for db in get_db(): + try: + user = await get_user_by_telegram_id(db, telegram_id) + if not user or not user.subscription: + break + + subscription = user.subscription + + # Реактивируем только DISABLED подписки (деактивированные из-за отписки) + # Тихо выходим если подписка не требует реактивации — без логов + if subscription.status != SubscriptionStatus.DISABLED.value: + break + + # Проверяем что подписка ещё не истекла + from datetime import datetime + if subscription.end_date and subscription.end_date <= datetime.utcnow(): + break + + # Реактивируем в БД + await reactivate_subscription(db, subscription) + sub_type = "Триальная" if subscription.is_trial else "Платная" + logger.info( + "✅ %s подписка пользователя %s реактивирована после подписки на канал", + sub_type, + telegram_id, + ) + + # Включаем в RemnaWave + if user.remnawave_uuid: + service = SubscriptionService() + try: + await service.enable_remnawave_user(user.remnawave_uuid) + except Exception as api_error: + logger.error( + "❌ Не удалось включить пользователя RemnaWave %s: %s", + user.remnawave_uuid, + api_error, + ) + + # Уведомляем пользователя о реактивации + try: + texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) + notification_text = texts.t( + "SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE", + "✅ Ваша подписка восстановлена!\n\n" + "Спасибо, что подписались на канал. VPN снова работает." + ) + await bot.send_message(telegram_id, notification_text) + except Exception as notify_error: + logger.warning( + "Не удалось отправить уведомление о реактивации пользователю %s: %s", + telegram_id, + notify_error, + ) + except Exception as db_error: + logger.error( + "❌ Ошибка реактивации подписки пользователя %s: %s", + telegram_id, + db_error, + ) + finally: + break + @staticmethod async def _deny_message( event: TelegramObject, diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py index 992139b1..548519b3 100644 --- a/app/services/promocode_service.py +++ b/app/services/promocode_service.py @@ -51,7 +51,13 @@ class PromoCodeService: existing_use = await check_user_promocode_usage(db, user_id, promocode.id) if existing_use: return {"success": False, "error": "already_used_by_user"} - + + # Проверка "только для первой покупки" + if getattr(promocode, 'first_purchase_only', False): + has_purchase = await self._user_has_paid_purchase(db, user_id) + if has_purchase: + return {"success": False, "error": "not_first_purchase"} + balance_before_kopeks = user.balance_kopeks result_description = await self._apply_promocode_effects(db, user, promocode) @@ -228,3 +234,20 @@ class PromoCodeService: effects.append("ℹ️ У вас уже есть активная подписка") return "\n".join(effects) if effects else "✅ Промокод активирован" + + async def _user_has_paid_purchase(self, db: AsyncSession, user_id: int) -> bool: + """Проверяет была ли у пользователя хотя бы одна успешная платная покупка.""" + from sqlalchemy import select, func + from app.database.models import Transaction + + result = await db.execute( + select(func.count(Transaction.id)) + .where( + Transaction.user_id == user_id, + Transaction.status == "success", + Transaction.amount_kopeks > 0, # Платные транзакции + Transaction.type.in_(["subscription", "balance_topup", "renewal"]) + ) + ) + count = result.scalar() + return count > 0 diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 4e11af5b..fdeacad9 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -708,7 +708,7 @@ async def auto_activate_subscription_after_topup( server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else [] balance = user.balance_kopeks - available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True) + available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) if not available_periods: logger.warning("🔁 Автоактивация: нет доступных периодов подписки") diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index 3dc89dcd..1ddafb60 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -331,6 +331,11 @@ class SubscriptionRenewalService: if devices_limit is None: devices_limit = settings.DEFAULT_DEVICE_LIMIT + # Модем добавляет +1 к device_limit, но оплачивается отдельно, + # поэтому не должен учитываться как платное устройство при продлении + if getattr(subscription, 'modem_enabled', False): + devices_limit = max(1, devices_limit - 1) + total_cost, details = await calculate_subscription_total_cost( db, period_days, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f5767f3a..990e963d 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -396,11 +396,23 @@ class SubscriptionService: await api.disable_user(user_uuid) logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}") return True - + except Exception as e: logger.error(f"Ошибка отключения RemnaWave пользователя: {e}") return False - + + async def enable_remnawave_user(self, user_uuid: str) -> bool: + """Включить пользователя в RemnaWave (реактивация).""" + try: + async with self.get_api_client() as api: + await api.enable_user(user_uuid) + logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}") + return True + + except Exception as e: + logger.error(f"Ошибка включения RemnaWave пользователя: {e}") + return False + async def revoke_subscription( self, db: AsyncSession, @@ -720,6 +732,11 @@ class SubscriptionService: else: device_limit = forced_limit + # Модем добавляет +1 к device_limit, но оплачивается отдельно, + # поэтому не должен учитываться как платное устройство при продлении + if getattr(subscription, 'modem_enabled', False): + device_limit = max(1, device_limit - 1) + devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE devices_discount_percent = _resolve_discount_percent( user,