From 0b18c16f47b266cd039dc282b4c34d8541bb7839 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 06:25:30 +0300 Subject: [PATCH 1/4] Update subscription_auto_purchase_service.py --- .../subscription_auto_purchase_service.py | 483 +++++++++++++++++- 1 file changed, 482 insertions(+), 1 deletion(-) 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: From cdb3507a56e898ff952a7b1c39c24d34da301a80 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 06:26:15 +0300 Subject: [PATCH 2/4] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 72 ++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) 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")] From bd6498fb736109c8c722848bd763c95525d754ef Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 07:52:16 +0300 Subject: [PATCH 3/4] Update subscription.py --- app/cabinet/routes/subscription.py | 147 ++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 5 deletions(-) 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'], }, ) From f8d7b3288cf64706b61258c03b3992c041c0e00c Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 07:52:54 +0300 Subject: [PATCH 4/4] Update cloudpayments.py --- app/services/payment/cloudpayments.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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)