diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index bbcfc7e3..0262253d 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1357,6 +1357,493 @@ async def confirm_tariff_switch( await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) +# ==================== Мгновенное переключение тарифов (без выбора периода) ==================== + +def _calculate_instant_switch_cost( + current_tariff: Tariff, + new_tariff: Tariff, + remaining_days: int, + db_user: Optional[User] = None, +) -> tuple[int, bool]: + """ + Рассчитывает стоимость мгновенного переключения тарифа. + + Если новый тариф дороже - доплата пропорционально оставшимся дням. + Если дешевле или равен - бесплатно. + + Returns: + (upgrade_cost_kopeks, is_upgrade) + """ + # Получаем месячные цены тарифов + current_monthly = current_tariff.get_price_for_period(30) or 0 + new_monthly = new_tariff.get_price_for_period(30) or 0 + + # Применяем скидку промогруппы если есть + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, 30) + + if discount_percent > 0: + new_monthly = _apply_promo_discount(new_monthly, discount_percent) + + # Рассчитываем разницу + price_diff = new_monthly - current_monthly + + if price_diff <= 0: + # Downgrade или тот же уровень - бесплатно + return 0, False + + # Upgrade - доплата пропорционально оставшимся дням + upgrade_cost = int(price_diff * remaining_days / 30) + return upgrade_cost, True + + +def format_instant_switch_list_text( + tariffs: List[Tariff], + current_tariff: Tariff, + remaining_days: int, + db_user: Optional[User] = None, +) -> str: + """Форматирует текст со списком тарифов для мгновенного переключения.""" + lines = [ + "📦 Мгновенная смена тарифа", + f"📌 Текущий: {current_tariff.name}", + f"⏰ Осталось: {remaining_days} дн.", + "", + "💡 При переключении остаток дней сохраняется.", + "⬆️ Повышение тарифа = доплата за разницу", + "⬇️ Понижение = бесплатно", + "", + ] + + for tariff in tariffs: + if tariff.id == current_tariff.id: + continue + + traffic_gb = tariff.traffic_limit_gb + traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + + # Рассчитываем стоимость переключения + cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, tariff, remaining_days, db_user + ) + + if is_upgrade: + cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}" + else: + cost_text = "⬇️ Бесплатно" + + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {cost_text}") + + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + +def get_instant_switch_keyboard( + tariffs: List[Tariff], + current_tariff: Tariff, + remaining_days: int, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Создает клавиатуру для мгновенного переключения тарифа.""" + texts = get_texts(language) + buttons = [] + + for tariff in tariffs: + if tariff.id == current_tariff.id: + continue + + # Рассчитываем стоимость + cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, tariff, remaining_days, db_user + ) + + if is_upgrade: + btn_text = f"📦 {tariff.name} (+{_format_price_kopeks(cost, compact=True)})" + else: + btn_text = f"📦 {tariff.name} (бесплатно)" + + buttons.append([ + InlineKeyboardButton( + text=btn_text, + callback_data=f"instant_sw_preview:{tariff.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_instant_switch_confirm_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения мгновенного переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить переключение", + callback_data=f"instant_sw_confirm:{tariff_id}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="instant_switch" + ) + ] + ]) + + +def get_instant_switch_insufficient_balance_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для мгновенного переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="instant_switch" + ) + ] + ]) + + +@error_handler +async def show_instant_switch_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает список тарифов для мгновенного переключения.""" + from datetime import datetime + + texts = get_texts(db_user.language) + await state.clear() + + # Проверяем наличие активной подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + if not subscription.tariff_id: + await callback.answer("У вашей подписки нет тарифа", show_alert=True) + return + + # Получаем текущий тариф + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not current_tariff: + await callback.answer("Текущий тариф не найден", show_alert=True) + return + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + if remaining_days == 0: + await callback.message.edit_text( + "❌ Переключение недоступно\n\n" + "У вашей подписки не осталось активных дней.\n" + "Используйте продление или покупку нового тарифа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Получаем доступные тарифы + promo_group_id = getattr(db_user, 'promo_group_id', None) + tariffs = await get_tariffs_for_user(db, promo_group_id) + + # Фильтруем текущий тариф + available_tariffs = [t for t in tariffs if t.id != current_tariff.id] + + if not available_tariffs: + await callback.message.edit_text( + "😔 Нет доступных тарифов для переключения\n\n" + "Вы уже используете единственный доступный тариф.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Формируем текст со списком тарифов + switch_text = format_instant_switch_list_text( + tariffs, current_tariff, remaining_days, db_user + ) + + await callback.message.edit_text( + switch_text, + reply_markup=get_instant_switch_keyboard( + tariffs, current_tariff, remaining_days, db_user.language, db_user + ), + parse_mode="HTML" + ) + + await state.update_data( + current_tariff_id=current_tariff.id, + remaining_days=remaining_days, + ) + await callback.answer() + + +@error_handler +async def preview_instant_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает превью мгновенного переключения тарифа.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + new_tariff = await get_tariff_by_id(db, tariff_id) + + if not new_tariff or not new_tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем данные из состояния + data = await state.get_data() + current_tariff_id = data.get('current_tariff_id') + remaining_days = data.get('remaining_days', 0) + + # Если данных нет в state, получаем заново + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription or not subscription.tariff_id: + await callback.answer("Подписка не найдена", show_alert=True) + return + + current_tariff_id = current_tariff_id or subscription.tariff_id + current_tariff = await get_tariff_by_id(db, current_tariff_id) + if not current_tariff: + await callback.answer("Текущий тариф не найден", show_alert=True) + return + + if not remaining_days and subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + # Рассчитываем стоимость переключения + upgrade_cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, new_tariff, remaining_days, db_user + ) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + + traffic = _format_traffic(new_tariff.traffic_limit_gb) + current_traffic = _format_traffic(current_tariff.traffic_limit_gb) + + texts = get_texts(db_user.language) + + if is_upgrade: + # Upgrade - нужна доплата + if user_balance >= upgrade_cost: + await callback.message.edit_text( + f"⬆️ Повышение тарифа\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"💰 Доплата: {_format_price_kopeks(upgrade_cost)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = upgrade_cost - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + # Downgrade или тот же уровень - бесплатно + await callback.message.edit_text( + f"⬇️ Переключение тарифа\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"💰 Бесплатно (понижение/равный тариф)", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + upgrade_cost=upgrade_cost, + is_upgrade=is_upgrade, + current_tariff_id=current_tariff_id, + remaining_days=remaining_days, + ) + await callback.answer() + + +@error_handler +async def confirm_instant_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает мгновенное переключение тарифа.""" + from datetime import datetime, timedelta + + tariff_id = int(callback.data.split(":")[1]) + new_tariff = await get_tariff_by_id(db, tariff_id) + + if not new_tariff or not new_tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем данные из состояния + data = await state.get_data() + upgrade_cost = data.get('upgrade_cost', 0) + is_upgrade = data.get('is_upgrade', False) + remaining_days = data.get('remaining_days', 0) + + # Проверяем подписку + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("Подписка не найдена", show_alert=True) + return + + # Проверяем баланс если это upgrade + user_balance = db_user.balance_kopeks or 0 + if is_upgrade and user_balance < upgrade_cost: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс если это upgrade + if is_upgrade and upgrade_cost > 0: + success = await subtract_user_balance( + db, db_user, upgrade_cost, + f"Переключение на тариф {new_tariff.name}" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из нового тарифа + squads = new_tariff.allowed_squads or [] + + # Обновляем подписку с новыми параметрами тарифа + # НЕ меняем end_date - только параметры тарифа + subscription.tariff_id = new_tariff.id + subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + subscription.device_limit = new_tariff.device_limit + subscription.connected_squads = squads + + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, # Не сбрасываем трафик при переключении + reset_reason="мгновенное переключение тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}") + + # Создаем транзакцию если была оплата + if is_upgrade and upgrade_cost > 0: + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-upgrade_cost, + description=f"Переключение на тариф {new_tariff.name}", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + remaining_days, + was_trial_conversion=False, + amount_kopeks=upgrade_cost, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + await state.clear() + + traffic = _format_traffic(new_tariff.traffic_limit_gb) + + if is_upgrade: + cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + else: + cost_text = "💰 Бесплатно" + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"{cost_text}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) + + def register_tariff_purchase_handlers(dp: Dispatcher): """Регистрирует обработчики покупки по тарифам.""" # Список тарифов (для режима tariffs) @@ -1376,8 +1863,13 @@ def register_tariff_purchase_handlers(dp: Dispatcher): dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) - # Переключение тарифов + # Переключение тарифов (с выбором периода) dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch") dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:")) dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) + + # Мгновенное переключение тарифов (без выбора периода) + dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch") + dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:")) + dp.callback_query.register(confirm_instant_switch, F.data.startswith("instant_sw_confirm:"))