Merge pull request #2475 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-01-31 20:49:48 +03:00
committed by GitHub
3 changed files with 388 additions and 1 deletions

View File

@@ -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,

View File

@@ -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,
},
)
# ============================================================================
# Уведомления об автопродлении
# ============================================================================

View File

@@ -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',
(
'✅ <b>Трафик добавлен автоматически!</b>\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: