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: