Fix subscription checkout resume after top-ups

This commit is contained in:
Egor
2025-09-19 10:54:16 +03:00
parent 719d656a64
commit 57cc2d2e3a
8 changed files with 369 additions and 257 deletions

View File

@@ -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 "❌ Отсутствует"

View File

@@ -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"🎉 <b>Платеж успешно обработан!</b>\n\n"

View File

@@ -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 = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {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 = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {data['devices']}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {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,

View File

@@ -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(

View File

@@ -253,6 +253,8 @@ class RussianTexts(Texts):
<b>Пополните баланс на {amount} и попробуйте снова.</b>
"""
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 = {

View File

@@ -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"✅ <b>Платеж успешно завершен!</b>\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,

View File

@@ -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))

View File

@@ -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: