diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py
index fde824bd..f7a10057 100644
--- a/app/cabinet/routes/subscription.py
+++ b/app/cabinet/routes/subscription.py
@@ -31,6 +31,7 @@ from app.services.subscription_purchase_service import (
PurchaseValidationError,
PurchaseBalanceError,
)
+from app.services.user_cart_service import user_cart_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import (
@@ -334,9 +335,60 @@ async def renew_subscription(
# Check balance
if user.balance_kopeks < price_kopeks:
+ missing = price_kopeks - user.balance_kopeks
+
+ # Get tariff info for cart
+ tariff_id = user.subscription.tariff_id
+ tariff_name = None
+ tariff_traffic_limit_gb = None
+ tariff_device_limit = None
+ tariff_allowed_squads = None
+
+ if tariff_id:
+ tariff = await get_tariff_by_id(db, tariff_id)
+ if tariff:
+ tariff_name = tariff.name
+ tariff_traffic_limit_gb = tariff.traffic_limit_gb
+ tariff_device_limit = tariff.device_limit
+ tariff_allowed_squads = tariff.allowed_squads or []
+
+ # Save cart for auto-purchase after balance top-up
+ cart_data = {
+ 'cart_mode': 'extend',
+ 'subscription_id': user.subscription.id,
+ 'tariff_id': tariff_id,
+ 'period_days': request.period_days,
+ 'total_price': price_kopeks,
+ 'user_id': user.id,
+ 'saved_cart': True,
+ 'missing_amount': missing,
+ 'return_to_cart': True,
+ 'description': f"Продление подписки на {request.period_days} дней" + (f" ({tariff_name})" if tariff_name else ""),
+ 'discount_percent': discount_percent,
+ 'source': 'cabinet',
+ }
+
+ # Add tariff parameters for tariffs mode
+ if tariff_id:
+ cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
+ cart_data['device_limit'] = tariff_device_limit
+ cart_data['allowed_squads'] = tariff_allowed_squads
+
+ try:
+ await user_cart_service.save_user_cart(user.id, cart_data)
+ logger.info(f"Cart saved for auto-renewal (cabinet) user {user.id}")
+ except Exception as e:
+ logger.error(f"Error saving cart for auto-renewal (cabinet): {e}")
+
raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Insufficient balance. Need {price_kopeks / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB",
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
+ detail={
+ "code": "insufficient_funds",
+ "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
+ "missing_amount": missing,
+ "cart_saved": True,
+ "cart_mode": "extend",
+ },
)
# Deduct balance and extend subscription
@@ -1077,7 +1129,14 @@ async def preview_purchase(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Dict[str, Any]:
- """Calculate and preview the total price for selected options."""
+ """Calculate and preview the total price for selected options (classic mode only)."""
+ # This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
+ if settings.is_tariffs_mode():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.",
+ )
+
try:
context = await purchase_service.build_options(db, user)
@@ -1115,7 +1174,14 @@ async def submit_purchase(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Dict[str, Any]:
- """Submit subscription purchase (deduct from balance)."""
+ """Submit subscription purchase (deduct from balance, classic mode only)."""
+ # This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
+ if settings.is_tariffs_mode():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.",
+ )
+
try:
context = await purchase_service.build_options(db, user)
@@ -1147,9 +1213,35 @@ async def submit_purchase(
detail=str(e),
)
except PurchaseBalanceError as e:
+ # Save cart for auto-purchase after balance top-up
+ try:
+ total_price = pricing.final_total if 'pricing' in locals() else 0
+ cart_data = {
+ 'cart_mode': 'subscription_purchase',
+ 'period_id': request.selection.period_id,
+ 'period_days': request.selection.period_days,
+ 'traffic_gb': request.selection.traffic_value, # _prepare_auto_purchase expects traffic_gb
+ 'countries': request.selection.servers, # _prepare_auto_purchase expects countries
+ 'devices': request.selection.devices,
+ 'total_price': total_price,
+ 'user_id': user.id,
+ 'saved_cart': True,
+ 'return_to_cart': True,
+ 'source': 'cabinet',
+ }
+ await user_cart_service.save_user_cart(user.id, cart_data)
+ logger.info(f"Cart saved for auto-purchase (cabinet /purchase) user {user.id}")
+ except Exception as cart_error:
+ logger.error(f"Error saving cart for auto-purchase (cabinet /purchase): {cart_error}")
+
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
- detail=str(e),
+ detail={
+ "code": "insufficient_funds",
+ "message": str(e),
+ "cart_saved": True,
+ "cart_mode": "subscription_purchase",
+ },
)
except Exception as e:
logger.error(f"Failed to submit purchase for user {user.id}: {e}")
@@ -1265,12 +1357,57 @@ async def purchase_tariff(
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
+
+ # Save cart for auto-purchase after balance top-up
+ if is_daily_tariff:
+ cart_data = {
+ 'cart_mode': 'daily_tariff_purchase',
+ 'tariff_id': tariff.id,
+ 'is_daily': True,
+ 'daily_price_kopeks': price_kopeks,
+ 'total_price': price_kopeks,
+ 'user_id': user.id,
+ 'saved_cart': True,
+ 'missing_amount': missing,
+ 'return_to_cart': True,
+ 'description': f"Покупка суточного тарифа {tariff.name}",
+ 'traffic_limit_gb': tariff.traffic_limit_gb,
+ 'device_limit': tariff.device_limit,
+ 'allowed_squads': tariff.allowed_squads or [],
+ 'source': 'cabinet',
+ }
+ else:
+ cart_data = {
+ 'cart_mode': 'tariff_purchase',
+ 'tariff_id': tariff.id,
+ 'period_days': period_days,
+ 'total_price': price_kopeks,
+ 'user_id': user.id,
+ 'saved_cart': True,
+ 'missing_amount': missing,
+ 'return_to_cart': True,
+ 'description': f"Покупка тарифа {tariff.name} на {period_days} дней",
+ 'traffic_limit_gb': traffic_limit_gb,
+ 'device_limit': tariff.device_limit,
+ 'allowed_squads': tariff.allowed_squads or [],
+ 'discount_percent': discount_percent,
+ 'source': 'cabinet',
+ }
+
+ try:
+ await user_cart_service.save_user_cart(user.id, cart_data)
+ logger.info(f"Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}")
+ except Exception as e:
+ logger.error(f"Error saving cart for auto-purchase (cabinet): {e}")
+
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
+ "cart_saved": True,
+ "cart_mode": cart_data['cart_mode'],
},
)
diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py
index 4b51d0e8..551398a8 100644
--- a/app/handlers/subscription/tariff_purchase.py
+++ b/app/handlers/subscription/tariff_purchase.py
@@ -621,13 +621,33 @@ async def select_tariff(
)
else:
missing = daily_price - user_balance
+
+ # Сохраняем данные корзины для автопокупки суточного тарифа
+ cart_data = {
+ 'cart_mode': 'daily_tariff_purchase',
+ 'tariff_id': tariff_id,
+ 'is_daily': True,
+ 'daily_price_kopeks': daily_price,
+ 'total_price': daily_price,
+ 'user_id': db_user.id,
+ 'saved_cart': True,
+ 'missing_amount': missing,
+ 'return_to_cart': True,
+ 'description': f"Покупка суточного тарифа {tariff.name}",
+ 'traffic_limit_gb': tariff.traffic_limit_gb,
+ 'device_limit': tariff.device_limit,
+ 'allowed_squads': tariff.allowed_squads or [],
+ }
+ await user_cart_service.save_user_cart(db_user.id, cart_data)
+
await callback.message.edit_text(
f"❌ Недостаточно средств\n\n"
f"📦 Тариф: {tariff.name}\n"
f"🔄 Тип: Суточный\n"
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
- f"⚠️ Не хватает: {_format_price_kopeks(missing)}",
+ f"⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n"
+ f"🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.",
reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
@@ -1087,15 +1107,35 @@ async def select_tariff_period(
parse_mode="HTML"
)
else:
- # Недостаточно средств
+ # Недостаточно средств - сохраняем корзину для автопокупки
missing = final_price - user_balance
+
+ # Сохраняем данные корзины для автопокупки после пополнения
+ cart_data = {
+ 'cart_mode': 'tariff_purchase',
+ 'tariff_id': tariff_id,
+ 'period_days': period,
+ 'total_price': final_price,
+ 'user_id': db_user.id,
+ 'saved_cart': True,
+ 'missing_amount': missing,
+ 'return_to_cart': True,
+ 'description': f"Покупка тарифа {tariff.name} на {period} дней",
+ 'traffic_limit_gb': tariff.traffic_limit_gb,
+ 'device_limit': tariff.device_limit,
+ 'allowed_squads': tariff.allowed_squads or [],
+ 'discount_percent': discount_percent,
+ }
+ await user_cart_service.save_user_cart(db_user.id, cart_data)
+
await callback.message.edit_text(
f"❌ Недостаточно средств\n\n"
f"📦 Тариф: {tariff.name}\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(final_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
- f"⚠️ Не хватает: {_format_price_kopeks(missing)}",
+ f"⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n"
+ f"🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
@@ -1584,13 +1624,37 @@ async def select_tariff_extend_period(
)
else:
missing = final_price - user_balance
+
+ # Получаем текущую подписку для сохранения в корзину
+ subscription = await get_subscription_by_user_id(db, db_user.id)
+
+ # Сохраняем данные корзины для автопокупки после пополнения
+ cart_data = {
+ 'cart_mode': 'extend',
+ 'tariff_id': tariff_id,
+ 'subscription_id': subscription.id if subscription else None,
+ 'period_days': period,
+ 'total_price': final_price,
+ 'user_id': db_user.id,
+ 'saved_cart': True,
+ 'missing_amount': missing,
+ 'return_to_cart': True,
+ 'description': f"Продление тарифа {tariff.name} на {period} дней",
+ 'traffic_limit_gb': tariff.traffic_limit_gb,
+ 'device_limit': tariff.device_limit,
+ 'allowed_squads': tariff.allowed_squads or [],
+ 'discount_percent': discount_percent,
+ }
+ await user_cart_service.save_user_cart(db_user.id, cart_data)
+
await callback.message.edit_text(
f"❌ Недостаточно средств\n\n"
f"📦 Тариф: {tariff.name}\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
- f"⚠️ Не хватает: {_format_price_kopeks(missing)}",
+ f"⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n"
+ f"🛒 Корзина сохранена! После пополнения баланса подписка будет продлена автоматически.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_extend")]
diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py
index dfad134f..05e942f0 100644
--- a/app/services/payment/cloudpayments.py
+++ b/app/services/payment/cloudpayments.py
@@ -260,7 +260,9 @@ class CloudPaymentsPaymentMixin:
# Auto-purchase if enabled
auto_purchase_success = False
try:
- auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
+ auto_purchase_success = await auto_purchase_saved_cart_after_topup(
+ db, user, bot=getattr(self, "bot", None)
+ )
except Exception as error:
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 57ae9316..8ca33a29 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -56,6 +56,8 @@ class AutoExtendContext:
traffic_limit_gb: Optional[int] = None
squad_uuid: Optional[str] = None
consume_promo_offer: bool = False
+ tariff_id: Optional[int] = None
+ allowed_squads: Optional[list] = None
async def _prepare_auto_purchase(
@@ -273,6 +275,7 @@ async def _prepare_auto_extend_context(
squad_uuid = cart_data.get("squad_uuid")
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
+ allowed_squads = cart_data.get("allowed_squads")
return AutoExtendContext(
subscription=subscription,
@@ -283,16 +286,26 @@ async def _prepare_auto_extend_context(
traffic_limit_gb=traffic_limit_gb,
squad_uuid=squad_uuid,
consume_promo_offer=consume_promo_offer,
+ tariff_id=tariff_id,
+ allowed_squads=allowed_squads,
)
def _apply_extension_updates(context: AutoExtendContext) -> None:
"""
- Применяет обновления лимитов подписки (трафик, устройства, серверы).
+ Применяет обновления лимитов подписки (трафик, устройства, серверы, тариф).
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
"""
subscription = context.subscription
+ # Обновляем tariff_id если указан в контексте
+ if context.tariff_id is not None:
+ subscription.tariff_id = context.tariff_id
+
+ # Обновляем allowed_squads если указаны (заменяем полностью)
+ if context.allowed_squads is not None:
+ subscription.connected_squads = context.allowed_squads
+
# Обновляем лимиты для триальной подписки
if subscription.is_trial:
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
@@ -528,6 +541,464 @@ async def _auto_extend_subscription(
return True
+async def _auto_purchase_tariff(
+ db: AsyncSession,
+ user: User,
+ cart_data: dict,
+ *,
+ bot: Optional[Bot] = None,
+) -> bool:
+ """Автоматическая покупка периодного тарифа из сохранённой корзины."""
+ from datetime import datetime
+ from app.database.crud.tariff import get_tariff_by_id
+ from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription
+ from app.database.crud.transaction import create_transaction
+ from app.database.crud.user import subtract_user_balance
+ from app.database.crud.server_squad import get_all_server_squads
+ from app.database.models import TransactionType
+
+ tariff_id = _safe_int(cart_data.get("tariff_id"))
+ period_days = _safe_int(cart_data.get("period_days"))
+ discount_percent = _safe_int(cart_data.get("discount_percent"))
+
+ if not tariff_id or period_days <= 0:
+ logger.warning(
+ "🔁 Автопокупка тарифа: некорректные данные корзины для пользователя %s (tariff_id=%s, period=%s)",
+ user.telegram_id,
+ tariff_id,
+ period_days,
+ )
+ return False
+
+ tariff = await get_tariff_by_id(db, tariff_id)
+ if not tariff or not tariff.is_active:
+ logger.warning(
+ "🔁 Автопокупка тарифа: тариф %s недоступен для пользователя %s",
+ tariff_id,
+ user.telegram_id,
+ )
+ return False
+
+ # Получаем актуальную цену тарифа
+ prices = tariff.period_prices or {}
+ base_price = prices.get(str(period_days))
+ if base_price is None:
+ logger.warning(
+ "🔁 Автопокупка тарифа: период %s дней недоступен для тарифа %s",
+ period_days,
+ tariff_id,
+ )
+ return False
+
+ final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
+
+ if user.balance_kopeks < final_price:
+ logger.info(
+ "🔁 Автопокупка тарифа: у пользователя %s недостаточно средств (%s < %s)",
+ user.telegram_id,
+ user.balance_kopeks,
+ final_price,
+ )
+ return False
+
+ # Списываем баланс
+ try:
+ description = f"Покупка тарифа {tariff.name} на {period_days} дней"
+ success = await subtract_user_balance(db, user, final_price, description)
+ if not success:
+ logger.warning(
+ "❌ Автопокупка тарифа: не удалось списать баланс пользователя %s",
+ user.telegram_id,
+ )
+ return False
+ except Exception as error:
+ logger.error(
+ "❌ Автопокупка тарифа: ошибка списания баланса пользователя %s: %s",
+ user.telegram_id,
+ error,
+ exc_info=True,
+ )
+ return False
+
+ # Получаем список серверов из тарифа
+ squads = tariff.allowed_squads or []
+ if not squads:
+ all_servers, _ = await get_all_server_squads(db, available_only=True)
+ squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
+
+ # Проверяем есть ли уже подписка
+ existing_subscription = await get_subscription_by_user_id(db, user.id)
+
+ try:
+ if existing_subscription:
+ # Продлеваем существующую подписку
+ subscription = await extend_subscription(
+ db,
+ existing_subscription,
+ days=period_days,
+ tariff_id=tariff.id,
+ traffic_limit_gb=tariff.traffic_limit_gb,
+ device_limit=tariff.device_limit,
+ connected_squads=squads,
+ )
+ was_trial_conversion = existing_subscription.is_trial
+ if was_trial_conversion:
+ subscription.is_trial = False
+ subscription.status = "active"
+ user.has_had_paid_subscription = True
+ await db.commit()
+ else:
+ # Создаём новую подписку
+ subscription = await create_paid_subscription(
+ db=db,
+ user_id=user.id,
+ duration_days=period_days,
+ traffic_limit_gb=tariff.traffic_limit_gb,
+ device_limit=tariff.device_limit,
+ connected_squads=squads,
+ tariff_id=tariff.id,
+ )
+ was_trial_conversion = False
+ except Exception as error:
+ logger.error(
+ "❌ Автопокупка тарифа: ошибка создания подписки для пользователя %s: %s",
+ user.telegram_id,
+ error,
+ exc_info=True,
+ )
+ await db.rollback()
+ return False
+
+ # Создаём транзакцию
+ try:
+ transaction = await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=final_price,
+ description=description,
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+ transaction = None
+
+ # Обновляем Remnawave
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason="покупка тарифа",
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+
+ # Очищаем корзину
+ await user_cart_service.delete_user_cart(user.id)
+ await clear_subscription_checkout_draft(user.id)
+
+ # Уведомления
+ if bot:
+ texts = get_texts(getattr(user, "language", "ru"))
+ period_label = format_period_description(period_days, getattr(user, "language", "ru"))
+
+ try:
+ notification_service = AdminNotificationService(bot)
+ await notification_service.send_subscription_purchase_notification(
+ db, user, subscription, transaction, period_days, was_trial_conversion
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+
+ try:
+ message = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
+ "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
+ ).format(period=period_label)
+
+ hint = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_HINT",
+ "Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
+ )
+
+ 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=f"{message}\n\n{hint}",
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка тарифа: не удалось уведомить пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+
+ logger.info(
+ "✅ Автопокупка тарифа: подписка на тариф %s (%s дней) оформлена для пользователя %s",
+ tariff.name,
+ period_days,
+ user.telegram_id,
+ )
+
+ return True
+
+
+async def _auto_purchase_daily_tariff(
+ db: AsyncSession,
+ user: User,
+ cart_data: dict,
+ *,
+ bot: Optional[Bot] = None,
+) -> bool:
+ """Автоматическая покупка суточного тарифа из сохранённой корзины."""
+ from datetime import datetime, timedelta
+ from app.database.crud.tariff import get_tariff_by_id
+ from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
+ from app.database.crud.transaction import create_transaction
+ from app.database.crud.user import subtract_user_balance
+ from app.database.crud.server_squad import get_all_server_squads
+ from app.database.models import TransactionType
+
+ tariff_id = _safe_int(cart_data.get("tariff_id"))
+ if not tariff_id:
+ logger.warning(
+ "🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя %s",
+ user.telegram_id,
+ )
+ return False
+
+ tariff = await get_tariff_by_id(db, tariff_id)
+ if not tariff or not tariff.is_active:
+ logger.warning(
+ "🔁 Автопокупка суточного тарифа: тариф %s недоступен для пользователя %s",
+ tariff_id,
+ user.telegram_id,
+ )
+ return False
+
+ if not getattr(tariff, 'is_daily', False):
+ logger.warning(
+ "🔁 Автопокупка суточного тарифа: тариф %s не является суточным для пользователя %s",
+ tariff_id,
+ user.telegram_id,
+ )
+ return False
+
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ if daily_price <= 0:
+ logger.warning(
+ "🔁 Автопокупка суточного тарифа: некорректная цена тарифа %s для пользователя %s",
+ tariff_id,
+ user.telegram_id,
+ )
+ return False
+
+ if user.balance_kopeks < daily_price:
+ logger.info(
+ "🔁 Автопокупка суточного тарифа: у пользователя %s недостаточно средств (%s < %s)",
+ user.telegram_id,
+ user.balance_kopeks,
+ daily_price,
+ )
+ return False
+
+ # Списываем баланс за первый день
+ try:
+ description = f"Активация суточного тарифа {tariff.name}"
+ success = await subtract_user_balance(db, user, daily_price, description)
+ if not success:
+ logger.warning(
+ "❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя %s",
+ user.telegram_id,
+ )
+ return False
+ except Exception as error:
+ logger.error(
+ "❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя %s: %s",
+ user.telegram_id,
+ error,
+ exc_info=True,
+ )
+ return False
+
+ # Получаем список серверов из тарифа
+ squads = tariff.allowed_squads or []
+ if not squads:
+ all_servers, _ = await get_all_server_squads(db, available_only=True)
+ squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
+
+ # Проверяем есть ли уже подписка
+ existing_subscription = await get_subscription_by_user_id(db, user.id)
+
+ try:
+ if existing_subscription:
+ # Обновляем существующую подписку на суточный тариф
+ # Суточность определяется через tariff.is_daily, поэтому достаточно установить tariff_id
+ was_trial_conversion = existing_subscription.is_trial # Сохраняем до изменения
+ existing_subscription.tariff_id = tariff.id
+ existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
+ existing_subscription.device_limit = tariff.device_limit
+ existing_subscription.connected_squads = squads
+ existing_subscription.status = "active"
+ existing_subscription.is_trial = False
+ existing_subscription.last_daily_charge_at = datetime.utcnow()
+ existing_subscription.is_daily_paused = False
+ existing_subscription.end_date = datetime.utcnow() + timedelta(days=1)
+ if was_trial_conversion:
+ user.has_had_paid_subscription = True
+ await db.commit()
+ await db.refresh(existing_subscription)
+ subscription = existing_subscription
+ else:
+ # Создаём новую суточную подписку
+ # Суточность определяется через tariff.is_daily
+ subscription = await create_paid_subscription(
+ db=db,
+ user_id=user.id,
+ duration_days=1,
+ traffic_limit_gb=tariff.traffic_limit_gb,
+ device_limit=tariff.device_limit,
+ connected_squads=squads,
+ tariff_id=tariff.id,
+ )
+ # Устанавливаем параметры для суточного списания
+ subscription.last_daily_charge_at = datetime.utcnow()
+ subscription.is_daily_paused = False
+ await db.commit()
+ was_trial_conversion = False
+ except Exception as error:
+ logger.error(
+ "❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя %s: %s",
+ user.telegram_id,
+ error,
+ exc_info=True,
+ )
+ await db.rollback()
+ return False
+
+ # Создаём транзакцию
+ try:
+ transaction = await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=daily_price,
+ description=description,
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+ transaction = None
+
+ # Обновляем Remnawave
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason="активация суточного тарифа",
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+
+ # Очищаем корзину
+ await user_cart_service.delete_user_cart(user.id)
+ await clear_subscription_checkout_draft(user.id)
+
+ # Уведомления
+ if bot:
+ texts = get_texts(getattr(user, "language", "ru"))
+
+ try:
+ notification_service = AdminNotificationService(bot)
+ await notification_service.send_subscription_purchase_notification(
+ db, user, subscription, transaction, 1, was_trial_conversion
+ )
+ except Exception as error:
+ logger.warning(
+ "⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя %s: %s",
+ user.telegram_id,
+ error,
+ )
+
+ try:
+ message = (
+ f"✅ Суточный тариф «{tariff.name}» активирован!\n\n"
+ f"💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n"
+ f"🔄 Средства будут списываться автоматически раз в сутки.\n\n"
+ f"ℹ️ Вы можете приостановить подписку в любой момент."
+ )
+
+ 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,
+ )
+
+ logger.info(
+ "✅ Автопокупка суточного тарифа: тариф %s активирован для пользователя %s",
+ tariff.name,
+ user.telegram_id,
+ )
+
+ return True
+
+
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
@@ -551,9 +1022,19 @@ async def auto_purchase_saved_cart_after_topup(
)
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
+
+ # Обработка продления подписки
if cart_mode == "extend":
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
+ # Обработка покупки периодного тарифа
+ if cart_mode == "tariff_purchase":
+ return await _auto_purchase_tariff(db, user, cart_data, bot=bot)
+
+ # Обработка покупки суточного тарифа
+ if cart_mode == "daily_tariff_purchase":
+ return await _auto_purchase_daily_tariff(db, user, cart_data, bot=bot)
+
try:
prepared = await _prepare_auto_purchase(db, user, cart_data)
except PurchaseValidationError as error: