diff --git a/app/database/models.py b/app/database/models.py
index b4e9bc80..3fb28fb5 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -529,6 +529,7 @@ class User(Base):
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ auto_purchase_after_topup_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
poll_responses = relationship("PollResponse", back_populates="user")
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 7b51db85..4de323ca 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -21,7 +21,7 @@ from app.database.crud.subscription import (
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
-from app.database.crud.user import subtract_user_balance
+from app.database.crud.user import subtract_user_balance, update_user
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
@@ -650,6 +650,22 @@ async def _edit_message_text_or_caption(
raise
+
+def _get_auto_purchase_status_lines(texts, enabled: bool) -> tuple[str, str]:
+ status_text = texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS.format(
+ status=(
+ texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED
+ if enabled
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED
+ )
+ )
+ status_hint = (
+ texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON
+ if enabled
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF
+ )
+ return status_text, status_hint
+
async def save_cart_and_redirect_to_topup(
callback: types.CallbackQuery,
state: FSMContext,
@@ -670,20 +686,157 @@ async def save_cart_and_redirect_to_topup(
await user_cart_service.save_user_cart(db_user.id, cart_data)
+ auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False)
+ status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled)
+
await callback.message.edit_text(
f"💰 Недостаточно средств для оформления подписки\n\n"
f"Требуется: {texts.format_price(missing_amount)}\n"
f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n"
f"🛒 Ваша корзина сохранена!\n"
+ f"{status_text}\n"
+ f"{status_hint}\n\n"
f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n"
f"Выберите способ пополнения:",
reply_markup=get_payment_methods_keyboard_with_cart(
db_user.language,
missing_amount,
+ auto_purchase_enabled=auto_purchase_enabled,
),
parse_mode="HTML"
)
+
+def _rebuild_topup_prompt_text(
+ texts,
+ missing_amount: int,
+ balance: int,
+ *,
+ status_text: str,
+ status_hint: str,
+) -> str:
+ return (
+ f"💰 Недостаточно средств для оформления подписки\n\n"
+ f"Требуется: {texts.format_price(missing_amount)}\n"
+ f"У вас: {texts.format_price(balance)}\n\n"
+ f"🛒 Ваша корзина сохранена!\n"
+ f"{status_text}\n"
+ f"{status_hint}\n\n"
+ f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n"
+ f"Выберите способ пополнения:"
+ )
+
+
+def _rebuild_insufficient_text(
+ texts,
+ total_price: int,
+ balance: int,
+ missing_amount: int,
+ *,
+ status_text: str,
+ status_hint: str,
+) -> str:
+ return (
+ f"❌ Все еще недостаточно средств\n\n"
+ f"Требуется: {texts.format_price(total_price)}\n"
+ f"У вас: {texts.format_price(balance)}\n"
+ f"Не хватает: {texts.format_price(missing_amount)}\n\n"
+ f"{status_text}\n{status_hint}"
+ )
+
+
+async def toggle_auto_purchase_after_topup(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ enable = callback.data == "auto_purchase_topup_toggle_on"
+
+ try:
+ if enable != getattr(db_user, "auto_purchase_after_topup_enabled", False):
+ db_user = await update_user(
+ db,
+ db_user,
+ auto_purchase_after_topup_enabled=enable,
+ )
+ except Exception as error:
+ logger.error("Не удалось обновить настройку автопокупки: %s", error)
+ await callback.answer("⚠️ Не удалось обновить настройку", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+ notice = (
+ texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON
+ if enable
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF
+ )
+ await callback.answer(notice, show_alert=False)
+
+ cart_data = await user_cart_service.get_user_cart(db_user.id)
+ if not cart_data:
+ # Обновляем только клавиатуру, если корзина не найдена
+ try:
+ await callback.message.edit_reply_markup(
+ reply_markup=get_payment_methods_keyboard_with_cart(
+ db_user.language,
+ 0,
+ auto_purchase_enabled=enable,
+ )
+ )
+ except Exception:
+ pass
+ return
+
+ total_price = cart_data.get('total_price', 0)
+ missing_amount = cart_data.get('missing_amount', 0)
+
+ if total_price:
+ recalculated_missing = max(0, total_price - db_user.balance_kopeks)
+ missing_amount = recalculated_missing
+ cart_data['missing_amount'] = missing_amount
+ await user_cart_service.save_user_cart(db_user.id, cart_data)
+
+ status_text, status_hint = _get_auto_purchase_status_lines(texts, enable)
+
+ message_text = callback.message.text or callback.message.caption or ""
+ reply_markup: InlineKeyboardMarkup
+
+ if "Ваша корзина сохранена" in message_text:
+ new_text = _rebuild_topup_prompt_text(
+ texts,
+ missing_amount,
+ db_user.balance_kopeks,
+ status_text=status_text,
+ status_hint=status_hint,
+ )
+ reply_markup = get_payment_methods_keyboard_with_cart(
+ db_user.language,
+ missing_amount,
+ auto_purchase_enabled=enable,
+ )
+ else:
+ total_value = total_price or (db_user.balance_kopeks + missing_amount)
+ new_text = _rebuild_insufficient_text(
+ texts,
+ total_value,
+ db_user.balance_kopeks,
+ missing_amount,
+ status_text=status_text,
+ status_hint=status_hint,
+ )
+ reply_markup = get_insufficient_balance_keyboard_with_cart(
+ db_user.language,
+ missing_amount,
+ auto_purchase_enabled=enable,
+ )
+
+ await _edit_message_text_or_caption(
+ callback.message,
+ new_text,
+ reply_markup=reply_markup,
+ parse_mode="HTML",
+ )
+
async def return_to_saved_cart(
callback: types.CallbackQuery,
state: FSMContext,
@@ -702,14 +855,18 @@ async def return_to_saved_cart(
if db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
+ auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False)
+ status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled)
await callback.message.edit_text(
f"❌ Все еще недостаточно средств\n\n"
f"Требуется: {texts.format_price(total_price)}\n"
f"У вас: {texts.format_price(db_user.balance_kopeks)}\n"
- f"Не хватает: {texts.format_price(missing_amount)}",
+ f"Не хватает: {texts.format_price(missing_amount)}\n\n"
+ f"{status_text}\n{status_hint}",
reply_markup=get_insufficient_balance_keyboard_with_cart(
db_user.language,
missing_amount,
+ auto_purchase_enabled=auto_purchase_enabled,
)
)
return
@@ -1597,6 +1754,11 @@ async def confirm_purchase(
missing=texts.format_price(missing_kopeks),
)
+ auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False)
+ status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled)
+
+ message_text = f"{message_text}\n\n{status_text}\n{status_hint}"
+
# Сохраняем данные корзины в Redis перед переходом к пополнению
cart_data = {
**data,
@@ -1614,7 +1776,8 @@ async def confirm_purchase(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
- has_saved_cart=True # Указываем, что есть сохраненная корзина
+ has_saved_cart=True, # Указываем, что есть сохраненная корзина
+ auto_purchase_enabled=auto_purchase_enabled,
),
parse_mode="HTML",
)
@@ -1649,12 +1812,18 @@ async def confirm_purchase(
missing=texts.format_price(missing_kopeks),
)
+ auto_purchase_enabled = getattr(db_user, "auto_purchase_after_topup_enabled", False)
+ status_text, status_hint = _get_auto_purchase_status_lines(texts, auto_purchase_enabled)
+
+ message_text = f"{message_text}\n\n{status_text}\n{status_hint}"
+
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
+ auto_purchase_enabled=auto_purchase_enabled,
),
parse_mode="HTML",
)
@@ -2212,6 +2381,11 @@ def register_handlers(dp: Dispatcher):
F.data == "clear_saved_cart",
)
+ dp.callback_query.register(
+ toggle_auto_purchase_after_topup,
+ F.data.in_(["auto_purchase_topup_toggle_on", "auto_purchase_topup_toggle_off"]),
+ )
+
dp.callback_query.register(
handle_autopay_menu,
F.data == "subscription_autopay"
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 46a64f05..94731644 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -643,6 +643,8 @@ def get_insufficient_balance_keyboard(
resume_callback: str | None = None,
amount_kopeks: int | None = None,
has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины
+ *,
+ auto_purchase_enabled: bool | None = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -663,25 +665,44 @@ def get_insufficient_balance_keyboard(
)
back_row_index = len(keyboard.inline_keyboard) - 1
+ insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
+ rows_to_insert: list[list[InlineKeyboardButton]] = []
+
+ if auto_purchase_enabled is not None:
+ toggle_row = [
+ InlineKeyboardButton(
+ text=(
+ texts.AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON
+ if auto_purchase_enabled
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON
+ ),
+ callback_data=(
+ "auto_purchase_topup_toggle_off"
+ if auto_purchase_enabled
+ else "auto_purchase_topup_toggle_on"
+ ),
+ )
+ ]
+ rows_to_insert.append(toggle_row)
+
# Если есть сохраненная корзина, добавляем кнопку возврата к оформлению
if has_saved_cart:
- return_row = [
+ rows_to_insert.append([
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
- ]
- insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
- keyboard.inline_keyboard.insert(insert_index, return_row)
+ ])
elif resume_callback:
- return_row = [
+ rows_to_insert.append([
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
- ]
- insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
- keyboard.inline_keyboard.insert(insert_index, return_row)
+ ])
+
+ for offset, row in enumerate(rows_to_insert):
+ keyboard.inline_keyboard.insert(insert_index + offset, row)
return keyboard
@@ -784,10 +805,34 @@ def get_subscription_keyboard(
def get_payment_methods_keyboard_with_cart(
language: str = "ru",
amount_kopeks: int = 0,
+ *,
+ auto_purchase_enabled: bool | None = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = get_payment_methods_keyboard(amount_kopeks, language)
+ toggle_row_insert_index = 0
+
+ if auto_purchase_enabled is not None:
+ keyboard.inline_keyboard.insert(
+ toggle_row_insert_index,
+ [
+ InlineKeyboardButton(
+ text=(
+ texts.AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON
+ if auto_purchase_enabled
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON
+ ),
+ callback_data=(
+ "auto_purchase_topup_toggle_off"
+ if auto_purchase_enabled
+ else "auto_purchase_topup_toggle_on"
+ ),
+ )
+ ],
+ )
+ toggle_row_insert_index += 1
+
# Добавляем кнопку "Очистить корзину"
keyboard.inline_keyboard.append([
InlineKeyboardButton(
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 4e9083e4..29e30c58 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -780,6 +780,16 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 Balance top-up required\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 Top up your balance to complete the purchase.\\n\\nChoose a top-up method:",
+ "AUTO_PURCHASE_AFTER_TOPUP_STATUS": "🤖 Auto purchase after top-up: {status}",
+ "AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED": "enabled",
+ "AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED": "disabled",
+ "AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON": "🤖 Enable auto purchase",
+ "AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON": "🚫 Disable auto purchase",
+ "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON": "🤖 Auto purchase enabled. Your subscription will be paid automatically after topping up.",
+ "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF": "🤖 Auto purchase disabled.",
+ "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS": "🤖 Auto purchase completed\\n\\nYour subscription for {period} was paid automatically. Charged: {amount}.",
+ "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS_WITH_DISCOUNT": "🤖 Auto purchase completed\\n\\nYour subscription for {period} was paid automatically. Charged: {amount}. Discount: {discount}.",
+ "AUTO_PURCHASE_AFTER_TOPUP_INSUFFICIENT": "🤖 Auto purchase failed — balance is still insufficient.",
"BALANCE_TOP_UP": "💳 Top up",
"BLOCK_BY_TIME": "⏳ Temporary block",
"BLOCK_FOREVER": "🚫 Block permanently",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 81d732a4..7e08eaf8 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -780,6 +780,16 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 Требуется пополнение баланса\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 Пополните баланс, чтобы завершить покупку.\n\nВыберите способ пополнения:",
+ "AUTO_PURCHASE_AFTER_TOPUP_STATUS": "🤖 Автопокупка после пополнения: {status}",
+ "AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED": "включена",
+ "AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED": "выключена",
+ "AUTO_PURCHASE_AFTER_TOPUP_ENABLE_BUTTON": "🤖 Включить автопокупку",
+ "AUTO_PURCHASE_AFTER_TOPUP_DISABLE_BUTTON": "🚫 Отключить автопокупку",
+ "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON": "🤖 Автопокупка включена. После пополнения баланс будет списан автоматически.",
+ "AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF": "🤖 Автопокупка отключена.",
+ "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS": "🤖 Автопокупка выполнена\n\nПодписка на {period} оплачена автоматически. Списано: {amount}.",
+ "AUTO_PURCHASE_AFTER_TOPUP_SUCCESS_WITH_DISCOUNT": "🤖 Автопокупка выполнена\n\nПодписка на {period} оплачена автоматически. Списано: {amount}. Скидка: {discount}.",
+ "AUTO_PURCHASE_AFTER_TOPUP_INSUFFICIENT": "🤖 Автопокупка не выполнена — на балансе все еще недостаточно средств.",
"BALANCE_TOP_UP": "💳 Пополнить",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"BLOCK_FOREVER": "🚫 Заблокировать",
diff --git a/app/services/auto_purchase_service.py b/app/services/auto_purchase_service.py
new file mode 100644
index 00000000..b8bce2f0
--- /dev/null
+++ b/app/services/auto_purchase_service.py
@@ -0,0 +1,307 @@
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import List, Optional
+
+from aiogram import Bot
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.server_squad import add_user_to_servers, get_server_ids_by_uuids
+from app.database.crud.subscription import add_subscription_servers, create_paid_subscription
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import SubscriptionStatus, TransactionType, User
+from app.keyboards.inline import get_insufficient_balance_keyboard_with_cart
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.subscription_checkout_service import clear_subscription_checkout_draft
+from app.services.subscription_service import SubscriptionService
+from app.services.user_cart_service import user_cart_service
+from app.utils.pricing_utils import format_period_description
+from app.utils.subscription_utils import get_display_subscription_link
+from app.utils.user_utils import mark_user_as_had_paid_subscription
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class AutoPurchaseResult:
+ triggered: bool
+ success: bool
+
+
+def _get_auto_purchase_status_lines(texts, enabled: bool) -> tuple[str, str]:
+ status_text = texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS.format(
+ status=(
+ texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_ENABLED
+ if enabled
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_STATUS_DISABLED
+ )
+ )
+ status_hint = (
+ texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_ON
+ if enabled
+ else texts.AUTO_PURCHASE_AFTER_TOPUP_TOGGLED_OFF
+ )
+ return status_text, status_hint
+
+
+def _build_autopurchase_failure_text(texts, total_price: int, balance: int, missing: int, enabled: bool) -> str:
+ status_text, status_hint = _get_auto_purchase_status_lines(texts, enabled)
+ return (
+ f"{texts.AUTO_PURCHASE_AFTER_TOPUP_INSUFFICIENT}\n\n"
+ f"Стоимость: {texts.format_price(total_price)}\n"
+ f"На балансе: {texts.format_price(balance)}\n"
+ f"Не хватает: {texts.format_price(missing)}\n\n"
+ f"{status_text}\n{status_hint}"
+ )
+
+
+def _build_autopurchase_success_prefix(texts, language: str, period_days: int, final_price: int, discount_value: int) -> str:
+ period_display = format_period_description(period_days, language)
+ if discount_value > 0:
+ return texts.AUTO_PURCHASE_AFTER_TOPUP_SUCCESS_WITH_DISCOUNT.format(
+ period=period_display,
+ amount=texts.format_price(final_price),
+ discount=texts.format_price(discount_value),
+ )
+ return texts.AUTO_PURCHASE_AFTER_TOPUP_SUCCESS.format(
+ period=period_display,
+ amount=texts.format_price(final_price),
+ )
+
+
+async def try_auto_purchase_after_topup(
+ db: AsyncSession,
+ user: User,
+ bot: Optional[Bot],
+) -> AutoPurchaseResult:
+ if not getattr(user, "auto_purchase_after_topup_enabled", False):
+ return AutoPurchaseResult(triggered=False, success=False)
+
+ cart_data = await user_cart_service.get_user_cart(user.id)
+ if not cart_data:
+ return AutoPurchaseResult(triggered=False, success=False)
+
+ texts = get_texts(user.language)
+
+ from app.handlers.subscription.pricing import _prepare_subscription_summary
+
+ try:
+ _, prepared_data = await _prepare_subscription_summary(user, cart_data, texts)
+ except ValueError as error:
+ logger.error("Не удалось восстановить корзину для автопокупки: %s", error)
+ await user_cart_service.delete_user_cart(user.id)
+ return AutoPurchaseResult(triggered=True, success=False)
+
+ final_price = prepared_data.get('total_price', 0)
+ promo_offer_discount_value = prepared_data.get('promo_offer_discount_value', 0)
+ promo_offer_discount_percent = prepared_data.get('promo_offer_discount_percent', 0)
+
+ await db.refresh(user)
+
+ if user.balance_kopeks < final_price:
+ missing = final_price - user.balance_kopeks
+ failure_text = _build_autopurchase_failure_text(
+ texts,
+ final_price,
+ user.balance_kopeks,
+ missing,
+ True,
+ )
+ if bot:
+ try:
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=failure_text,
+ parse_mode="HTML",
+ reply_markup=get_insufficient_balance_keyboard_with_cart(
+ user.language,
+ missing,
+ auto_purchase_enabled=True,
+ ),
+ )
+ except Exception as send_error:
+ logger.error("Не удалось отправить уведомление об автопокупке: %s", send_error)
+ return AutoPurchaseResult(triggered=True, success=False)
+
+ success = await subtract_user_balance(
+ db,
+ user,
+ final_price,
+ f"Покупка подписки на {prepared_data['period_days']} дней (авто)",
+ consume_promo_offer=promo_offer_discount_value > 0,
+ )
+
+ if not success:
+ await db.refresh(user)
+ missing = max(0, final_price - user.balance_kopeks)
+ failure_text = _build_autopurchase_failure_text(
+ texts,
+ final_price,
+ user.balance_kopeks,
+ missing,
+ True,
+ )
+ if bot:
+ try:
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=failure_text,
+ parse_mode="HTML",
+ reply_markup=get_insufficient_balance_keyboard_with_cart(
+ user.language,
+ missing,
+ auto_purchase_enabled=True,
+ ),
+ )
+ except Exception as send_error:
+ logger.error("Не удалось отправить уведомление об автопокупке: %s", send_error)
+ return AutoPurchaseResult(triggered=True, success=False)
+
+ final_traffic_gb = prepared_data.get('final_traffic_gb', prepared_data.get('traffic_gb', 0))
+ server_prices = prepared_data.get('server_prices_for_period', [])
+
+ existing_subscription = user.subscription
+ was_trial_conversion = False
+ current_time = datetime.utcnow()
+
+ if existing_subscription:
+ bonus_period = timedelta()
+ if existing_subscription.is_trial:
+ was_trial_conversion = True
+ trial_duration = (current_time - existing_subscription.start_date).days
+ if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and existing_subscription.end_date:
+ remaining_trial_delta = existing_subscription.end_date - current_time
+ if remaining_trial_delta.total_seconds() > 0:
+ bonus_period = remaining_trial_delta
+
+ existing_subscription.is_trial = False
+ existing_subscription.status = SubscriptionStatus.ACTIVE.value
+ existing_subscription.traffic_limit_gb = final_traffic_gb
+ existing_subscription.device_limit = prepared_data['devices']
+ existing_subscription.connected_squads = prepared_data['countries']
+ existing_subscription.start_date = current_time
+ existing_subscription.end_date = current_time + timedelta(days=prepared_data['period_days']) + bonus_period
+ existing_subscription.updated_at = current_time
+ existing_subscription.traffic_used_gb = 0.0
+
+ await db.commit()
+ await db.refresh(existing_subscription)
+ subscription = existing_subscription
+ else:
+ subscription = await create_paid_subscription(
+ db=db,
+ user_id=user.id,
+ duration_days=prepared_data['period_days'],
+ traffic_limit_gb=final_traffic_gb,
+ device_limit=prepared_data['devices'],
+ connected_squads=prepared_data['countries'],
+ )
+
+ await mark_user_as_had_paid_subscription(db, user)
+
+ server_ids = await get_server_ids_by_uuids(db, prepared_data['countries'])
+ if server_ids:
+ await add_subscription_servers(db, subscription, server_ids, server_prices)
+ await add_user_to_servers(db, server_ids)
+
+ await db.refresh(user)
+
+ subscription_service = SubscriptionService()
+ if user.remnawave_uuid:
+ remnawave_user = await subscription_service.update_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason="автопокупка подписки",
+ )
+ else:
+ remnawave_user = await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason="автопокупка подписки",
+ )
+ if not remnawave_user:
+ remnawave_user = await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason="автопокупка подписки (повтор)",
+ )
+
+ transaction = await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=final_price,
+ description=f"Подписка на {prepared_data['period_days']} дней (авто)",
+ )
+
+ if bot:
+ try:
+ notification_service = AdminNotificationService(bot)
+ await notification_service.send_subscription_purchase_notification(
+ db,
+ user,
+ subscription,
+ transaction,
+ prepared_data['period_days'],
+ was_trial_conversion,
+ )
+ except Exception as notify_error:
+ logger.error("Ошибка отправки уведомления админам об автопокупке: %s", notify_error)
+
+ await db.refresh(user)
+ await db.refresh(subscription)
+
+ subscription_link = get_display_subscription_link(subscription)
+ hide_subscription_link = settings.should_hide_subscription_link()
+
+ auto_prefix = _build_autopurchase_success_prefix(
+ texts,
+ user.language,
+ prepared_data['period_days'],
+ final_price,
+ promo_offer_discount_value,
+ )
+
+ instruction_text = texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
+ success_text = f"{texts.SUBSCRIPTION_PURCHASED}\n\n{auto_prefix}\n\n{instruction_text}"
+
+ if bot:
+ rows: List[List[InlineKeyboardButton]] = []
+ if subscription_link and not hide_subscription_link:
+ rows.append([
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ url=subscription_link,
+ )
+ ])
+ rows.append([
+ InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
+ ])
+ rows.append([
+ InlineKeyboardButton(text=texts.BACK_TO_MAIN_MENU_BUTTON, callback_data="back_to_menu")
+ ])
+
+ try:
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=success_text,
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
+ )
+ except Exception as send_error:
+ logger.error("Не удалось отправить сообщение об автопокупке: %s", send_error)
+
+ await clear_subscription_checkout_draft(user.id)
+ await user_cart_service.delete_user_cart(user.id)
+
+ return AutoPurchaseResult(triggered=True, success=True)
diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py
index aab5adb5..f007b5e6 100644
--- a/app/services/payment/cryptobot.py
+++ b/app/services/payment/cryptobot.py
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
+from app.services.auto_purchase_service import try_auto_purchase_after_topup
+from app.services.user_cart_service import user_cart_service
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
@@ -292,20 +294,25 @@ class CryptoBotPaymentMixin:
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
- from app.services.user_cart_service import user_cart_service
+ autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None))
+ if autopurchase_result.triggered:
+ logger.info(
+ "Автопокупка после пополнения %s для пользователя %s",
+ "успешна" if autopurchase_result.success else "не выполнена",
+ user.id,
+ )
+ return True
+
from aiogram import types
+ from app.localization.texts import get_texts
+
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
- # Если у пользователя есть сохраненная корзина,
- # отправляем ему уведомление с кнопкой вернуться к оформлению
- from app.localization.texts import get_texts
-
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
-
- # Создаем клавиатуру с кнопками
+
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
@@ -320,7 +327,7 @@ class CryptoBotPaymentMixin:
callback_data="back_to_menu"
)]
])
-
+
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py
index 71a9dd8b..904b7a27 100644
--- a/app/services/payment/mulenpay.py
+++ b/app/services/payment/mulenpay.py
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
+from app.services.auto_purchase_service import try_auto_purchase_after_topup
+from app.services.user_cart_service import user_cart_service
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -323,11 +325,19 @@ class MulenPayPaymentMixin:
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
- from app.services.user_cart_service import user_cart_service
+ autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None))
+ if autopurchase_result.triggered:
+ logger.info(
+ "Автопокупка после пополнения %s для пользователя %s",
+ "успешна" if autopurchase_result.success else "не выполнена",
+ user.id,
+ )
+ return True
+
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
- # Если у пользователя есть сохраненная корзина,
+ # Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py
index 7cae4325..ad2f0b4d 100644
--- a/app/services/payment/pal24.py
+++ b/app/services/payment/pal24.py
@@ -12,7 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
+from app.services.auto_purchase_service import try_auto_purchase_after_topup
from app.services.pal24_service import Pal24APIError
+from app.services.user_cart_service import user_cart_service
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -434,6 +436,15 @@ class Pal24PaymentMixin:
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
+ autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None))
+ if autopurchase_result.triggered:
+ logger.info(
+ "Автопокупка после пополнения %s для пользователя %s",
+ "успешна" if autopurchase_result.success else "не выполнена",
+ user.id,
+ )
+ return True
+
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py
index ea710427..cc8597f3 100644
--- a/app/services/payment/stars.py
+++ b/app/services/payment/stars.py
@@ -19,6 +19,8 @@ from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
+from app.services.auto_purchase_service import try_auto_purchase_after_topup
+from app.services.user_cart_service import user_cart_service
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -239,7 +241,15 @@ class TelegramStarsMixin:
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
- from app.services.user_cart_service import user_cart_service
+ autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None))
+ if autopurchase_result.triggered:
+ logger.info(
+ "Автопокупка после пополнения %s для пользователя %s",
+ "успешна" if autopurchase_result.success else "не выполнена",
+ user.id,
+ )
+ return True
+
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py
index 134c1ca0..378e99d3 100644
--- a/app/services/payment/wata.py
+++ b/app/services/payment/wata.py
@@ -12,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
+from app.services.auto_purchase_service import try_auto_purchase_after_topup
+from app.services.user_cart_service import user_cart_service
from app.services.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
@@ -520,7 +522,15 @@ class WataPaymentMixin:
logger.error("Ошибка отправки уведомления пользователю WATA: %s", error)
try:
- from app.services.user_cart_service import user_cart_service
+ autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None))
+ if autopurchase_result.triggered:
+ logger.info(
+ "Автопокупка после пополнения %s для пользователя %s",
+ "успешна" if autopurchase_result.success else "не выполнена",
+ user.id,
+ )
+ return payment
+
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
index b4210899..67a699b5 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
+from app.services.auto_purchase_service import try_auto_purchase_after_topup
+from app.services.user_cart_service import user_cart_service
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -462,8 +464,16 @@ class YooKassaPaymentMixin:
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
- from app.services.user_cart_service import user_cart_service
try:
+ autopurchase_result = await try_auto_purchase_after_topup(db, user, getattr(self, "bot", None))
+ if autopurchase_result.triggered:
+ logger.info(
+ "Автопокупка после пополнения %s для пользователя %s",
+ "успешна" if autopurchase_result.success else "не выполнена",
+ user.id,
+ )
+ return True
+
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
diff --git a/migrations/alembic/versions/f2acb8b40cb5_add_auto_purchase_after_topup.py b/migrations/alembic/versions/f2acb8b40cb5_add_auto_purchase_after_topup.py
new file mode 100644
index 00000000..c244ba19
--- /dev/null
+++ b/migrations/alembic/versions/f2acb8b40cb5_add_auto_purchase_after_topup.py
@@ -0,0 +1,36 @@
+"""add auto purchase after topup flag"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "f2acb8b40cb5"
+down_revision: Union[str, None] = "9f0f2d5a1c7b"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+USERS_TABLE = "users"
+COLUMN_NAME = "auto_purchase_after_topup_enabled"
+
+
+def upgrade() -> None:
+ op.add_column(
+ USERS_TABLE,
+ sa.Column(
+ COLUMN_NAME,
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+ op.alter_column(
+ USERS_TABLE,
+ COLUMN_NAME,
+ server_default=None,
+ )
+
+
+def downgrade() -> None:
+ op.drop_column(USERS_TABLE, COLUMN_NAME)
diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py
index f003d765..1f97c024 100644
--- a/tests/services/test_payment_service_webhooks.py
+++ b/tests/services/test_payment_service_webhooks.py
@@ -762,6 +762,40 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
)
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
+ autopurchase_mock = AsyncMock(
+ return_value=SimpleNamespace(triggered=False, success=False)
+ )
+ monkeypatch.setattr(
+ "app.services.payment.pal24.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.wata.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.yookassa.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.cryptobot.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.mulenpay.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.stars.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+
class DummyTypes:
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard=None, **kwargs):
@@ -928,6 +962,40 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
)
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
+ autopurchase_mock = AsyncMock(
+ return_value=SimpleNamespace(triggered=False, success=False)
+ )
+ monkeypatch.setattr(
+ "app.services.payment.pal24.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.wata.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.yookassa.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.cryptobot.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.mulenpay.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "app.services.payment.stars.try_auto_purchase_after_topup",
+ autopurchase_mock,
+ raising=False,
+ )
+
class DummyTypes:
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard=None, **kwargs):