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,