From 418d329b7587d26f00e833e5bd535acb7a9a91c7 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 31 Jan 2026 20:45:55 +0300 Subject: [PATCH 1/2] Update subscription_auto_purchase_service.py --- .../subscription_auto_purchase_service.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) 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: From a7712c715143c4d9f3d4edf66e9fc0382cfd8f0e Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 31 Jan 2026 20:46:25 +0300 Subject: [PATCH 2/2] Add files via upload --- app/cabinet/routes/subscription.py | 144 ++++++++++++++++++++++++++++- app/cabinet/routes/websocket.py | 19 ++++ 2 files changed, 162 insertions(+), 1 deletion(-) 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, + }, + ) + + # ============================================================================ # УвСдомлСния ΠΎΠ± Π°Π²Ρ‚ΠΎΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ # ============================================================================