From 3f0a26b1c72d72819aa9008178a5ca56389c1852 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 07:55:04 +0300 Subject: [PATCH] Revert "Rollback trial activation when charge errors occur" --- .env.example | 2 - README.md | 6 - app/config.py | 20 -- app/handlers/admin/pricing.py | 23 +- app/handlers/subscription/purchase.py | 250 ++++----------------- app/localization/locales/en.json | 7 +- app/localization/locales/ru.json | 7 +- app/services/admin_notification_service.py | 11 +- app/services/trial_activation_service.py | 130 ----------- app/webapi/routes/miniapp.py | 106 +-------- app/webapi/schemas/miniapp.py | 9 +- 11 files changed, 55 insertions(+), 516 deletions(-) delete mode 100644 app/services/trial_activation_service.py diff --git a/.env.example b/.env.example index 2ad25e03..6387650a 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,6 @@ REMNAWAVE_USER_DELETE_MODE=delete TRIAL_DURATION_DAYS=3 TRIAL_TRAFFIC_LIMIT_GB=10 TRIAL_DEVICE_LIMIT=1 -TRIAL_PAYMENT_ENABLED=false -TRIAL_ACTIVATION_PRICE=0 TRIAL_SQUAD_UUID= # ===== ПЛАТНАЯ ПОДПИСКА ===== diff --git a/README.md b/README.md index 8983a19a..06f7670b 100644 --- a/README.md +++ b/README.md @@ -538,12 +538,6 @@ TRAFFIC_PACKAGES_CONFIG="100:15000:true" # Бесплатные устройства в триал подписке TRIAL_DEVICE_LIMIT=1 -# Требовать оплату за активацию триала -TRIAL_PAYMENT_ENABLED=false - -# Стоимость активации триала (в копейках) -TRIAL_ACTIVATION_PRICE=0 - # Бесплатные устройства в платной подписке DEFAULT_DEVICE_LIMIT=3 diff --git a/app/config.py b/app/config.py index 57549cca..0d63adaa 100644 --- a/app/config.py +++ b/app/config.py @@ -85,8 +85,6 @@ class Settings(BaseSettings): TRIAL_TRAFFIC_LIMIT_GB: int = 10 TRIAL_DEVICE_LIMIT: int = 2 TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False - TRIAL_PAYMENT_ENABLED: bool = False - TRIAL_ACTIVATION_PRICE: int = 0 DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 TRIAL_SQUAD_UUID: Optional[str] = None @@ -885,24 +883,6 @@ class Settings(BaseSettings): def get_disabled_mode_device_limit(self) -> Optional[int]: return self.get_devices_selection_disabled_amount() - - def is_trial_paid_activation_enabled(self) -> bool: - return bool(self.TRIAL_PAYMENT_ENABLED) - - def get_trial_activation_price(self) -> int: - try: - value = int(self.TRIAL_ACTIVATION_PRICE) - except (TypeError, ValueError): - logger.warning( - "Некорректное значение TRIAL_ACTIVATION_PRICE: %s", - self.TRIAL_ACTIVATION_PRICE, - ) - return 0 - - if value < 0: - return 0 - - return value def is_yookassa_enabled(self) -> bool: return (self.YOOKASSA_ENABLED and diff --git a/app/handlers/admin/pricing.py b/app/handlers/admin/pricing.py index eae7b8c9..c3abf75d 100644 --- a/app/handlers/admin/pricing.py +++ b/app/handlers/admin/pricing.py @@ -96,24 +96,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = ( label_en="📱 Device limit", action="input", ), - SettingEntry( - key="TRIAL_PAYMENT_ENABLED", - section="trial", - label_ru="💳 Платная активация", - label_en="💳 Paid activation", - action="toggle", - description_ru="Если включено — за активацию триала будет списываться указанная сумма.", - description_en="When enabled, the configured amount is charged during trial activation.", - ), - SettingEntry( - key="TRIAL_ACTIVATION_PRICE", - section="trial", - label_ru="💰 Стоимость активации", - label_en="💰 Activation price", - action="price", - description_ru="Указывается в копейках. 0 — бесплатная активация.", - description_en="Amount in kopeks. 0 — free activation.", - ), SettingEntry( key="TRIAL_ADD_REMAINING_DAYS_TO_PAID", section="trial", @@ -308,14 +290,11 @@ def _format_trial_summary(lang_code: str) -> str: duration = settings.TRIAL_DURATION_DAYS traffic = settings.TRIAL_TRAFFIC_LIMIT_GB devices = settings.TRIAL_DEVICE_LIMIT - price_note = "" - if settings.is_trial_paid_activation_enabled(): - price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}" traffic_label = _format_traffic_label(traffic, lang_code, short=True) devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱" days_suffix = "д" if lang_code == "ru" else "d" - return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}" + return f"{duration}{days_suffix}, {traffic_label}, {devices_label}" def _format_core_summary(lang_code: str) -> str: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index c67b8f4a..2ea0ebfa 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -58,13 +58,6 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.services.subscription_service import SubscriptionService -from app.services.trial_activation_service import ( - TrialPaymentChargeFailed, - TrialPaymentInsufficientFunds, - charge_trial_activation_if_required, - rollback_trial_subscription_activation, - preview_trial_activation_charge, -) def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]: @@ -458,22 +451,12 @@ async def show_trial_offer( devices=trial_device_limit, ) - price_line = "" - if settings.is_trial_paid_activation_enabled(): - trial_price = settings.get_trial_activation_price() - if trial_price > 0: - price_line = texts.t( - "TRIAL_PAYMENT_PRICE_LINE", - "\n💳 Стоимость активации: {price}", - ).format(price=settings.format_price(trial_price)) - trial_text = texts.TRIAL_AVAILABLE.format( days=settings.TRIAL_DURATION_DAYS, traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB), devices=trial_device_limit if trial_device_limit is not None else "", devices_line=devices_line, - server_name=trial_server_name, - price_line=price_line, + server_name=trial_server_name ) await callback.message.edit_text( @@ -499,28 +482,6 @@ async def activate_trial( await callback.answer() return - try: - preview_trial_activation_charge(db_user) - except TrialPaymentInsufficientFunds as error: - required_label = settings.format_price(error.required_amount) - balance_label = settings.format_price(error.balance_amount) - missing_label = settings.format_price(error.missing_amount) - message = texts.t( - "TRIAL_PAYMENT_INSUFFICIENT_FUNDS", - "⚠️ Недостаточно средств для активации триала.\n" - "Необходимо: {required}\nНа балансе: {balance}\n" - "Не хватает: {missing}\n\nПополните баланс и попробуйте снова.", - ).format(required=required_label, balance=balance_label, missing=missing_label) - - await callback.message.edit_text( - message, - reply_markup=get_insufficient_balance_keyboard(db_user.language), - ) - await callback.answer() - return - - charged_amount = 0 - try: forced_devices = None if not settings.is_devices_selection_enabled(): @@ -534,72 +495,6 @@ async def activate_trial( await db.refresh(db_user) - try: - charged_amount = await charge_trial_activation_if_required( - db, - db_user, - description="Активация триала через бота", - ) - except TrialPaymentInsufficientFunds as error: - rollback_success = await rollback_trial_subscription_activation(db, subscription) - await db.refresh(db_user) - if not rollback_success: - await callback.answer( - texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала. Попробуйте позже.", - ), - show_alert=True, - ) - return - - logger.error( - "Insufficient funds detected after trial creation for user %s: %s", - db_user.id, - error, - ) - required_label = settings.format_price(error.required_amount) - balance_label = settings.format_price(error.balance_amount) - missing_label = settings.format_price(error.missing_amount) - message = texts.t( - "TRIAL_PAYMENT_INSUFFICIENT_FUNDS", - "⚠️ Недостаточно средств для активации триала.\n" - "Необходимо: {required}\nНа балансе: {balance}\n" - "Не хватает: {missing}\n\nПополните баланс и попробуйте снова.", - ).format( - required=required_label, - balance=balance_label, - missing=missing_label, - ) - - await callback.message.edit_text( - message, - reply_markup=get_insufficient_balance_keyboard(db_user.language), - ) - await callback.answer() - return - except TrialPaymentChargeFailed: - rollback_success = await rollback_trial_subscription_activation(db, subscription) - await db.refresh(db_user) - if not rollback_success: - await callback.answer( - texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала. Попробуйте позже.", - ), - show_alert=True, - ) - return - - await callback.answer( - texts.t( - "TRIAL_PAYMENT_FAILED", - "Не удалось списать средства для активации триала. Попробуйте позже.", - ), - show_alert=True, - ) - return - subscription_service = SubscriptionService() remnawave_user = await subscription_service.create_remnawave_user( db, subscription @@ -609,51 +504,39 @@ async def activate_trial( try: notification_service = AdminNotificationService(callback.bot) - await notification_service.send_trial_activation_notification( - db, - db_user, - subscription, - charged_amount_kopeks=charged_amount, - ) + await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() - payment_note = "" - if charged_amount > 0: - payment_note = "\n\n" + texts.t( - "TRIAL_PAYMENT_CHARGED_NOTE", - "💳 С вашего баланса списано {amount}.", - ).format(amount=settings.format_price(charged_amount)) - if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) elif hide_subscription_link: trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) else: subscription_import_link = texts.t( @@ -667,8 +550,6 @@ async def activate_trial( f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) - trial_success_text += payment_note - connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -679,12 +560,8 @@ async def activate_trial( web_app=types.WebAppInfo(url=subscription_link), ) ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": if not settings.MINIAPP_CUSTOM_URL: @@ -704,33 +581,22 @@ async def activate_trial( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "link": rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link, - ) - ] + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append( - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ] - ) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) elif connect_mode == "happ_cryptolink": rows = [ @@ -744,51 +610,33 @@ async def activate_trial( happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append( - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ] - ) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: - connect_keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], + ]) await callback.message.edit_text( trial_success_text, reply_markup=connect_keyboard, - parse_mode="HTML", + parse_mode="HTML" ) else: - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд." - ) - trial_success_text += payment_note await callback.message.edit_text( - trial_success_text, - reply_markup=get_back_keyboard(db_user.language), + f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.", + reply_markup=get_back_keyboard(db_user.language) ) - logger.info( - f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}" - ) + logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}") except Exception as e: logger.error(f"Ошибка активации триала: {e}") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e1f95605..7e6655ac 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1413,13 +1413,8 @@ "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", - "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a trial plan{price_line}:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic}{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic}{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Devices: {devices} pcs", - "TRIAL_PAYMENT_PRICE_LINE": "\n💳 Activation price: {price}", - "TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.", - "TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. 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_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", "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index c0afb67e..cee78825 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1433,13 +1433,8 @@ "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", - "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic}{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic}{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Устройства: {devices} шт.", - "TRIAL_PAYMENT_PRICE_LINE": "\n💳 Стоимость активации: {price}", - "TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.", - "TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.", - "TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.", - "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", "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 8b4c1379..553e794f 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -190,9 +190,7 @@ class AdminNotificationService: self, db: AsyncSession, user: User, - subscription: Subscription, - *, - charged_amount_kopeks: Optional[int] = None, + subscription: Subscription ) -> bool: if not self._is_enabled(): return False @@ -212,12 +210,6 @@ class AdminNotificationService: else: trial_device_limit = settings.TRIAL_DEVICE_LIMIT - payment_block = "" - if charged_amount_kopeks and charged_amount_kopeks > 0: - payment_block = ( - f"\n💳 Оплата за активацию: {settings.format_price(charged_amount_kopeks)}" - ) - message = f"""🎯 АКТИВАЦИЯ ТРИАЛА 👤 Пользователь: {user_display} @@ -232,7 +224,6 @@ class AdminNotificationService: 📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)} 📱 Устройства: {trial_device_limit} 🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'} -{payment_block} 📆 Действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')} 🔗 Реферер: {referrer_info} diff --git a/app/services/trial_activation_service.py b/app/services/trial_activation_service.py deleted file mode 100644 index 635a9611..00000000 --- a/app/services/trial_activation_service.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.subscription import decrement_subscription_server_counts -from app.database.crud.user import subtract_user_balance -from app.database.models import Subscription, User - - -logger = logging.getLogger(__name__) - - -class TrialPaymentError(Exception): - """Base exception for trial activation payment issues.""" - - -@dataclass(slots=True) -class TrialPaymentInsufficientFunds(TrialPaymentError): - required_amount: int - balance_amount: int - - @property - def missing_amount(self) -> int: - return max(0, self.required_amount - self.balance_amount) - - -class TrialPaymentChargeFailed(TrialPaymentError): - """Raised when balance charge could not be completed.""" - - -def get_trial_activation_charge_amount() -> int: - """Returns the configured activation charge in kopeks if payment is enabled.""" - - if not settings.is_trial_paid_activation_enabled(): - return 0 - - try: - price_kopeks = int(settings.get_trial_activation_price() or 0) - except (TypeError, ValueError): # pragma: no cover - defensive - price_kopeks = 0 - - return max(0, price_kopeks) - - -def preview_trial_activation_charge(user: User) -> int: - """Validates that the user can afford the trial activation charge.""" - - price_kopeks = get_trial_activation_charge_amount() - if price_kopeks <= 0: - return 0 - - balance = int(getattr(user, "balance_kopeks", 0) or 0) - if balance < price_kopeks: - raise TrialPaymentInsufficientFunds(price_kopeks, balance) - - return price_kopeks - - -async def charge_trial_activation_if_required( - db: AsyncSession, - user: User, - *, - description: Optional[str] = None, -) -> int: - """Charges the user's balance if paid trial activation is enabled. - - Returns the charged amount in kopeks. If payment is not required or the - configured price is zero, the function returns ``0``. - """ - - price_kopeks = preview_trial_activation_charge(user) - if price_kopeks <= 0: - return 0 - - charge_description = description or "Активация триальной подписки" - - success = await subtract_user_balance( - db, - user, - price_kopeks, - charge_description, - ) - if not success: - raise TrialPaymentChargeFailed() - - # subtract_user_balance обновляет пользователя, но на всякий случай приводим к int - return int(price_kopeks) - - -async def rollback_trial_subscription_activation( - db: AsyncSession, - subscription: Optional[Subscription], -) -> bool: - """Attempts to undo a previously created trial subscription. - - Returns ``True`` when the rollback succeeds or when ``subscription`` is - falsy. In case of a database failure the function returns ``False`` after - logging the error so callers can decide how to proceed. - """ - - if not subscription: - return True - - try: - await decrement_subscription_server_counts(db, subscription) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Failed to decrement server counters during trial rollback for %s: %s", - subscription.user_id, - error, - ) - - try: - await db.delete(subscription) - await db.commit() - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Failed to remove trial subscription %s after charge failure: %s", - getattr(subscription, "id", ""), - error, - ) - await db.rollback() - return False - - return True diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index cdd49c65..e089968d 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -67,13 +67,6 @@ from app.services.payment_service import PaymentService, get_wata_payment_by_lin from app.services.promo_offer_service import promo_offer_service from app.services.promocode_service import PromoCodeService from app.services.subscription_service import SubscriptionService -from app.services.trial_activation_service import ( - TrialPaymentChargeFailed, - TrialPaymentInsufficientFunds, - charge_trial_activation_if_required, - rollback_trial_subscription_activation, - preview_trial_activation_charge, -) from app.services.subscription_purchase_service import ( purchase_service, PurchaseBalanceError, @@ -2895,13 +2888,6 @@ async def get_subscription_details( trial_duration_days = ( settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None ) - trial_price_kopeks = settings.get_trial_activation_price() - trial_payment_required = ( - settings.is_trial_paid_activation_enabled() and trial_price_kopeks > 0 - ) - trial_price_label = ( - settings.format_price(trial_price_kopeks) if trial_payment_required else None - ) subscription_missing_reason = None if subscription is None: @@ -2965,9 +2951,6 @@ async def get_subscription_details( trial_available=trial_available, trial_duration_days=trial_duration_days, trial_status="available" if trial_available else "unavailable", - trial_payment_required=trial_payment_required, - trial_price_kopeks=trial_price_kopeks if trial_payment_required else None, - trial_price_label=trial_price_label, **autopay_extras, ) @@ -3120,20 +3103,6 @@ async def activate_subscription_trial_endpoint( }, ) - try: - preview_trial_activation_charge(user) - except TrialPaymentInsufficientFunds as error: - missing = error.missing_amount - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": "Not enough funds to activate the trial", - "missing_amount_kopeks": missing, - "required_amount_kopeks": error.required_amount, - "balance_kopeks": error.balance_amount, - }, - ) from error forced_devices = None if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() @@ -3158,61 +3127,6 @@ async def activate_subscription_trial_endpoint( }, ) from error - charged_amount = 0 - try: - charged_amount = await charge_trial_activation_if_required(db, user) - except TrialPaymentInsufficientFunds as error: - rollback_success = await rollback_trial_subscription_activation(db, subscription) - await db.refresh(user) - if not rollback_success: - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={ - "code": "trial_rollback_failed", - "message": "Failed to revert trial activation after charge error", - }, - ) from error - - logger.error( - "Balance check failed after trial creation for user %s: %s", - user.id, - error, - ) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": "Not enough funds to activate the trial", - "missing_amount_kopeks": error.missing_amount, - "required_amount_kopeks": error.required_amount, - "balance_kopeks": error.balance_amount, - }, - ) from error - except TrialPaymentChargeFailed as error: - rollback_success = await rollback_trial_subscription_activation(db, subscription) - await db.refresh(user) - if not rollback_success: - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={ - "code": "trial_rollback_failed", - "message": "Failed to revert trial activation after charge error", - }, - ) from error - - logger.error( - "Failed to charge balance for trial activation after subscription %s creation: %s", - subscription.id, - error, - ) - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={ - "code": "charge_failed", - "message": "Failed to charge balance for trial activation", - }, - ) from error - await db.refresh(user) await db.refresh(subscription) @@ -3244,9 +3158,6 @@ async def activate_subscription_trial_endpoint( duration_days = settings.TRIAL_DURATION_DAYS language_code = _normalize_language_code(user) - charged_amount_label = ( - settings.format_price(charged_amount) if charged_amount > 0 else None - ) if language_code == "ru": if duration_days: message = f"Триал активирован на {duration_days} дн. Приятного пользования!" @@ -3258,19 +3169,8 @@ async def activate_subscription_trial_endpoint( else: message = "Trial activated successfully. Enjoy!" - if charged_amount_label: - if language_code == "ru": - message = f"{message}\n\n💳 С вашего баланса списано {charged_amount_label}." - else: - message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance." - await _with_admin_notification_service( - lambda service: service.send_trial_activation_notification( - db, - user, - subscription, - charged_amount_kopeks=charged_amount, - ) + lambda service: service.send_trial_activation_notification(db, user, subscription) ) return MiniAppSubscriptionTrialResponse( @@ -3278,10 +3178,6 @@ async def activate_subscription_trial_endpoint( subscription_id=getattr(subscription, "id", None), trial_status="activated", trial_duration_days=duration_days, - charged_amount_kopeks=charged_amount if charged_amount > 0 else None, - charged_amount_label=charged_amount_label, - balance_kopeks=user.balance_kopeks, - balance_label=settings.format_price(user.balance_kopeks), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 839a6f17..c21b6060 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -465,11 +465,8 @@ class MiniAppSubscriptionResponse(BaseModel): trial_available: bool = False trial_duration_days: Optional[int] = None trial_status: Optional[str] = None - trial_payment_required: bool = Field(default=False, alias="trialPaymentRequired") - trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks") - trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel") - model_config = ConfigDict(extra="allow", populate_by_name=True) + model_config = ConfigDict(extra="allow") class MiniAppSubscriptionServerOption(BaseModel): @@ -739,10 +736,6 @@ class MiniAppSubscriptionTrialResponse(BaseModel): subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") trial_status: Optional[str] = Field(default=None, alias="trialStatus") trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays") - charged_amount_kopeks: Optional[int] = Field(default=None, alias="chargedAmountKopeks") - charged_amount_label: Optional[str] = Field(default=None, alias="chargedAmountLabel") - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") model_config = ConfigDict(populate_by_name=True)