mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-01 07:42:30 +00:00
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления об автопродлении
|
||||
# ============================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user