diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 0a722660..ef39f54b 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -666,9 +666,35 @@ async def purchase_traffic( # Проверяем баланс if user.balance_kopeks < final_price: + missing = final_price - user.balance_kopeks + + # Save cart for auto-purchase after balance top-up + cart_data = { + 'cart_mode': 'add_traffic', + 'subscription_id': subscription.id, + 'traffic_gb': request.gb, + 'price_kopeks': final_price, + 'base_price_kopeks': base_price_kopeks, + 'discount_percent': traffic_discount_percent, + 'source': 'cabinet', + 'description': f'Докупка {request.gb} ГБ трафика', + } + + try: + await user_cart_service.save_user_cart(user.id, cart_data) + logger.info(f'Cart saved for traffic purchase (cabinet) user {user.id}: +{request.gb} GB') + except Exception as e: + logger.error(f'Error saving cart for traffic purchase (cabinet): {e}') + raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB', + detail={ + 'code': 'insufficient_funds', + 'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}', + 'missing_amount': missing, + 'cart_saved': True, + 'cart_mode': 'add_traffic', + }, ) # Формируем описание @@ -1940,6 +1966,122 @@ async def purchase_devices( ) +@router.post('/traffic/save-cart') +async def save_traffic_cart( + request: TrafficPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> dict[str, bool]: + """Save cart for traffic purchase (for insufficient balance flow).""" + from app.utils.pricing_utils import calculate_prorated_price + + await db.refresh(user, ['subscription']) + subscription = user.subscription + + if not subscription: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='У вас нет активной подписки', + ) + + if subscription.status not in ['active', 'trial']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Ваша подписка неактивна', + ) + + if subscription.is_trial: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Докупка трафика недоступна на пробном периоде', + ) + + if subscription.traffic_limit_gb == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='У вас уже безлимитный трафик', + ) + + # Get traffic price from tariff or settings + tariff = None + base_price_kopeks = 0 + is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id + + if is_tariff_mode: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Тариф не найден', + ) + + if not getattr(tariff, 'traffic_topup_enabled', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Докупка трафика недоступна на вашем тарифе', + ) + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if request.gb not in packages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Пакет трафика {request.gb} ГБ недоступен', + ) + base_price_kopeks = packages[request.gb] + else: + if not settings.is_traffic_topup_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Докупка трафика отключена', + ) + + packages = settings.get_traffic_packages() + matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None) + if not matching_pkg: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Недоступный пакет трафика', + ) + base_price_kopeks = matching_pkg['price'] + + # Apply promo group discount + traffic_discount_percent = 0 + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + + # Calculate prorated price + final_price, _ = calculate_prorated_price( + base_price_kopeks, + subscription.end_date, + ) + + # Save cart for auto-purchase after balance top-up + cart_data = { + 'cart_mode': 'add_traffic', + 'subscription_id': subscription.id, + 'traffic_gb': request.gb, + 'price_kopeks': final_price, + 'base_price_kopeks': base_price_kopeks, + 'discount_percent': traffic_discount_percent, + 'source': 'cabinet', + 'description': f'Докупка {request.gb} ГБ трафика', + } + await user_cart_service.save_user_cart(user.id, cart_data) + logger.info(f'Cart saved for traffic purchase (cabinet save-cart) user {user.id}: +{request.gb} GB') + + return {'success': True, 'cart_saved': True} + + @router.post('/devices/save-cart') async def save_devices_cart( request: DevicePurchaseRequest, diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py index c9f2ed9e..60665552 100644 --- a/app/cabinet/routes/websocket.py +++ b/app/cabinet/routes/websocket.py @@ -393,6 +393,25 @@ async def notify_user_devices_purchased( ) +async def notify_user_traffic_purchased( + user_id: int, + traffic_gb_added: int, + new_traffic_limit_gb: int, + amount_kopeks: int, +) -> None: + """Уведомить пользователя о покупке трафика.""" + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.traffic_purchased', + 'traffic_gb_added': traffic_gb_added, + 'new_traffic_limit_gb': new_traffic_limit_gb, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + }, + ) + + # ============================================================================ # Уведомления об автопродлении # ============================================================================ diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 20fe7f1f..b03a69d2 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -1334,6 +1334,228 @@ async def _auto_add_devices( return True +async def _auto_add_traffic( + db: AsyncSession, + user: User, + cart_data: dict, + *, + bot: Bot | None = None, +) -> bool: + """Auto-purchase traffic from saved cart after balance topup.""" + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + from app.database.crud.subscription import add_subscription_traffic, get_subscription_by_user_id + from app.database.crud.user import subtract_user_balance + from app.database.models import PaymentMethod + + traffic_gb = _safe_int(cart_data.get('traffic_gb')) + price_kopeks = _safe_int(cart_data.get('price_kopeks')) + + if traffic_gb <= 0 or price_kopeks <= 0: + logger.warning( + '🔁 Автопокупка трафика: некорректные данные корзины для пользователя %s (traffic_gb=%s, price=%s)', + _format_user_id(user), + traffic_gb, + price_kopeks, + ) + return False + + # Verify balance + if user.balance_kopeks < price_kopeks: + logger.info( + '🔁 Автопокупка трафика: у пользователя %s недостаточно средств (%s < %s)', + _format_user_id(user), + user.balance_kopeks, + price_kopeks, + ) + return False + + # Verify subscription + subscription = await get_subscription_by_user_id(db, user.id) + if not subscription: + logger.warning( + '🔁 Автопокупка трафика: у пользователя %s нет подписки', + _format_user_id(user), + ) + await user_cart_service.delete_user_cart(user.id) + return False + + if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'): + logger.warning( + '🔁 Автопокупка трафика: подписка пользователя %s не активна (status=%s)', + _format_user_id(user), + subscription.status, + ) + await user_cart_service.delete_user_cart(user.id) + return False + + if subscription.is_trial: + logger.warning( + '🔁 Автопокупка трафика: у пользователя %s пробная подписка', + _format_user_id(user), + ) + await user_cart_service.delete_user_cart(user.id) + return False + + if subscription.traffic_limit_gb == 0: + logger.warning( + '🔁 Автопокупка трафика: у пользователя %s уже безлимитный трафик', + _format_user_id(user), + ) + await user_cart_service.delete_user_cart(user.id) + return False + + # Deduct balance + description = f'Докупка {traffic_gb} ГБ трафика' + try: + success = await subtract_user_balance( + db, + user, + price_kopeks, + description, + create_transaction=True, + payment_method=PaymentMethod.BALANCE, + ) + if not success: + logger.warning( + '❌ Автопокупка трафика: не удалось списать баланс пользователя %s', + _format_user_id(user), + ) + return False + except Exception as error: + logger.error( + '❌ Автопокупка трафика: ошибка списания баланса пользователя %s: %s', + _format_user_id(user), + error, + exc_info=True, + ) + return False + + # Add traffic + old_traffic_limit = subscription.traffic_limit_gb or 0 + try: + await add_subscription_traffic(db, subscription, traffic_gb) + await db.commit() + await db.refresh(subscription) + except Exception as error: + logger.error( + '❌ Автопокупка трафика: ошибка добавления трафика пользователю %s: %s', + _format_user_id(user), + error, + exc_info=True, + ) + await db.rollback() + return False + + # Sync with RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + except Exception as error: + logger.warning( + '⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя %s: %s', + _format_user_id(user), + error, + ) + + # Clear cart (transaction already created in subtract_user_balance) + await user_cart_service.delete_user_cart(user.id) + + logger.info( + '✅ Автопокупка трафика: пользователь %s добавил %s ГБ (было %s, стало %s) за %s коп.', + _format_user_id(user), + traffic_gb, + old_traffic_limit, + subscription.traffic_limit_gb, + price_kopeks, + ) + + # WebSocket notification for cabinet + try: + from app.cabinet.routes.websocket import notify_user_traffic_purchased + + await notify_user_traffic_purchased( + user_id=user.id, + traffic_gb_added=traffic_gb, + new_traffic_limit_gb=subscription.traffic_limit_gb or 0, + amount_kopeks=price_kopeks, + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автопокупка трафика: не удалось отправить WebSocket уведомление: %s', + ws_error, + ) + + # User notification + if bot and user.telegram_id: + texts = get_texts(getattr(user, 'language', 'ru')) + try: + message = texts.t( + 'AUTO_PURCHASE_TRAFFIC_SUCCESS', + ( + '✅ Трафик добавлен автоматически!\n\n' + '📈 Добавлено: {traffic_gb} ГБ\n' + '📊 Новый лимит: {new_limit} ГБ\n' + '💰 Списано: {price}' + ), + ).format( + traffic_gb=traffic_gb, + new_limit=subscription.traffic_limit_gb, + price=texts.format_price(price_kopeks), + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + + await bot.send_message( + chat_id=user.telegram_id, + text=message, + reply_markup=keyboard, + parse_mode='HTML', + ) + except Exception as error: + logger.warning( + '⚠️ Автопокупка трафика: не удалось уведомить пользователя %s: %s', + user.telegram_id, + error, + ) + + # Admin notification + if bot: + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_update_notification( + db, + user, + subscription, + 'traffic', + old_traffic_limit, + subscription.traffic_limit_gb, + price_kopeks, + ) + except Exception as error: + logger.warning( + '⚠️ Автопокупка трафика: не удалось уведомить админов: %s', + error, + ) + + return True + + async def auto_purchase_saved_cart_after_topup( db: AsyncSession, user: User, @@ -1407,6 +1629,10 @@ async def auto_purchase_saved_cart_after_topup( if cart_mode == 'add_devices': return await _auto_add_devices(db, user, cart_data, bot=bot) + # Обработка докупки трафика + if cart_mode == 'add_traffic': + return await _auto_add_traffic(db, user, cart_data, bot=bot) + try: prepared = await _prepare_auto_purchase(db, user, cart_data) except PurchaseValidationError as error: