diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 6c67aa14..d1716fe2 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -11,29 +11,36 @@ from app.localization.texts import get_texts from app.database.models import User from app.utils.user_utils import mark_user_as_had_paid_subscription from app.database.crud.user_message import get_random_active_message +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) async def show_main_menu( - callback: types.CallbackQuery, - db_user: User, + callback: types.CallbackQuery, + db_user: User, db: AsyncSession ): texts = get_texts(db_user.language) - + from datetime import datetime db_user.last_activity = datetime.utcnow() await db.commit() - + has_active_subscription = bool(db_user.subscription) subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -44,11 +51,13 @@ async def show_main_menu( subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() + async def mark_user_as_had_paid_subscription( db: AsyncSession, user: User @@ -101,17 +110,20 @@ async def handle_back_to_menu( db: AsyncSession ): await state.clear() - + texts = get_texts(db_user.language) - + has_active_subscription = db_user.subscription is not None subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -121,13 +133,13 @@ async def handle_back_to_menu( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() - def _get_subscription_status(user: User, texts) -> str: if not user.subscription: return "❌ Отсутствует" diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index db169b32..ea3b2aee 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,13 +1,11 @@ import logging from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id -from app.localization.texts import get_texts logger = logging.getLogger(__name__) @@ -91,33 +89,7 @@ async def handle_successful_payment( if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await payment_service.build_topup_success_keyboard(user) await message.answer( f"🎉 Платеж успешно обработан!\n\n" diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index af67d840..8a868c7d 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -43,6 +43,12 @@ from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService +from app.services.subscription_checkout_service import ( + clear_subscription_checkout_draft, + get_subscription_checkout_draft, + save_subscription_checkout_draft, + should_offer_checkout_resume, +) from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -56,6 +62,109 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() + +async def _prepare_subscription_summary( + db_user: User, + data: Dict[str, Any], + texts, +) -> Tuple[str, Dict[str, Any]]: + from app.utils.pricing_utils import ( + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, + ) + + summary_data = dict(data) + countries = await _get_available_countries() + + months_in_period = calculate_months_from_days(summary_data['period_days']) + period_display = format_period_description(summary_data['period_days'], db_user.language) + + base_price = PERIOD_PRICES[summary_data['period_days']] + + if settings.is_traffic_fixed(): + traffic_limit = settings.get_fixed_traffic_limit() + traffic_price_per_month = settings.get_traffic_price(traffic_limit) + final_traffic_gb = traffic_limit + else: + traffic_gb = summary_data.get('traffic_gb', 0) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) + final_traffic_gb = traffic_gb + + total_traffic_price = traffic_price_per_month * months_in_period + + countries_price_per_month = 0 + selected_countries_names: List[str] = [] + selected_server_prices: List[int] = [] + + selected_country_ids = set(summary_data.get('countries', [])) + for country in countries: + if country['uuid'] in selected_country_ids: + server_price_per_month = country['price_kopeks'] + countries_price_per_month += server_price_per_month + selected_countries_names.append(country['name']) + selected_server_prices.append(server_price_per_month * months_in_period) + + total_countries_price = countries_price_per_month * months_in_period + + devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE + total_devices_price = devices_price_per_month * months_in_period + + total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + + monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) + + if not is_valid: + raise ValueError("Subscription price calculation validation failed") + + summary_data['total_price'] = total_price + summary_data['server_prices_for_period'] = selected_server_prices + + if settings.is_traffic_fixed(): + if final_traffic_gb == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{final_traffic_gb} ГБ" + else: + if summary_data.get('traffic_gb', 0) == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" + + details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + + if total_traffic_price > 0: + details_lines.append( + f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" + ) + if total_countries_price > 0: + details_lines.append( + f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" + ) + if total_devices_price > 0: + details_lines.append( + f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" + ) + + details_text = "\n".join(details_lines) + + summary_text = ( + "📋 Сводка заказа\n\n" + f"📅 Период: {period_display}\n" + f"📊 Трафик: {traffic_display}\n" + f"🌍 Страны: {', '.join(selected_countries_names)}\n" + f"📱 Устройства: {devices_selected}\n\n" + "💰 Детализация стоимости:\n" + f"{details_text}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + return summary_text, summary_data + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -664,6 +773,13 @@ async def apply_countries_changes( data = await state.get_data() texts = get_texts(db_user.language) + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) subscription = db_user.subscription selected_countries = data.get('countries', []) @@ -1455,7 +1571,10 @@ async def confirm_add_devices( missing_kopeks = price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2048,107 +2167,29 @@ async def devices_continue( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation - if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) return - + data = await state.get_data() texts = get_texts(db_user.language) - - countries = await _get_available_countries() - selected_countries_names = [] - - months_in_period = calculate_months_from_days(data['period_days']) - period_display = format_period_description(data['period_days'], db_user.language) - - base_price = PERIOD_PRICES[data['period_days']] - - if settings.is_traffic_fixed(): - traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - final_traffic_gb = settings.get_fixed_traffic_limit() - else: - traffic_price_per_month = settings.get_traffic_price(data['traffic_gb']) - final_traffic_gb = data['traffic_gb'] - - total_traffic_price = traffic_price_per_month * months_in_period - - countries_price_per_month = 0 - selected_server_prices = [] - - for country in countries: - if country['uuid'] in data['countries']: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - selected_countries_names.append(country['name']) - selected_server_prices.append(server_price_per_month * months_in_period) - - total_countries_price = countries_price_per_month * months_in_period - - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - - total_price = base_price + total_traffic_price + total_countries_price + total_devices_price - - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) - - if not is_valid: + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - - data['total_price'] = total_price - data['server_prices_for_period'] = selected_server_prices - await state.set_data(data) - - if settings.is_traffic_fixed(): - if final_traffic_gb == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{final_traffic_gb} ГБ" - else: - if data['traffic_gb'] == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{data['traffic_gb']} ГБ" - - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] - if total_traffic_price > 0: - details_lines.append( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" - ) - if total_countries_price > 0: - details_lines.append( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" - ) - if total_devices_price > 0: - details_lines.append( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" - ) - details_text = "\n".join(details_lines) + await state.set_data(prepared_data) + await save_subscription_checkout_draft(db_user.id, prepared_data) - summary_text = ( - "📋 Сводка заказа\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {data['devices']}\n\n" - "💰 Детализация стоимости:\n" - f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) - await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML", ) - + await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer() @@ -2164,7 +2205,14 @@ async def confirm_purchase( data = await state.get_data() texts = get_texts(db_user.language) - + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(data['period_days']) @@ -2219,11 +2267,16 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return + purchase_completed = False + try: success = await subtract_user_balance( db, db_user, final_price, @@ -2234,7 +2287,10 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2396,6 +2452,7 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + purchase_completed = True logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") except Exception as e: @@ -2405,9 +2462,48 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + if purchase_completed: + await clear_subscription_checkout_draft(db_user.id) + await state.clear() await callback.answer() + + +async def resume_subscription_checkout( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, +): + texts = get_texts(db_user.language) + + draft = await get_subscription_checkout_draft(db_user.id) + + if not draft: + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) + except ValueError as exc: + logger.error( + f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" + ) + await clear_subscription_checkout_draft(db_user.id) + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + await state.set_data(prepared_data) + await state.set_state(SubscriptionStates.confirming_purchase) + await save_subscription_checkout_draft(db_user.id, prepared_data) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + + await callback.answer() async def add_traffic( callback: types.CallbackQuery, db_user: User, @@ -2705,14 +2801,15 @@ async def handle_subscription_cancel( db_user: User, db: AsyncSession ): - + texts = get_texts(db_user.language) - + await state.clear() - + await clear_subscription_checkout_draft(db_user.id) + from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) - + await callback.answer("❌ Покупка отменена") async def _get_available_countries(): @@ -3802,6 +3899,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_confirm", SubscriptionStates.confirming_purchase ) + + dp.callback_query.register( + resume_subscription_checkout, + F.data == "subscription_resume_checkout", + ) dp.callback_query.register( handle_autopay_menu, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a55bd6c6..b1fd2310 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -56,7 +56,8 @@ def get_main_menu_keyboard( has_active_subscription: bool = False, subscription_is_active: bool = False, balance_kopeks: int = 0, - subscription=None + subscription=None, + show_resume_checkout: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -131,7 +132,15 @@ def get_main_menu_keyboard( keyboard.append(subscription_buttons) else: keyboard.append([subscription_buttons[0]]) - + + if show_resume_checkout: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + keyboard.extend([ [ InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), @@ -166,17 +175,31 @@ def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_insufficient_balance_keyboard( + language: str = "ru", + resume_callback: str | None = None, +) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ + keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( text=texts.GO_TO_BALANCE_TOP_UP, callback_data="balance_topup", ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")], - ]) + ] + ] + + if resume_callback: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_subscription_keyboard( diff --git a/app/localization/texts.py b/app/localization/texts.py index 50360cac..499c03f2 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -253,6 +253,8 @@ class RussianTexts(Texts): Пополните баланс на {amount} и попробуйте снова. """ GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Вернуться к оформлению" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Сохраненный заказ не найден. Соберите подписку заново." SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!" BALANCE_INFO = """ @@ -541,6 +543,8 @@ To get started, select interface language: Top up {amount} and try again.""" GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." LANGUAGES = { diff --git a/app/services/payment_service.py b/app/services/payment_service.py index dc980dfc..0855be9e 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -22,6 +22,10 @@ from app.external.cryptobot import CryptoBotService from app.utils.currency_converter import currency_converter from app.database.database import get_db from app.localization.texts import get_texts +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) @@ -33,7 +37,49 @@ class PaymentService: self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None - + + async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: + texts = get_texts(user.language if user else "ru") + + has_active_subscription = ( + user + and user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + ) + + first_button = InlineKeyboardButton( + text=( + texts.MENU_EXTEND_SUBSCRIPTION + if has_active_subscription + else texts.MENU_BUY_SUBSCRIPTION + ), + callback_data=( + "subscription_extend" if has_active_subscription else "menu_buy" + ), + ) + + keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]] + + if user: + draft_exists = await has_subscription_checkout_draft(user.id) + if should_offer_checkout_resume(user, draft_exists): + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + + keyboard_rows.append([ + InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance") + ]) + keyboard_rows.append([ + InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + async def create_stars_invoice( self, amount_kopeks: int, @@ -124,33 +170,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -432,33 +452,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -545,33 +539,7 @@ class PaymentService: user = await get_user_by_telegram_id(db, telegram_id) break - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) message = ( f"✅ Платеж успешно завершен!\n\n" @@ -827,33 +795,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py new file mode 100644 index 00000000..e49b6a5e --- /dev/null +++ b/app/services/subscription_checkout_service.py @@ -0,0 +1,52 @@ +from typing import Optional + +from app.database.models import User +from app.utils.cache import UserCache + + +_CHECKOUT_SESSION_KEY = "subscription_checkout" +_CHECKOUT_TTL_SECONDS = 3600 + + +async def save_subscription_checkout_draft( + user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS +) -> bool: + """Persist subscription checkout draft data in cache.""" + + return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl) + + +async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]: + """Retrieve subscription checkout draft from cache.""" + + return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def clear_subscription_checkout_draft(user_id: int) -> bool: + """Remove stored subscription checkout draft for the user.""" + + return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def has_subscription_checkout_draft(user_id: int) -> bool: + draft = await get_subscription_checkout_draft(user_id) + return draft is not None + + +def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: + """ + Determine whether checkout resume button should be available for the user. + + Only users without an active paid subscription or users currently on trial + are eligible to continue assembling the subscription from the saved draft. + """ + + if not has_draft: + return False + + subscription = getattr(user, "subscription", None) + + if subscription is None: + return True + + return bool(getattr(subscription, "is_trial", False)) diff --git a/app/utils/cache.py b/app/utils/cache.py index 408f7246..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -203,14 +203,19 @@ class UserCache: @staticmethod async def set_user_session( - user_id: int, - session_key: str, - data: Any, + user_id: int, + session_key: str, + data: Any, expire: int = 1800 ) -> bool: key = cache_key("session", user_id, session_key) return await cache.set(key, data, expire) + @staticmethod + async def delete_user_session(user_id: int, session_key: str) -> bool: + key = cache_key("session", user_id, session_key) + return await cache.delete(key) + class SystemCache: