From a3903a252efdd0db4b42ca3fd6771f1627050a7f Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 9 Feb 2026 21:39:53 +0300 Subject: [PATCH] refactor: remove smart auto-activation & activation prompt, fix production bugs Remove AUTO_ACTIVATE_AFTER_TOPUP and SHOW_ACTIVATION_PROMPT_AFTER_TOPUP features from all payment providers, config, system settings, and tests. Cart auto-purchase (AUTO_PURCHASE_AFTER_TOPUP) is preserved. Bug fixes: - fix KeyError 'months' in devices.py for custom locale overrides - fix IntegrityError on trial subscription retry (update existing PENDING instead of INSERT) - fix PendingRollbackError cascade by adding db.rollback() before recovery - fix TelegramForbiddenError not caught in photo_message.py - fix "query is too old" spam in required_sub_channel_check - add missing trial locale keys (TRIAL_PAYMENT_DESCRIPTION, TRIAL_REFUND_DESCRIPTION, TRIAL_ACTIVATION_ERROR) --- .env.example | 8 +- README.md | 2 - app/config.py | 16 - app/database/crud/subscription.py | 19 + app/handlers/start.py | 15 +- app/handlers/subscription/devices.py | 1 + app/handlers/subscription/purchase.py | 40 ++- app/localization/locales/en.json | 3 + app/localization/locales/fa.json | 3 + app/localization/locales/ru.json | 3 + app/localization/locales/ua.json | 3 + app/localization/locales/zh.json | 6 + app/services/payment/cloudpayments.py | 14 +- app/services/payment/common.py | 85 +---- app/services/payment/cryptobot.py | 22 +- app/services/payment/freekassa.py | 19 +- app/services/payment/heleket.py | 17 - app/services/payment/kassa_ai.py | 55 +-- app/services/payment/mulenpay.py | 24 +- app/services/payment/pal24.py | 19 +- app/services/payment/platega.py | 19 +- app/services/payment/stars.py | 22 +- app/services/payment/wata.py | 19 +- app/services/payment/yookassa.py | 118 +++--- .../subscription_auto_purchase_service.py | 338 +----------------- app/services/system_settings_service.py | 14 - app/services/tribute_service.py | 20 +- app/utils/photo_message.py | 12 +- tests/services/test_kassa_ai_notifications.py | 65 ---- uv.lock | 2 +- 30 files changed, 172 insertions(+), 831 deletions(-) diff --git a/.env.example b/.env.example index 9ada6fc5..9a4bd047 100644 --- a/.env.example +++ b/.env.example @@ -838,15 +838,9 @@ CONTESTS_BUTTON_VISIBLE=false # Реферальные конкурсы (турниры среди рефералов) REFERRAL_CONTESTS_ENABLED=false -# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ ===== +# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ ===== # Автоматическая покупка из сохранённой корзины после пополнения баланса AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false -# Умная автоактивация: система сама решает — продлить или создать подписку -# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса -AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false -# Показывать предупреждение об активации подписки после пополнения баланса -# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства" -SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false # ===== КНОПКА АКТИВАЦИИ ===== ACTIVATE_BUTTON_VISIBLE=false diff --git a/README.md b/README.md index 15301c60..a846714e 100644 --- a/README.md +++ b/README.md @@ -1342,7 +1342,6 @@ CONTEST_BUTTON_VISIBLE=true - 🔄 Автоплатёж с настройкой дня списания - 🎁 Реферальные и промо-бонусы - ⚡ **Быстрое пополнение** с кнопками быстрых сумм -- 🔄 **Умная автоактивация** подписки после пополнения баланса 📱 **Управление подписками** @@ -1529,7 +1528,6 @@ CONTEST_BUTTON_VISIBLE=true - 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами - 🧾 **История операций** - хранение всех транзакций и действий для аудита - 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч -- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса - 📝 **Ротация логов** - автоматическая очистка и архивация старых логов - 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами diff --git a/app/config.py b/app/config.py index 260b8def..392a64ae 100644 --- a/app/config.py +++ b/app/config.py @@ -339,12 +339,6 @@ class Settings(BaseSettings): NALOGO_STORAGE_PATH: str = './nalogo_tokens.json' AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False - AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False - - # Показывать предупреждение об активации подписки после пополнения баланса - # Если True - после пополнения показывает большое сообщение с кнопками: - # "Активировать", "Продлить", "Добавить устройства" - SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False # Отключение превью ссылок в сообщениях бота DISABLE_WEB_PAGE_PREVIEW: bool = False @@ -1183,16 +1177,6 @@ class Settings(BaseSettings): return bool(value) - def is_auto_activate_after_topup_enabled(self) -> bool: - """Умная автоактивация после пополнения баланса (без корзины).""" - value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False) - - if isinstance(value, str): - normalized = value.strip().lower() - return normalized in {'1', 'true', 'yes', 'on'} - - return bool(value) - def is_quick_amount_buttons_enabled(self) -> bool: """Показывать ли кнопки быстрого выбора суммы пополнения.""" return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 6b94c8b4..06dd6c64 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -95,6 +95,25 @@ async def create_trial_subscription( end_date = datetime.utcnow() + timedelta(days=duration_days) + # Check for existing PENDING trial subscription (retry after failed payment) + existing = await get_subscription_by_user_id(db, user_id) + if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value: + existing.status = SubscriptionStatus.ACTIVE.value + existing.start_date = datetime.utcnow() + existing.end_date = end_date + existing.traffic_limit_gb = traffic_limit_gb + existing.device_limit = device_limit + existing.connected_squads = final_squads + existing.tariff_id = tariff_id + await db.commit() + await db.refresh(existing) + logger.info( + '🎁 Обновлена PENDING триальная подписка %s для пользователя %s', + existing.id, + user_id, + ) + return existing + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, diff --git a/app/handlers/start.py b/app/handlers/start.py index 4cf25d11..3118a703 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -2048,9 +2048,22 @@ async def required_sub_channel_check( ) await state.set_state(RegistrationStates.waiting_for_rules_accept) + except TelegramBadRequest as e: + error_msg = str(e).lower() + if 'query is too old' in error_msg or 'query id is invalid' in error_msg: + logger.debug('Устаревший callback в required_sub_channel_check, игнорируем') + else: + logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}') + try: + await query.answer(f'{texts.ERROR}!', show_alert=True) + except Exception: + pass except Exception as e: logger.error(f'Ошибка в required_sub_channel_check: {e}') - await query.answer(f'{texts.ERROR}!', show_alert=True) + try: + await query.answer(f'{texts.ERROR}!', show_alert=True) + except Exception: + pass def register_handlers(dp: Dispatcher): diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 9101c022..396a8b11 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -416,6 +416,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d ).format( amount=texts.format_price(price), period=period_label, + months=period_label, ) if total_discount > 0: cost_text += texts.t( diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 431ea2d9..c8ffc466 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -3227,6 +3227,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: await db.refresh(db_user) + # Сохраняем ID до начала транзакции (на случай detached session) + user_id_snapshot = db_user.id + # Создаем триальную подписку subscription: Subscription | None = None remnawave_user = None @@ -3388,22 +3391,33 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: except Exception as error: logger.error( 'Unexpected error during paid trial activation for user %s: %s', - db_user.id, + user_id_snapshot, error, ) - # Пытаемся откатить и вернуть деньги - if subscription: - await rollback_trial_subscription_activation(db, subscription) - from app.database.crud.user import add_user_balance + # Откатываем сессию чтобы очистить PendingRollbackError + try: + await db.rollback() + except Exception: + pass - await add_user_balance( - db, - db_user, - trial_price_kopeks, - texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'), - transaction_type=TransactionType.REFUND, - ) - await db.refresh(db_user) + # Пытаемся вернуть деньги + try: + from app.database.crud.user import add_user_balance + + await add_user_balance( + db, + db_user, + trial_price_kopeks, + texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'), + transaction_type=TransactionType.REFUND, + ) + await db.refresh(db_user) + except Exception as refund_error: + logger.error( + 'Failed to refund trial payment for user %s: %s', + user_id_snapshot, + refund_error, + ) await callback.message.edit_text( texts.t( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index d870c0ad..9ae174d0 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1548,6 +1548,9 @@ "TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.", "TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.", "TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.", + "TRIAL_PAYMENT_DESCRIPTION": "Trial subscription payment", + "TRIAL_REFUND_DESCRIPTION": "Refund for failed trial activation", + "TRIAL_ACTIVATION_ERROR": "❌ An error occurred during trial activation. Funds have been returned to your balance.", "TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json index d77ae92b..49f549f3 100644 --- a/app/localization/locales/fa.json +++ b/app/localization/locales/fa.json @@ -1569,6 +1569,9 @@ "TRIAL_PROVISIONING_FAILED": "فعال‌سازی آزمایشی ناموفق. وجه بازگشت داده شد. بعداً تلاش کنید.", "TRIAL_ROLLBACK_FAILED": "لغو آزمایشی پس از خطا ناموفق. با پشتیبانی تماس بگیرید.", "TRIAL_REFUND_FAILED": "بازگشت وجه آزمایشی ناموفق. فوراً با پشتیبانی تماس بگیرید.", + "TRIAL_PAYMENT_DESCRIPTION": "پرداخت اشتراک آزمایشی", + "TRIAL_REFUND_DESCRIPTION": "بازگشت وجه به دلیل خطا در فعال‌سازی آزمایشی", + "TRIAL_ACTIVATION_ERROR": "❌ خطا در فعال‌سازی آزمایشی. وجه به موجودی بازگردانده شد.", "TRIAL_PAYMENT_CHARGED_NOTE": "💳 از موجودی شما {amount} کسر شد.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 دسترسی متوقف شد\n\nعضویت شما در کانال یافت نشد، آزمایشی غیرفعال شد.\n\nعضو شوید و «{check_button}» بزنید.", "TRIAL_ENDING_SOON": "\n🎁 آزمایشی به‌زودی تمام می‌شود!\n\nچند ساعت تا انقضا مانده.\n\n💎 بدون VPN نمانید!\n\n🔥 پیشنهاد ویژه:\n• ۳۰ روز فقط {price}\n• ترافیک نامحدود\n• همه سرورها\n• سرعت تا ۱ گیگ\n\n⚡️ قبل از پایان اقدام کنید!\n", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 8292133f..5121051d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1569,6 +1569,9 @@ "TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.", "TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.", "TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.", + "TRIAL_PAYMENT_DESCRIPTION": "Оплата пробной подписки", + "TRIAL_REFUND_DESCRIPTION": "Возврат за неудачную активацию триала", + "TRIAL_ACTIVATION_ERROR": "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.", "TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index f77943d1..7959a00a 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1479,6 +1479,9 @@ "TRIAL_PROVISIONING_FAILED": "Не вдалося завершити активацію тріалу. Кошти повернуто на баланс. Спробуйте пізніше.", "TRIAL_ROLLBACK_FAILED": "Не вдалося скасувати активацію тріалу після помилки списання. Зв'яжіться з підтримкою і спробуйте пізніше.", "TRIAL_REFUND_FAILED": "Не вдалося повернути оплату за активацію тріалу. Негайно зв'яжіться з підтримкою.", + "TRIAL_PAYMENT_DESCRIPTION": "Оплата пробної підписки", + "TRIAL_REFUND_DESCRIPTION": "Повернення за невдалу активацію тріалу", + "TRIAL_ACTIVATION_ERROR": "❌ Виникла помилка при активації тріалу. Кошти повернуто на баланс.", "TRIAL_PAYMENT_CHARGED_NOTE": "💳 З вашого балансу списано {amount}.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ призупинено\n\nМи не знайшли вашу підписку на наш канал, тому тестову підписку вимкнено.\n\nПідпишіться на канал і натисніть «{check_button}», щоб повернути доступ.", "TRIAL_ENDING_SOON": "\n🎁 Тестова підписка скоро закінчиться!\n\nВаша тестова підписка закінчується через декілька годин.\n\n💎 Не хочете залишитися без VPN?\nПереходьте на повну підписку!\n\n🔥 Спеціальна пропозиція:\n• 30 днів усього за {price}\n• Безлімітний трафік  \n• Всі сервери доступні\n• Швидкість до 1ГБіт/сек\n\n⚡️ Встигніть оформити до закінчення тестового періоду!\n", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 6f8fc731..bc16170f 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1477,6 +1477,9 @@ "TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。", "TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。", "TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。", +"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款", +"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款", +"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。", "TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。", "TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫访问已暂停\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。", "TRIAL_ENDING_SOON":"\n🎁试用订阅即将结束!\n\n您的试用订阅将在几小时后过期。\n\n💎不想没有VPN吗?\n升级到完整订阅!\n\n🔥特别优惠:\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n", @@ -1807,6 +1810,9 @@ "TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。", "TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。", "TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。", +"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款", +"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款", +"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。", "TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。", "TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫访问已暂停\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。", "TRIAL_ENDING_SOON":"\n🎁试用订阅即将结束!\n\n您的试用订阅将在几小时后过期。\n\n💎不想没有VPN吗?\n升级到完整订阅!\n\n🔥特别优惠:\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n", diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 5cff5337..93451982 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -12,7 +12,6 @@ from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.cloudpayments_service import CloudPaymentsAPIError from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -262,22 +261,11 @@ class CloudPaymentsPaymentMixin: logger.exception('Ошибка отправки уведомления CloudPayments: %s', error) # Auto-purchase if enabled - auto_purchase_success = False try: - auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None)) + await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None)) except Exception as error: logger.exception('Ошибка автопокупки после CloudPayments: %s', error) - # Умная автоактивация если автопокупка не сработала - if not auto_purchase_success: - try: - # Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений - await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks - ) - except Exception as error: - logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error) - return True async def process_cloudpayments_fail_webhook( diff --git a/app/services/payment/common.py b/app/services/payment/common.py index a4a2d3d7..3e97a631 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -171,79 +171,18 @@ class PaymentCommonMixin: try: payment_method = payment_method_title or 'Банковская карта (YooKassa)' - # Проверяем, нужно ли показывать яркое предупреждение об активации - if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - # Определяем статус подписки для выбора правильной кнопки - has_active_subscription = False - if user_snapshot: - try: - subscription = user_snapshot.subscription - has_active_subscription = bool( - subscription - and not getattr(subscription, 'is_trial', False) - and getattr(subscription, 'is_active', False) - ) - except Exception: - pass - - # Яркое сообщение с восклицательными знаками - message = ( - '✅ Платеж успешно завершен!\n\n' - f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' - f'💳 Способ: {payment_method}\n\n' - '💎 Средства зачислены на ваш баланс!\n\n' - '‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n' - '⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n' - '👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇' - ) - - # Формируем клавиатуру с кнопками действий - keyboard_rows: list[list[InlineKeyboardButton]] = [] - - # Кнопка активации или продления в зависимости от статуса - if has_active_subscription: - # Активная платная подписка - показываем продление и изменение устройств - keyboard_rows.append( - [ - build_miniapp_or_callback_button( - text='🔄 ПРОДЛИТЬ ПОДПИСКУ', - callback_data='subscription_extend', - ) - ] - ) - keyboard_rows.append( - [ - build_miniapp_or_callback_button( - text='📱 Изменить количество устройств', - callback_data='subscription_change_devices', - ) - ] - ) - else: - # Нет подписки или истекла - показываем только активацию - keyboard_rows.append( - [ - build_miniapp_or_callback_button( - text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ', - callback_data='menu_buy', - ) - ] - ) - - keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - else: - # Стандартное сообщение с полной клавиатурой - keyboard = await self.build_topup_success_keyboard(user_snapshot) - message = ( - '✅ Платеж успешно завершен!\n\n' - f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' - f'💳 Способ: {payment_method}\n\n' - 'Средства зачислены на ваш баланс!\n\n' - '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' - 'Обязательно активируйте подписку отдельно!\n\n' - f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' - f'подписка будет приобретена автоматически после пополнения баланса.' - ) + # Стандартное сообщение с полной клавиатурой + keyboard = await self.build_topup_success_keyboard(user_snapshot) + message = ( + '✅ Платеж успешно завершен!\n\n' + f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' + f'💳 Способ: {payment_method}\n\n' + 'Средства зачислены на ваш баланс!\n\n' + '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + 'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.' + ) await self.bot.send_message( chat_id=telegram_id, diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 93975cf7..129d5729 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -14,7 +14,6 @@ from app.config import settings from app.database.database import AsyncSessionLocal from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.services.subscription_renewal_service import ( @@ -361,26 +360,7 @@ class CryptoBotPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, - user, - bot=bot_instance, - topup_amount=amount_kopeks, - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and bot_instance and not activation_notification_sent: + if has_saved_cart and bot_instance: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 4533cce7..1d64c936 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -14,7 +14,6 @@ from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.freekassa_service import freekassa_service from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -388,23 +387,7 @@ class FreekassaPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 310e75f2..6b7f39c3 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -452,22 +451,6 @@ class HeleketPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - if not auto_purchase_success: - try: - await auto_activate_subscription_after_topup( - db, - user, - bot=getattr(self, 'bot', None), - topup_amount=amount_kopeks, - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) except Exception as error: logger.error( 'Ошибка при работе с автоактивацией для пользователя %s: %s', diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 1cc3818d..09d7d449 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -14,7 +14,6 @@ from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.kassa_ai_service import kassa_ai_service from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -339,34 +338,14 @@ class KassaAiPaymentMixin: try: display_name = settings.get_kassa_ai_display_name() - if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - # Яркое сообщение для тупых - from aiogram import types - - message = ( - '✅ Платеж успешно завершен!\n\n' - f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' - f'💳 Способ: {display_name}\n\n' - '💎 Средства зачислены на ваш баланс!\n\n' - '‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n' - '⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n' - '👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇' - ) - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ', callback_data='menu_buy')], - ] - ) - else: - # Стандартное сообщение (как было раньше) - keyboard = await self.build_topup_success_keyboard(user) - message = ( - '✅ Пополнение успешно!\n\n' - f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' - f'💳 Способ: {display_name}\n' - f'🆔 Транзакция: {transaction.id}\n\n' - 'Баланс пополнен автоматически!' - ) + keyboard = await self.build_topup_success_keyboard(user) + message = ( + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'💳 Способ: {display_name}\n' + f'🆔 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ) await self.bot.send_message( user.telegram_id, @@ -404,23 +383,7 @@ class KassaAiPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index f41befcf..bfa2e577 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -390,28 +389,7 @@ class MulenPayPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили и есть telegram_id - if ( - has_saved_cart - and getattr(self, 'bot', None) - and not activation_notification_sent - and user.telegram_id - ): + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 9834bb8a..8725de65 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -13,7 +13,6 @@ from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.pal24_service import Pal24APIError from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -489,23 +488,7 @@ class Pal24PaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index e83bd31b..0675fb29 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -13,7 +13,6 @@ from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.platega_service import PlategaService from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -470,23 +469,7 @@ class PlategaPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index e68e02c5..c4630e39 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -19,7 +19,6 @@ 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.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -534,26 +533,7 @@ class TelegramStarsMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, - user, - bot=getattr(self, 'bot', None), - topup_amount=amount_kopeks, - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: texts = get_texts(user.language) cart_message = texts.t( 'BALANCE_TOPUP_CART_REMINDER_DETAILED', diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 3f9326af..6402a145 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.services.wata_service import WataAPIError, WataService @@ -575,23 +574,7 @@ class WataPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 254ea8ed..b96ed6c4 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.payment_logger import payment_logger as logger @@ -847,78 +846,59 @@ class YooKassaPaymentMixin: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - if not auto_purchase_success: - try: - await auto_activate_subscription_after_topup( - db, - user, - bot=getattr(self, 'bot', None), - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление с кнопкой вернуться к оформлению + from aiogram import types - # Если включен яркий промпт активации, пропускаем старое уведомление - # т.к. оно будет отправлено через _send_payment_success_notification - if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению - from aiogram import types + from app.localization.texts import get_texts - 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) + ) - 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, + callback_data='return_to_saved_cart', + ) + ], + [ + types.InlineKeyboardButton( + text='💰 Мой баланс', + callback_data='menu_balance', + ) + ], + [ + types.InlineKeyboardButton( + text='🏠 Главное меню', + callback_data='back_to_menu', + ) + ], + ] + ) - # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data='return_to_saved_cart', - ) - ], - [ - types.InlineKeyboardButton( - text='💰 Мой баланс', - callback_data='menu_balance', - ) - ], - [ - types.InlineKeyboardButton( - text='🏠 Главное меню', - 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' - f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' - f'Обязательно активируйте подписку отдельно!\n\n' - f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' - f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}', - reply_markup=keyboard, - ) - logger.info( - f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}' - ) - else: - logger.info( - 'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена', - user.id, - ) + await self.bot.send_message( + chat_id=user.telegram_id, + text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}', + reply_markup=keyboard, + ) + logger.info( + f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}' + ) + else: + logger.info( + 'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена', + user.id, + ) except Exception as e: logger.error( f'Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}', diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index b03a69d2..9808cf0c 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -1814,340 +1814,4 @@ async def auto_purchase_saved_cart_after_topup( return True -async def auto_activate_subscription_after_topup( - db: AsyncSession, - user: User, - *, - bot: Bot | None = None, - topup_amount: int | None = None, -) -> tuple[bool, bool]: - """ - Умная автоактивация после пополнения баланса. - - Работает БЕЗ сохранённой корзины: - - Если подписка активна — ничего не делает - - Если подписка истекла — продлевает с теми же параметрами - - Если подписки нет — создаёт новую с дефолтными параметрами - - Выбирает максимальный период, который можно оплатить из баланса. - - Args: - topup_amount: Сумма пополнения в копейках (для отображения в уведомлении) - - Returns: - tuple[bool, bool]: (success, notification_sent) - - success: True если подписка активирована - - notification_sent: True если уведомление отправлено пользователю - """ - from datetime import datetime - - # Lazy imports to avoid circular dependency - from app.cabinet.routes.websocket import ( - notify_user_subscription_activated, - notify_user_subscription_renewed, - ) - from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids - 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.models import PaymentMethod, TransactionType - from app.services.admin_notification_service import AdminNotificationService - from app.services.subscription_renewal_service import SubscriptionRenewalService - from app.services.subscription_service import SubscriptionService - - if not user or not getattr(user, 'id', None): - return (False, False) - - subscription = await get_subscription_by_user_id(db, user.id) - - # Если автоактивация отключена - уведомление отправится из _send_payment_success_notification - if not settings.is_auto_activate_after_topup_enabled(): - logger.info( - '⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service', - _format_user_id(user), - ) - return (False, False) - - # Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть) - if subscription and subscription.status == 'ACTIVE' and subscription.end_date > datetime.utcnow(): - logger.info( - '🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем', - _format_user_id(user), - ) - return (False, False) - - # Определяем параметры подписки - if subscription: - device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT - # В режиме fixed_with_topup при автоактивации используем фиксированный лимит - if settings.is_traffic_fixed(): - traffic_limit_gb = settings.get_fixed_traffic_limit() - else: - traffic_limit_gb = subscription.traffic_limit_gb or 0 - connected_squads = subscription.connected_squads or [] - else: - device_limit = settings.DEFAULT_DEVICE_LIMIT - # В режиме fixed_with_topup при автоактивации используем фиксированный лимит - if settings.is_traffic_fixed(): - traffic_limit_gb = settings.get_fixed_traffic_limit() - else: - traffic_limit_gb = 0 - connected_squads = [] - - # Если серверы не выбраны — берём бесплатные по умолчанию - if not connected_squads: - available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id) - connected_squads = [s.squad_uuid for s in available_servers if s.is_available and s.price_kopeks == 0] - if not connected_squads and available_servers: - connected_squads = [available_servers[0].squad_uuid] - - server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else [] - - balance = user.balance_kopeks - available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) - - if not available_periods: - logger.warning('🔁 Автоактивация: нет доступных периодов подписки') - return (False, False) - - subscription_service = SubscriptionService() - - # Найти максимальный период <= баланса - best_period = None - best_price = 0 - - for period in available_periods: - try: - price, _ = await subscription_service.calculate_subscription_price_with_months( - period, traffic_limit_gb, server_ids, device_limit, db, user=user - ) - if price <= balance: - best_period = period - best_price = price - break - except Exception as calc_error: - logger.warning( - '🔁 Автоактивация: ошибка расчёта цены для периода %s: %s', - period, - calc_error, - ) - continue - - if not best_period: - logger.info( - '🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода', - _format_user_id(user), - balance, - ) - # Уведомление отправится из _send_payment_success_notification - logger.info( - '⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service', - _format_user_id(user), - ) - return (False, False) - - texts = get_texts(getattr(user, 'language', 'ru')) - - try: - if subscription: - # Продление существующей подписки - renewal_service = SubscriptionRenewalService() - pricing = await renewal_service.calculate_pricing(db, user, subscription, best_period) - - result = await renewal_service.finalize( - db, - user, - subscription, - pricing, - description=f'Автоматическое продление на {best_period} дней', - payment_method=PaymentMethod.BALANCE, - ) - - logger.info( - '✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.', - _format_user_id(user), - best_period, - best_price, - ) - - # Send WebSocket notification to cabinet frontend - try: - await notify_user_subscription_renewed( - user_id=user.id, - new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '', - amount_kopeks=best_price, - ) - except Exception as ws_error: - logger.warning( - '⚠️ Автоактивация: не удалось отправить WS уведомление о продлении для %s: %s', - _format_user_id(user), - ws_error, - ) - - # Уведомление пользователю (только для Telegram-пользователей) - if bot and user.telegram_id: - try: - period_label = format_period_description(best_period, getattr(user, 'language', 'ru')) - new_end_date = result.subscription.end_date - end_date_str = new_end_date.strftime('%d.%m.%Y') if new_end_date else '—' - - message = texts.t( - 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED', - '✅ Подписка автоматически продлена на {period}.', - ).format(period=period_label) - - details = texts.t( - 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS', - '⏰ Новая дата окончания: {date}.', - ).format(date=end_date_str) - - hint = texts.t( - 'AUTO_PURCHASE_SUBSCRIPTION_HINT', - 'Перейдите в раздел «Моя подписка», чтобы получить ссылку.', - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), - callback_data='menu_subscription', - ) - ], - ] - ) - - await bot.send_message( - chat_id=user.telegram_id, - text=f'{message}\n{details}\n\n{hint}', - reply_markup=keyboard, - parse_mode='HTML', - ) - except Exception as notify_error: - logger.warning( - '⚠️ Автоактивация: не удалось уведомить пользователя %s: %s', - user.telegram_id or user.id, - notify_error, - ) - - else: - # Создание новой подписки - new_subscription = await create_paid_subscription( - db, - user.id, - best_period, - traffic_limit_gb=traffic_limit_gb, - device_limit=device_limit, - connected_squads=connected_squads, - update_server_counters=True, - ) - - await subtract_user_balance(db, user, best_price, f'Активация подписки на {best_period} дней') - - await subscription_service.create_remnawave_user(db, new_subscription) - - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=best_price, - description=f'Активация подписки на {best_period} дней', - payment_method=PaymentMethod.BALANCE, - ) - - logger.info( - '✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.', - best_period, - _format_user_id(user), - best_price, - ) - - # Send WebSocket notification to cabinet frontend - try: - await notify_user_subscription_activated( - user_id=user.id, - expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '', - tariff_name='', - ) - except Exception as ws_error: - logger.warning( - '⚠️ Автоактивация: не удалось отправить WS уведомление об активации для %s: %s', - _format_user_id(user), - ws_error, - ) - - # Уведомление пользователю (только для Telegram-пользователей) - if bot and user.telegram_id: - try: - period_label = format_period_description(best_period, getattr(user, 'language', 'ru')) - - 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', - ) - ], - ] - ) - - await bot.send_message( - chat_id=user.telegram_id, - text=f'{message}\n\n{hint}', - reply_markup=keyboard, - parse_mode='HTML', - ) - - except Exception as notify_error: - logger.warning( - '⚠️ Автоактивация: не удалось уведомить пользователя %s: %s', - user.telegram_id or user.id, - notify_error, - ) - - # Уведомление админам (независимо от telegram_id) - if bot: - try: - notification_service = AdminNotificationService(bot) - await notification_service.send_subscription_purchase_notification( - db, - user, - new_subscription, - None, # transaction - best_period, - False, # was_trial_conversion - ) - except Exception as admin_error: - logger.warning( - '⚠️ Автоактивация: не удалось уведомить админов: %s', - admin_error, - ) - - return (True, True) # success=True, notification_sent=True (об активации) - - except Exception as e: - logger.error( - '❌ Автоактивация: ошибка для пользователя %s: %s', - _format_user_id(user), - e, - exc_info=True, - ) - try: - await db.rollback() - except Exception: - pass - return (False, False) - - -__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup'] +__all__ = ['auto_purchase_saved_cart_after_topup'] diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 28c4d307..717bcf34 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -260,7 +260,6 @@ class BotConfigurationService: 'PAYMENT_BALANCE_TEMPLATE': 'PAYMENT', 'PAYMENT_SUBSCRIPTION_TEMPLATE': 'PAYMENT', 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': 'PAYMENT', - 'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': 'PAYMENT', 'SIMPLE_SUBSCRIPTION_ENABLED': 'SIMPLE_SUBSCRIPTION', 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': 'SIMPLE_SUBSCRIPTION', 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION', @@ -585,19 +584,6 @@ class BotConfigurationService: 'example': 'true', 'warning': ('Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена.'), }, - 'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': { - 'description': ( - 'Включает режим яркого промпта активации подписки после пополнения баланса. ' - 'Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками ' - 'и кнопками для активации/продления подписки или изменения количества устройств.' - ), - 'format': 'Булево значение.', - 'example': 'true', - 'warning': ( - 'При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. ' - 'Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств).' - ), - }, 'SUPPORT_TICKET_SLA_MINUTES': { 'description': 'Лимит времени для ответа модераторов на тикет в минутах.', 'format': 'Целое число от 1 до 1440.', diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 17e5a6c5..c45508dc 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -14,7 +14,6 @@ from app.database.models import PaymentMethod, TransactionType from app.external.tribute import TributeService as TributeAPI from app.services.payment_service import PaymentService from app.services.subscription_auto_purchase_service import ( - auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) from app.utils.user_utils import format_referrer_info @@ -307,23 +306,8 @@ class TributeService: if auto_purchase_success: has_saved_cart = False - # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False - if not auto_purchase_success: - try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( - session, user, bot=self.bot, topup_amount=amount_kopeks - ) - except Exception as auto_activate_error: - logger.error( - 'Ошибка умной автоактивации для пользователя %s: %s', - user.id, - auto_activate_error, - exc_info=True, - ) - - # Отправляем уведомление только если его ещё не отправили и есть telegram_id - if has_saved_cart and self.bot and not activation_notification_sent and user_id: + # Отправляем уведомление только если есть сохранённая корзина и telegram_id + if has_saved_cart and self.bot and user_id: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from aiogram import types diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index e7a5efc3..7ca4a1a6 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -2,7 +2,7 @@ import asyncio import logging from aiogram import types -from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError from aiogram.types import InaccessibleMessage, InputMediaPhoto from app.config import settings @@ -126,6 +126,8 @@ async def edit_or_answer_photo( reply_markup=keyboard, parse_mode=resolved_parse_mode, ) + except TelegramForbiddenError: + logger.debug('Пользователь заблокировал бота, пропускаем') except TelegramBadRequest as error: try: await callback.message.delete() @@ -140,6 +142,8 @@ async def edit_or_answer_photo( if callback.message.photo: await callback.message.delete() await _answer_text(callback, caption, keyboard, resolved_parse_mode) + except TelegramForbiddenError: + logger.debug('Пользователь заблокировал бота, пропускаем') except TelegramBadRequest as error: await _answer_text(callback, caption, keyboard, resolved_parse_mode, error) return @@ -167,6 +171,10 @@ async def edit_or_answer_photo( pass await _answer_text(callback, caption, keyboard, resolved_parse_mode) return + except TelegramForbiddenError: + # Пользователь заблокировал бота — молча игнорируем + logger.debug('Пользователь заблокировал бота, пропускаем edit_media') + return except TelegramBadRequest as error: if is_privacy_restricted_error(error): try: @@ -189,7 +197,7 @@ async def edit_or_answer_photo( parse_mode=resolved_parse_mode, ) _cache_logo_file_id(result) - except TelegramBadRequest as photo_error: + except (TelegramBadRequest, TelegramForbiddenError) as photo_error: await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error) except Exception: # Последний фоллбек — обычный текст diff --git a/tests/services/test_kassa_ai_notifications.py b/tests/services/test_kassa_ai_notifications.py index 3912a2e1..ea720655 100644 --- a/tests/services/test_kassa_ai_notifications.py +++ b/tests/services/test_kassa_ai_notifications.py @@ -5,71 +5,6 @@ from unittest.mock import MagicMock -def test_notification_message_bright_prompt(): - """ - Тест: проверяем что формируется ЯРКОЕ сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true. - """ - - # Эмулируем код из kassa_ai.py - SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = True - display_name = 'Kassa AI' - amount_formatted = '10₽' - - if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - message = ( - '✅ Платеж успешно завершен!\n\n' - f'💰 Сумма: {amount_formatted}\n' - f'💳 Способ: {display_name}\n\n' - '💎 Средства зачислены на ваш баланс!\n\n' - '‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n' - '⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n' - '👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇' - ) - else: - message = '' - - # Проверки - assert '‼️' in message - assert 'ВНИМАНИЕ' in message - assert 'ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ' in message - assert '👇' in message - assert display_name in message - assert amount_formatted in message - print(f'\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}') - - -def test_notification_message_standard(): - """ - Тест: проверяем что формируется обычное сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false. - """ - # Эмулируем код из kassa_ai.py - SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = False - display_name = 'Kassa AI' - amount_formatted = '10₽' - - if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - message = '' - else: - message = ( - '✅ Платеж успешно завершен!\n\n' - f'💰 Сумма: {amount_formatted}\n' - f'💳 Способ: {display_name}\n\n' - 'Средства зачислены на ваш баланс!\n\n' - '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' - 'Обязательно активируйте подписку отдельно!\n\n' - f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' - f'подписка будет приобретена автоматически после пополнения баланса.' - ) - - # Проверки - assert '‼️' not in message - assert 'ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ' not in message - assert 'Платеж успешно завершен' in message - assert display_name in message - assert amount_formatted in message - print(f'\n✅ Обычное сообщение сформировано правильно:\n{message}') - - def test_telegram_id_saved_before_commit(): """ Тест: проверяем что telegram_id сохраняется в локальную переменную ДО commit. diff --git a/uv.lock b/uv.lock index 6bacc96f..378d6208 100644 --- a/uv.lock +++ b/uv.lock @@ -1149,7 +1149,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.6.0" +version = "3.8.0" source = { virtual = "." } dependencies = [ { name = "aiogram" },