feat: update tariff UI and mini app

This commit is contained in:
3252a8
2026-04-28 14:34:12 +03:00
parent 9deb98bbe7
commit 829758dc44
4 changed files with 326 additions and 10 deletions
+36
View File
@@ -2205,6 +2205,14 @@ def _serialize_subscription(
"traffic_used": _format_bytes(active.get("traffic_used_bytes")), "traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_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")), "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)), "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None), "provider": getattr(local_sub, "provider", None),
} }
@@ -2217,6 +2225,34 @@ def _serialize_plans(
subscription_options: Optional[Dict[int, float]] = None, subscription_options: Optional[Dict[int, float]] = None,
stars_subscription_options: Optional[Dict[int, int]] = None, stars_subscription_options: Optional[Dict[int, int]] = None,
) -> List[Dict[str, Any]]: ) -> 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_subscription_options = subscription_options or settings.subscription_options
active_stars_subscription_options = stars_subscription_options or settings.stars_subscription_options active_stars_subscription_options = stars_subscription_options or settings.stars_subscription_options
plans: List[Dict[str, Any]] = [] plans: List[Dict[str, Any]] = []
+16 -9
View File
@@ -67,20 +67,23 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
'platega': 'Platega', 'platega': 'Platega',
}.get(payment.provider, payment.provider or 'Unknown') }.get(payment.provider, payment.provider or 'Unknown')
traffic_mode = getattr(settings, "traffic_sale_mode", False) sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
if traffic_mode: traffic_like = sale_base in {"traffic", "traffic_package", "topup"}
traffic_val = payment.subscription_duration_months or 0 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}" 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) period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
else: else:
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0) 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 ( return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n" f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n" f"👤 {user_info}\n"
f"💳 {provider_text}\n" f"💳 {provider_text}\n"
f"📅 {payment_date}\n" f"📅 {payment_date}\n"
f"{period_line}\n" f"{period_line}{tariff_line}{sale_line}\n"
f"📋 {payment.status}\n" f"📋 {payment.status}\n"
f"📝 {payment.description or 'N/A'}" 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_status"),
_("admin_csv_description"), _("admin_csv_description"),
_("admin_csv_units"), _("admin_csv_units"),
"sale_mode",
"tariff_key",
"purchased_gb",
_("admin_csv_created_at"), _("admin_csv_created_at"),
_("admin_csv_provider_payment_id") _("admin_csv_provider_payment_id")
]) ])
traffic_mode = getattr(settings, "traffic_sale_mode", False)
# Write payment data # Write payment data
for payment in all_payments: for payment in all_payments:
units_val = payment.subscription_duration_months or "" units_val = payment.purchased_gb or payment.subscription_duration_months or ""
if traffic_mode and units_val not in ("", None): if (payment.purchased_gb is not None) and units_val not in ("", None):
try: try:
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
except Exception: except Exception:
units_val = payment.subscription_duration_months or "" units_val = payment.purchased_gb or payment.subscription_duration_months or ""
writer.writerow([ writer.writerow([
payment.payment_id, payment.payment_id,
payment.user_id, payment.user_id,
@@ -237,6 +241,9 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
payment.status, payment.status,
payment.description or "", payment.description or "",
units_val, 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.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
payment.provider_payment_id or "" payment.provider_payment_id or ""
]) ])
+225
View File
@@ -13,6 +13,10 @@ from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_subscription_options_keyboard,
get_back_to_main_menu_markup, get_back_to_main_menu_markup,
get_autorenew_confirm_keyboard, 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.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
@@ -57,6 +61,21 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
return return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL 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 {} traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_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) 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) 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( async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery], event: Union[types.Message, types.CallbackQuery],
i18n_data: dict, i18n_data: dict,
@@ -215,6 +427,11 @@ async def my_subscription_command_handler(
config_link=config_link_value, config_link=config_link_value,
) )
else: 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( text = get_text(
"my_subscription_details", "my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", 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, traffic_period=period_label,
) )
if tariff_prefix:
text = tariff_prefix + "\n" + text
base_markup = get_back_to_main_menu_markup(current_lang, i18n) base_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = base_markup.inline_keyboard 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") 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: if prepend_rows:
kb = prepend_rows + kb kb = prepend_rows + kb
except Exception: except Exception:
+49 -1
View File
@@ -1,6 +1,6 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from aiogram.types import InlineKeyboardMarkup, WebAppInfo 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 from config.settings import Settings
@@ -185,6 +185,54 @@ def get_subscription_options_keyboard(subscription_options: Dict[
return builder.as_markup() 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, def get_payment_method_keyboard(months: int, price: float,
stars_price: Optional[int], stars_price: Optional[int],
currency_symbol_val: str, lang: str, currency_symbol_val: str, lang: str,