From 829758dc44569ea0a618c91178f7fff7c79b5c23 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 28 Apr 2026 14:34:12 +0300 Subject: [PATCH] feat: update tariff UI and mini app --- bot/app/web/subscription_webapp.py | 36 ++++ bot/handlers/admin/payments.py | 25 ++- bot/handlers/user/subscription/core.py | 225 +++++++++++++++++++++++++ bot/keyboards/inline/user_keyboards.py | 50 +++++- 4 files changed, 326 insertions(+), 10 deletions(-) diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 0849b93..1a8e601 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -2205,6 +2205,14 @@ def _serialize_subscription( "traffic_used": _format_bytes(active.get("traffic_used_bytes")), "traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")), "traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")), + "tariff_key": active.get("tariff_key"), + "tariff_name": active.get("tariff_name"), + "tariff_description": active.get("tariff_description"), + "billing_model": active.get("billing_model"), + "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")), + "topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")), + "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None, + "is_throttled": bool(active.get("is_throttled")), "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), "provider": getattr(local_sub, "provider", None), } @@ -2217,6 +2225,34 @@ def _serialize_plans( subscription_options: Optional[Dict[int, float]] = None, stars_subscription_options: Optional[Dict[int, int]] = None, ) -> List[Dict[str, Any]]: + if settings.tariffs_config: + plans: List[Dict[str, Any]] = [] + for tariff in settings.tariffs_config.enabled_tariffs: + item: Dict[str, Any] = { + "tariff_key": tariff.key, + "billing_model": tariff.billing_model, + "title": tariff.name(lang), + "description": tariff.description(lang), + "squad_uuids": tariff.squad_uuids, + } + if tariff.billing_model == "period": + item["periods"] = [ + { + "months": int(months), + "price": tariff.period_price(int(months), "rub"), + "stars_price": tariff.period_price(int(months), "stars"), + } + for months in tariff.enabled_periods + ] + item["monthly_gb"] = tariff.monthly_gb + else: + item["traffic_packages"] = [ + {"gb": package.gb, "price": package.price} + for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + ] + plans.append(item) + return plans + active_subscription_options = subscription_options or settings.subscription_options active_stars_subscription_options = stars_subscription_options or settings.stars_subscription_options plans: List[Dict[str, Any]] = [] diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index f7ba8eb..7f25e8a 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -67,20 +67,23 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S 'platega': 'Platega', }.get(payment.provider, payment.provider or 'Unknown') - traffic_mode = getattr(settings, "traffic_sale_mode", False) - if traffic_mode: - traffic_val = payment.subscription_duration_months or 0 + sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0] + traffic_like = sale_base in {"traffic", "traffic_package", "topup"} + if traffic_like: + traffic_val = payment.purchased_gb or payment.subscription_duration_months or 0 traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}" period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display) else: period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0) + tariff_line = f"\nTariff: {payment.tariff_key}" if payment.tariff_key else "" + sale_line = f"\nSale mode: {payment.sale_mode}" if payment.sale_mode else "" return ( f"{status_emoji} {payment.amount} {payment.currency}\n" f"👤 {user_info}\n" f"💳 {provider_text}\n" f"📅 {payment_date}\n" - f"{period_line}\n" + f"{period_line}{tariff_line}{sale_line}\n" f"📋 {payment.status}\n" f"📝 {payment.description or 'N/A'}" ) @@ -212,20 +215,21 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: _("admin_csv_status"), _("admin_csv_description"), _("admin_csv_units"), + "sale_mode", + "tariff_key", + "purchased_gb", _("admin_csv_created_at"), _("admin_csv_provider_payment_id") ]) - traffic_mode = getattr(settings, "traffic_sale_mode", False) - # Write payment data for payment in all_payments: - units_val = payment.subscription_duration_months or "" - if traffic_mode and units_val not in ("", None): + units_val = payment.purchased_gb or payment.subscription_duration_months or "" + if (payment.purchased_gb is not None) and units_val not in ("", None): try: units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" except Exception: - units_val = payment.subscription_duration_months or "" + units_val = payment.purchased_gb or payment.subscription_duration_months or "" writer.writerow([ payment.payment_id, payment.user_id, @@ -237,6 +241,9 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: payment.status, payment.description or "", units_val, + payment.sale_mode or "", + payment.tariff_key or "", + payment.purchased_gb or "", payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "", payment.provider_payment_id or "" ]) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 9f86679..85d580e 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -13,6 +13,10 @@ from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_back_to_main_menu_markup, get_autorenew_confirm_keyboard, + get_tariff_catalog_keyboard, + get_tariff_periods_keyboard, + get_tariff_packages_keyboard, + get_payment_method_keyboard, ) from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService @@ -57,6 +61,21 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + tariffs_config = getattr(settings, "tariffs_config", None) + if tariffs_config: + text_content = get_text("select_subscription_period") + reply_markup = get_tariff_catalog_keyboard(tariffs_config.enabled_tariffs, current_lang, i18n) + target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event + if isinstance(event, types.CallbackQuery): + try: + await target_message_obj.edit_text(text_content, reply_markup=reply_markup) + except Exception: + await target_message_obj.answer(text_content, reply_markup=reply_markup) + await event.answer() + else: + await target_message_obj.answer(text_content, reply_markup=reply_markup) + return + traffic_packages = getattr(settings, "traffic_packages", {}) or {} stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {} traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages) @@ -108,6 +127,199 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery, i1 await display_subscription_options(callback, i18n_data, settings, session) +@router.callback_query(F.data.startswith("tariff:select:")) +async def select_tariff_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + return + tariff_key = callback.data.split(":", 2)[2] + try: + tariff = config.require(tariff_key) + except Exception: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + if tariff.billing_model == "period": + markup = get_tariff_periods_keyboard(tariff, current_lang, i18n, settings) + else: + markup = get_tariff_packages_keyboard(tariff, tariff.traffic_packages.rub, current_lang, i18n) + text = f"{tariff.name(current_lang)}\n{tariff.description(current_lang)}".strip() + await callback.message.edit_text(text, reply_markup=markup) + await callback.answer() + + +@router.callback_query(F.data.startswith("tariff:period:")) +async def select_tariff_period_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + return + _, _, tariff_key, months_raw = callback.data.split(":", 3) + tariff = config.require(tariff_key) + months = int(months_raw) + price_rub = tariff.period_price(months, "rub") + stars_price = tariff.period_price(months, "stars") + if price_rub is None: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + markup = get_payment_method_keyboard( + months, + price_rub, + int(stars_price) if stars_price else None, + settings.DEFAULT_CURRENCY_SYMBOL, + current_lang, + i18n, + settings, + sale_mode=f"subscription@{tariff.key}", + ) + await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) + await callback.answer() + + +@router.callback_query(F.data.startswith("tariff:package:")) +async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + return + _, _, tariff_key, gb_raw = callback.data.split(":", 3) + tariff = config.require(tariff_key) + gb = float(gb_raw) + packages = tariff.traffic_packages.rub if tariff.billing_model == "traffic" else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else []) + package = next((pkg for pkg in packages if float(pkg.gb) == gb), None) + if not package: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + sale_mode = f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}" + markup = get_payment_method_keyboard( + gb, + package.price, + None, + settings.DEFAULT_CURRENCY_SYMBOL, + current_lang, + i18n, + settings, + sale_mode=sale_mode, + ) + await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup) + await callback.answer() + + +@router.callback_query(F.data == "tariff_topup:list") +async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + config = settings.tariffs_config + active = await subscription_service.get_active_subscription_details(session, callback.from_user.id) + if not config or not active or not active.get("tariff_key") or not callback.message: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + tariff = config.require(active["tariff_key"]) + packages = config.topup_packages_for(tariff) + rub_packages = packages.rub if packages else [] + if not rub_packages: + await callback.answer(get_text("no_subscription_options_available"), show_alert=True) + return + markup = get_tariff_packages_keyboard(tariff, rub_packages, current_lang, i18n, back_callback="main_action:my_subscription") + await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup) + await callback.answer() + + +@router.callback_query(F.data == "tariff_change:list") +async def tariff_change_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + config = settings.tariffs_config + active = await subscription_service.get_active_subscription_details(session, callback.from_user.id) + if not config or not active or not callback.message: + await callback.answer("Error", show_alert=True) + return + rows = [] + for tariff in config.enabled_tariffs: + if tariff.key == active.get("tariff_key"): + continue + rows.append([InlineKeyboardButton(text=tariff.name(current_lang), callback_data=f"tariff_change:select:{tariff.key}")]) + rows.append([InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data="main_action:my_subscription")]) + await callback.message.edit_text("Выберите тариф", reply_markup=InlineKeyboardMarkup(inline_keyboard=rows)) + await callback.answer() + + +@router.callback_query(F.data.startswith("tariff_change:select:")) +async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer("Error", show_alert=True) + return + tariff_key = callback.data.split(":", 2)[2] + target = config.require(tariff_key) + db_sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id) + if not db_sub: + await callback.answer("Error", show_alert=True) + return + options = subscription_service.calculate_tariff_switch_options(db_sub, target) + rows = [] + if options["mode"] == "period_to_period": + rows.append([InlineKeyboardButton(text=f"Без доплаты, дней станет {options['recalc_days']}", callback_data=f"tariff_change:apply:{target.key}:recalc_days")]) + if options.get("paid_diff_rub", 0) > 0: + rows.append([InlineKeyboardButton(text=f"Доплатить {options['paid_diff_rub']} RUB", callback_data=f"tariff_change:pay:{target.key}:{options['paid_diff_rub']}")]) + elif options["mode"] == "period_to_traffic": + rows.append([InlineKeyboardButton(text=f"Перейти без доплаты, получить {options['converted_gb']} GB", callback_data=f"tariff_change:apply:{target.key}:convert_days_to_gb")]) + for package in target.traffic_packages.rub: + rows.append([InlineKeyboardButton(text=f"+ {package.gb:g} GB за {package.price:g} RUB", callback_data=f"tariff:package:{target.key}:{package.gb:g}")]) + else: + for months in target.enabled_periods: + price = target.period_price(months, "rub") + if price: + rows.append([InlineKeyboardButton(text=f"{months} мес. за {price:g} RUB", callback_data=f"tariff:period:{target.key}:{months}")]) + rows.append([InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data="tariff_change:list")]) + await callback.message.edit_text(f"{target.name(current_lang)}\n{target.description(current_lang)}".strip(), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows)) + await callback.answer() + + +@router.callback_query(F.data.startswith("tariff_change:apply:")) +async def tariff_change_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession): + _, _, tariff_key, mode = callback.data.split(":", 3) + result = await subscription_service.switch_tariff_without_payment(session, callback.from_user.id, tariff_key, mode) + if result: + await session.commit() + await callback.answer("Готово", show_alert=True) + await my_subscription_command_handler(callback, i18n_data, settings, subscription_service.panel_service, subscription_service, session, callback.bot) + else: + await callback.answer("Error", show_alert=True) + + +@router.callback_query(F.data.startswith("tariff_change:pay:")) +async def tariff_change_pay_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + _, _, tariff_key, amount_raw = callback.data.split(":", 3) + amount = float(amount_raw) + markup = get_payment_method_keyboard( + 1, + amount, + None, + settings.DEFAULT_CURRENCY_SYMBOL, + current_lang, + i18n, + settings, + sale_mode=f"tariff_upgrade@{tariff_key}", + ) + await callback.message.edit_text("Выберите способ оплаты", reply_markup=markup) + await callback.answer() + + async def my_subscription_command_handler( event: Union[types.Message, types.CallbackQuery], i18n_data: dict, @@ -215,6 +427,11 @@ async def my_subscription_command_handler( config_link=config_link_value, ) else: + tariff_prefix = "" + if active.get("tariff_name"): + tariff_prefix = f"🎟 {active.get('tariff_name')}\n" + if active.get("tariff_description"): + tariff_prefix += f"{active.get('tariff_description')}\n" text = get_text( "my_subscription_details", end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", @@ -230,6 +447,8 @@ async def my_subscription_command_handler( ), traffic_period=period_label, ) + if tariff_prefix: + text = tariff_prefix + "\n" + text base_markup = get_back_to_main_menu_markup(current_lang, i18n) kb = base_markup.inline_keyboard @@ -324,6 +543,12 @@ async def my_subscription_command_handler( InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage") ]) + if settings.tariffs_config and local_sub and local_sub.tariff_key: + prepend_rows.append([ + InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list"), + InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list"), + ]) + if prepend_rows: kb = prepend_rows + kb except Exception: diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 36f7427..32e167a 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -1,6 +1,6 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.types import InlineKeyboardMarkup, WebAppInfo -from typing import Dict, Optional, List, Tuple +from typing import Dict, Optional, List, Tuple, Any from config.settings import Settings @@ -185,6 +185,54 @@ def get_subscription_options_keyboard(subscription_options: Dict[ return builder.as_markup() +def get_tariff_catalog_keyboard(tariffs: List[Any], lang: str, i18n_instance) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + for tariff in tariffs: + label = tariff.name(lang) + if tariff.billing_model == "period": + min_price = tariff.min_period_price_rub() + if min_price is not None: + label = f"{label} от {min_price:g}" + else: + package = tariff.min_traffic_package_rub() + if package: + label = f"{label} от {package.price:g} / {package.gb:g} GB" + builder.row(InlineKeyboardButton(text=label, callback_data=f"tariff:select:{tariff.key}")) + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main")) + return builder.as_markup() + + +def get_tariff_periods_keyboard(tariff: Any, lang: str, i18n_instance, settings: Settings) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + for months in tariff.enabled_periods: + rub_price = tariff.period_price(months, "rub") + if rub_price and rub_price > 0: + builder.row( + InlineKeyboardButton( + text=_("subscribe_for_months_button", months=months, price=rub_price, currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL), + callback_data=f"tariff:period:{tariff.key}:{months}", + ) + ) + builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:subscribe")) + return builder.as_markup() + + +def get_tariff_packages_keyboard(tariff: Any, packages: List[Any], lang: str, i18n_instance, back_callback: str = "main_action:subscribe") -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + for package in packages: + builder.row( + InlineKeyboardButton( + text=_("buy_traffic_package_button", traffic_gb=f"{package.gb:g}", price=package.price, currency_symbol="RUB"), + callback_data=f"tariff:package:{tariff.key}:{package.gb:g}", + ) + ) + builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)) + return builder.as_markup() + + def get_payment_method_keyboard(months: int, price: float, stars_price: Optional[int], currency_symbol_val: str, lang: str,