mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-17 09:30:35 +00:00
Fix subscription checkout resume after top-ups
This commit is contained in:
@@ -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 "❌ Отсутствует"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
52
app/services/subscription_checkout_service.py
Normal file
52
app/services/subscription_checkout_service.py
Normal 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))
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user