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:")) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index b8bbb1e8..2b4e4ca5 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1000,7 +1000,7 @@ def get_subscription_keyboard( settings_row.append( InlineKeyboardButton( text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), - callback_data="tariff_switch" + callback_data="instant_switch" ) ) keyboard.append(settings_row) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index e5b70dc3..227772aa 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6543,6 +6543,271 @@ async def purchase_tariff_endpoint( ) +def _calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days: int, + promo_group=None, +) -> tuple[int, bool]: + """ + Рассчитывает стоимость переключения тарифа. + + Returns: + (cost_kopeks, is_upgrade) - стоимость доплаты и флаг апгрейда + """ + # Берём месячную цену (30 дней) как базу + current_monthly = current_tariff.get_price_for_period(30) or 0 + new_monthly = new_tariff.get_price_for_period(30) or 0 + + # Применяем скидку промогруппы + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount = max(0, min(100, int(v))) + current_monthly = int(current_monthly * (100 - discount) / 100) + new_monthly = int(new_monthly * (100 - discount) / 100) + break + except (TypeError, ValueError): + pass + + price_diff = new_monthly - current_monthly + + if price_diff <= 0: + # Даунгрейд или равная цена - бесплатно + return 0, False + + # Апгрейд - рассчитываем доплату пропорционально оставшимся дням + upgrade_cost = int(price_diff * remaining_days / 30) + return upgrade_cost, True + + +@router.post("/subscription/tariff/switch/preview") +async def preview_tariff_switch_endpoint( + payload: MiniAppTariffSwitchRequest, + db: AsyncSession = Depends(get_db_session), +): + """Предпросмотр переключения тарифа - показывает стоимость.""" + from app.webapi.schemas.miniapp import MiniAppTariffSwitchRequest, MiniAppTariffSwitchPreviewResponse + from datetime import datetime + + user = await _authorize_miniapp_user(payload.init_data, db) + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"}, + ) + + subscription = getattr(user, "subscription", None) + if not subscription or not subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + ) + + if subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + ) + + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, payload.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + ) + + if subscription.tariff_id == payload.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "same_tariff", "message": "Already on this tariff"}, + ) + + # Проверяем доступность тарифа для пользователя + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "tariff_not_available", "message": "Tariff not available for your promo group"}, + ) + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date and subscription.end_date > datetime.utcnow(): + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Рассчитываем стоимость переключения + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group + ) + + balance = user.balance_kopeks or 0 + has_enough = balance >= upgrade_cost + missing = max(0, upgrade_cost - balance) if not has_enough else 0 + + return MiniAppTariffSwitchPreviewResponse( + can_switch=has_enough, + current_tariff_id=current_tariff.id if current_tariff else None, + current_tariff_name=current_tariff.name if current_tariff else None, + new_tariff_id=new_tariff.id, + new_tariff_name=new_tariff.name, + remaining_days=remaining_days, + upgrade_cost_kopeks=upgrade_cost, + upgrade_cost_label=settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", + balance_kopeks=balance, + balance_label=settings.format_price(balance), + has_enough_balance=has_enough, + missing_amount_kopeks=missing, + missing_amount_label=settings.format_price(missing) if missing > 0 else "", + is_upgrade=is_upgrade, + message=None, + ) + + +@router.post("/subscription/tariff/switch") +async def switch_tariff_endpoint( + payload: MiniAppTariffSwitchRequest, + db: AsyncSession = Depends(get_db_session), +): + """Переключение тарифа без изменения даты окончания.""" + from app.webapi.schemas.miniapp import MiniAppTariffSwitchRequest, MiniAppTariffSwitchResponse + from datetime import datetime + + user = await _authorize_miniapp_user(payload.init_data, db) + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"}, + ) + + subscription = getattr(user, "subscription", None) + if not subscription or not subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + ) + + if subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + ) + + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, payload.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + ) + + if subscription.tariff_id == payload.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "same_tariff", "message": "Already on this tariff"}, + ) + + # Проверяем доступность тарифа + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "tariff_not_available", "message": "Tariff not available"}, + ) + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date and subscription.end_date > datetime.utcnow(): + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Рассчитываем стоимость + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group + ) + + # Списываем доплату если апгрейд + if upgrade_cost > 0: + if user.balance_kopeks < upgrade_cost: + missing = upgrade_cost - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", + "missing_amount": missing, + }, + ) + + description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" + success = await subtract_user_balance(db, user, upgrade_cost, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "balance_error", "message": "Failed to charge balance"}, + ) + + # Записываем транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=upgrade_cost, + description=description, + ) + + # Обновляем подписку - меняем тариф без изменения даты + 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 = new_tariff.allowed_squads or [] + # Сбрасываем докупленный трафик при смене тарифа + subscription.purchased_traffic_gb = 0 + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + # Синхронизируем с RemnaWave + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при смене тарифа: {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if upgrade_cost > 0: + if lang == "ru": + message = f"Тариф изменён на '{new_tariff.name}'. Списано {settings.format_price(upgrade_cost)}" + else: + message = f"Switched to '{new_tariff.name}'. Charged {settings.format_price(upgrade_cost)}" + else: + if lang == "ru": + message = f"Тариф изменён на '{new_tariff.name}'" + else: + message = f"Switched to '{new_tariff.name}'" + + return MiniAppTariffSwitchResponse( + success=True, + message=message, + tariff_id=new_tariff.id, + tariff_name=new_tariff.name, + charged_kopeks=upgrade_cost, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) + + @router.post("/subscription/traffic-topup") async def purchase_traffic_topup_endpoint( payload: MiniAppTrafficTopupRequest, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 723e492c..68693a8f 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -607,6 +607,42 @@ class MiniAppTariffPurchaseResponse(BaseModel): balance_label: Optional[str] = None +class MiniAppTariffSwitchRequest(BaseModel): + """Запрос на переключение тарифа (без выбора периода).""" + init_data: str = Field(..., alias="initData") + tariff_id: int = Field(..., alias="tariffId") + + +class MiniAppTariffSwitchPreviewResponse(BaseModel): + """Предпросмотр переключения тарифа.""" + can_switch: bool = True + current_tariff_id: Optional[int] = None + current_tariff_name: Optional[str] = None + new_tariff_id: int + new_tariff_name: str + remaining_days: int = 0 + upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена + upgrade_cost_label: str = "" + balance_kopeks: int = 0 + balance_label: str = "" + has_enough_balance: bool = True + missing_amount_kopeks: int = 0 + missing_amount_label: str = "" + is_upgrade: bool = False # True если новый тариф дороже + message: Optional[str] = None + + +class MiniAppTariffSwitchResponse(BaseModel): + """Ответ на переключение тарифа.""" + success: bool = True + message: Optional[str] = None + tariff_id: int + tariff_name: str + charged_kopeks: int = 0 + balance_kopeks: int = 0 + balance_label: str = "" + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None diff --git a/miniapp/index.html b/miniapp/index.html index 0c3b58ba..22cbf20e 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -816,6 +816,285 @@ background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.08)); } + /* ============================================ + Instant Tariff Switch Styles + ============================================ */ + .instant-switch-current-info { + display: flex; + gap: 16px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 12px; + margin-bottom: 12px; + } + + .instant-switch-current-tariff, + .instant-switch-remaining { + flex: 1; + } + + .instant-switch-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + + .instant-switch-value { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .instant-switch-hint { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border: 1px solid rgba(59, 130, 246, 0.15); + border-radius: 10px; + margin-bottom: 4px; + } + + .instant-switch-hint-icon { + font-size: 18px; + flex-shrink: 0; + } + + .instant-switch-hint-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + /* Tariff item in instant switch */ + .instant-switch-tariff-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; + } + + .instant-switch-tariff-item:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.04); + } + + .instant-switch-tariff-item.current { + opacity: 0.5; + cursor: default; + pointer-events: none; + } + + .instant-switch-tariff-info { + flex: 1; + min-width: 0; + } + + .instant-switch-tariff-name { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 4px; + } + + .instant-switch-tariff-details { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-secondary); + } + + .instant-switch-tariff-cost { + text-align: right; + flex-shrink: 0; + } + + .instant-switch-cost-badge { + display: inline-block; + padding: 6px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + } + + .instant-switch-cost-badge.upgrade { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.15) 0%, rgba(249, 115, 22, 0.08) 100%); + color: #f97316; + border: 1px solid rgba(249, 115, 22, 0.25); + } + + .instant-switch-cost-badge.free { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(34, 197, 94, 0.08) 100%); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.25); + } + + /* Confirmation panel */ + .instant-switch-confirm { + margin-top: 20px; + padding: 20px; + background: var(--bg-secondary); + border-radius: 16px; + border: 2px solid var(--primary); + animation: slideUp 0.3s ease; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .instant-switch-confirm-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .instant-switch-confirm-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .instant-switch-confirm-close { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--bg-tertiary, var(--bg-primary)); + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .instant-switch-confirm-close:hover { + background: var(--bg-primary); + color: var(--text-primary); + } + + .instant-switch-compare { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-bottom: 16px; + } + + .instant-switch-compare-item { + text-align: center; + flex: 1; + } + + .instant-switch-compare-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + margin-bottom: 4px; + } + + .instant-switch-compare-value { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .instant-switch-compare-arrow { + font-size: 20px; + color: var(--primary); + flex-shrink: 0; + } + + .instant-switch-cost { + text-align: center; + padding: 12px; + background: var(--bg-primary); + border-radius: 10px; + margin-bottom: 12px; + } + + .instant-switch-cost-label { + font-size: 13px; + color: var(--text-secondary); + } + + .instant-switch-cost-value { + font-size: 18px; + font-weight: 700; + color: var(--primary); + margin-left: 8px; + } + + .instant-switch-cost-value.free { + color: #22c55e; + } + + .instant-switch-balance { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; + } + + .instant-switch-insufficient { + text-align: center; + font-size: 13px; + color: #ef4444; + padding: 10px; + background: rgba(239, 68, 68, 0.1); + border-radius: 8px; + margin-bottom: 12px; + } + + .instant-switch-confirm-actions { + display: flex; + gap: 10px; + margin-top: 16px; + } + + .instant-switch-confirm-actions .btn { + flex: 1; + } + + .instant-switch-confirm-actions .btn-secondary { + background: var(--bg-tertiary, var(--bg-primary)); + color: var(--text-primary); + border: 1px solid var(--border-color); + } + + :root[data-theme="dark"] .instant-switch-hint { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.06) 100%); + border-color: rgba(59, 130, 246, 0.2); + } + + :root[data-theme="dark"] .instant-switch-cost-badge.upgrade { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 100%); + color: #fb923c; + } + + :root[data-theme="dark"] .instant-switch-cost-badge.free { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%); + color: #4ade80; + } + .subscription-settings-toggle.active .tariff-name-badge { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.12)); box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.15); @@ -5543,6 +5822,96 @@ + + +