From aa5a9496edaf48df3aa98019074ed52fca9b4c3b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 3 May 2026 22:36:33 +0300 Subject: [PATCH] feat: hwid limits and topups --- bot/app/web/frontend/src/App.svelte | 135 ++++++++++++- bot/app/web/subscription_webapp.py | 141 +++++++++++++- bot/handlers/user/subscription/core.py | 90 +++++++++ .../user/subscription/payments_crypto.py | 2 +- .../user/subscription/payments_freekassa.py | 3 +- .../user/subscription/payments_platega.py | 3 +- .../user/subscription/payments_severpay.py | 3 +- .../user/subscription/payments_stars.py | 2 +- .../user/subscription/payments_yookassa.py | 7 +- bot/keyboards/inline/user_keyboards.py | 14 ++ bot/services/subscription_service.py | 184 +++++++++++++++++- config/tariffs.example.json | 11 ++ config/tariffs_config.py | 31 +++ db/dal/tariff_dal.py | 20 +- db/migrator.py | 21 ++ db/models.py | 16 ++ docs/tariffs.md | 13 ++ locales/en.json | 14 +- locales/ru.json | 14 +- tests/test_tariffs_config.py | 22 +++ tests/test_webapp_assets.py | 7 + 21 files changed, 738 insertions(+), 15 deletions(-) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index de1683b..8a47f8d 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -17,6 +17,7 @@ Home, LockKeyhole, Mail, + Plus, RefreshCw, Send, Smartphone, @@ -215,10 +216,13 @@ let paymentStep = "tariff"; let selectedTariffKey = ""; let topupModalOpen = query.get("topup") === "1"; + let deviceTopupModalOpen = query.get("device_topup") === "1"; let changeModalOpen = query.get("change") === "1"; let topupOptions = null; + let deviceTopupOptions = null; let changeOptions = null; let selectedTopupPlan = null; + let selectedDeviceTopupPlan = null; let selectedChangeTarget = null; let selectedChangeAction = null; let changeConfirmOpen = false; @@ -409,6 +413,16 @@ { id: "standard:topup:200", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 200, months: 200, price: 1299, currency: "RUB", title: "200 GB", subtitle: "Стандарт" }, ], }; + DEV_MOCK.data.device_topup_options = { + ok: true, + tariff_key: "standard", + tariff_name: "Стандарт", + current_limit: 5, + plans: [ + { id: "standard:hwid:1", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "hwid_devices", device_count: 1, months: 1, price: 99, currency: "RUB", title: "+1", subtitle: "Стандарт" }, + { id: "standard:hwid:3", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "hwid_devices", device_count: 3, months: 3, price: 249, currency: "RUB", title: "+3", subtitle: "Стандарт" }, + ], + }; } else if (mode === "devices") { DEV_MOCK.data.settings.my_devices_enabled = true; DEV_MOCK.data.subscription = { @@ -482,7 +496,7 @@ $: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0); $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); $: applyFavicon(CFG.logoUrl, brandEmoji); - $: syncBodyScrollLock(paymentModalOpen || changeModalOpen || changeConfirmOpen || topupModalOpen || linkEmailOpen); + $: syncBodyScrollLock(paymentModalOpen || changeModalOpen || changeConfirmOpen || topupModalOpen || deviceTopupModalOpen || linkEmailOpen); $: if (!tariffMode && !selectedPlan && plans.length) selectedPlan = plans[Math.min(1, plans.length - 1)]; $: if (tariffMode && selectedTariffKey && !tariffCatalog.some((tariff) => tariff.key === selectedTariffKey)) { selectedTariffKey = ""; @@ -753,6 +767,7 @@ await loadDevices(); } if (topupModalOpen) await loadTopupOptions(); + if (deviceTopupModalOpen) await loadDeviceTopupOptions(); if (changeModalOpen) await loadTariffChangeOptions(); } @@ -805,6 +820,7 @@ } if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; if (path === "/devices") return structuredCloneSafe(DEV_MOCK.data.devices); + if (path === "/devices/topup-options") return structuredCloneSafe(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] }); if (path === "/tariffs/topup-options") return structuredCloneSafe(DEV_MOCK.data.topup_options || { ok: true, plans: [] }); if (path === "/tariffs/change-options") return structuredCloneSafe(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] }); if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") { @@ -1327,6 +1343,7 @@ body: JSON.stringify({ months: selectedPlan.months, traffic_gb: selectedPlan.traffic_gb, + device_count: selectedPlan.device_count, tariff_key: selectedPlan.tariff_key, sale_mode: selectedPlan.sale_mode, method: selectedMethod, @@ -1410,6 +1427,47 @@ } } + async function loadDeviceTopupOptions() { + if (deviceTopupOptions || tariffActionBusy) return; + tariffActionBusy = true; + try { + const response = await api("/devices/topup-options"); + if (!response?.ok) throw response; + deviceTopupOptions = response; + selectedDeviceTopupPlan = response.plans?.[0] || null; + } catch (error) { + showToast(error?.message || t("wa_device_topup_options_failed")); + deviceTopupModalOpen = false; + } finally { + tariffActionBusy = false; + } + } + + async function createDeviceTopupPayment() { + if (!selectedDeviceTopupPlan || !selectedMethod || payBusy) return; + payBusy = true; + try { + const response = await api("/payments", { + method: "POST", + body: JSON.stringify({ + months: selectedDeviceTopupPlan.device_count || selectedDeviceTopupPlan.months, + device_count: selectedDeviceTopupPlan.device_count || selectedDeviceTopupPlan.months, + tariff_key: selectedDeviceTopupPlan.tariff_key || deviceTopupOptions?.tariff_key, + sale_mode: "hwid_devices", + method: selectedMethod, + }), + }); + if (!response.ok || !response.payment_url) throw response; + showToast(t("wa_payment_created")); + openExternalLink(response.payment_url); + deviceTopupModalOpen = false; + } catch (error) { + showToast(error?.message || t("wa_payment_create_failed")); + } finally { + payBusy = false; + } + } + async function applyTariffChange() { if (!selectedChangeTarget || !selectedChangeAction || tariffActionBusy) return; if (selectedChangeAction.kind === "payment") { @@ -1676,6 +1734,16 @@ loadDevices(); } + function openDeviceTopupModal() { + selectedMethod = methods[0]?.id || ""; + deviceTopupModalOpen = true; + loadDeviceTopupOptions(); + } + + function closeDeviceTopupModal() { + deviceTopupModalOpen = false; + } + function goSettings() { paymentModalOpen = false; activeTab = "settings"; @@ -2539,6 +2607,12 @@
+ {#if subscription?.active && subscription?.max_devices !== 0} + + {/if} {#if devicesBusy && !devicesLoaded} @@ -3092,6 +3166,65 @@ + +
+ {#if deviceTopupOptions?.plans?.length} +
+ {#each deviceTopupOptions.plans as plan} + + {/each} +
+
+ {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} +
+ + {:else} + {tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_hwid_device_options")} + {/if} +
+
+ None: app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_get("/api/devices", devices_route) app.router.add_post("/api/devices/disconnect", disconnect_device_route) + app.router.add_get("/api/devices/topup-options", device_topup_options_route) app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route) app.router.add_get("/api/tariffs/change-options", tariff_change_options_route) app.router.add_post("/api/tariffs/change", tariff_change_route) @@ -1665,7 +1667,34 @@ async def create_payment_route(request: web.Request) -> web.Response: traffic_gb_for_payment: Optional[float] = None requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) - if tariffs_config and requested_sale_mode == "topup": + if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}: + tariff_key = str(payment_payload.tariff_key or "").strip() + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + try: + tariff = tariffs_config.require(tariff_key) + except Exception: + return _json_error(400, "invalid_plan", "Tariff is not available") + try: + device_count = int(float( + payment_payload.device_count + if payment_payload.device_count is not None + else payment_payload.months + )) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid device package") + packages = tariff.hwid_device_packages + rub_packages = {int(package.count): float(package.price) for package in (packages.rub if packages else [])} + stars_packages = {int(package.count): int(float(package.price)) for package in (packages.stars if packages else [])} + price = rub_packages.get(device_count) + stars_price = stars_packages.get(device_count) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Device package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = device_count + sale_mode = f"hwid_devices@{tariff.key}" + elif tariffs_config and requested_sale_mode == "topup": tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: return _json_error(400, "invalid_plan", "Tariff is not selected") @@ -2127,6 +2156,44 @@ async def disconnect_device_route(request: web.Request) -> web.Response: return web.json_response({"ok": True}) +async def device_topup_options_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid) + if not sub or not sub.tariff_key: + return _json_error(400, "subscription_required", "Active tariff subscription is required") + tariff = config.require(sub.tariff_key) + active = await subscription_service.get_active_subscription_details(session, user_id) + plans = _serialize_hwid_device_packages( + settings, + tariff, + tariff.hwid_device_packages, + db_user.language_code or settings.DEFAULT_LANGUAGE, + ) + return web.json_response( + { + "ok": True, + "tariff_key": tariff.key, + "tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE), + "current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None, + "extra_hwid_devices": int(sub.extra_hwid_devices or 0), + "plans": plans, + } + ) + + async def payment_status_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) try: @@ -2879,6 +2946,8 @@ def _serialize_subscription( "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None, "is_throttled": bool(active.get("is_throttled")), "max_devices": _coerce_int_or_none(active.get("max_devices")), + "base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")), + "extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0, "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), "provider": getattr(local_sub, "provider", None), } @@ -2904,6 +2973,13 @@ def _serialize_plans( "description": tariff.description(lang), "squad_uuids": tariff.squad_uuids, "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "hwid_device_limit": tariff.hwid_device_limit, + "hwid_device_packages": _serialize_hwid_device_packages( + settings, + tariff, + tariff.hwid_device_packages, + lang, + ), } if tariff.billing_model == "period": for months in sorted(tariff.enabled_periods): @@ -3041,6 +3117,39 @@ def _serialize_topup_packages( return plans +def _serialize_hwid_device_packages( + settings: Settings, + tariff: Any, + packages: Optional[Any], + lang: str, +) -> List[Dict[str, Any]]: + rub_packages = {int(package.count): float(package.price) for package in (packages.rub if packages else [])} + stars_packages = {int(package.count): int(float(package.price)) for package in (packages.stars if packages else [])} + plans: List[Dict[str, Any]] = [] + for count in sorted(set(rub_packages) | set(stars_packages)): + price = rub_packages.get(count) + stars_price = stars_packages.get(count) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + plan: Dict[str, Any] = { + "id": f"{tariff.key}:hwid:{count}", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "billing_model": tariff.billing_model, + "sale_mode": "hwid_devices", + "months": int(count), + "device_count": int(count), + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": f"+{count}", + "subtitle": tariff.name(lang), + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + def _serialize_tariff_change_target( settings: Settings, config: Any, @@ -3169,6 +3278,10 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool: return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup"} +def _sale_mode_is_hwid_devices(sale_mode: str) -> bool: + return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"} + + async def _create_subscription_payment( *, request: web.Request, @@ -3185,9 +3298,12 @@ async def _create_subscription_payment( settings: Settings = request.app["settings"] sale_mode = str(sale_mode or "subscription") traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) description = ( _traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang) if traffic_sale + else _hwid_devices_payment_description(int(float(months)), lang) + if hwid_devices_sale else _payment_description(int(months), lang) ) @@ -3248,6 +3364,7 @@ async def _create_base_payment_record( sale_mode: Optional[str] = None, tariff_key: Optional[str] = None, purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, ) -> Payment: payment = await payment_dal.create_payment_record( session, @@ -3262,6 +3379,7 @@ async def _create_base_payment_record( "sale_mode": sale_mode, "tariff_key": tariff_key, "purchased_gb": purchased_gb, + "purchased_hwid_devices": purchased_hwid_devices, }, ) await session.commit() @@ -3286,6 +3404,7 @@ async def _create_yookassa_payment( try: traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -3298,16 +3417,19 @@ async def _create_yookassa_payment( sale_mode=sale_mode, tariff_key=_sale_mode_tariff_key(sale_mode), purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, ) metadata = { "user_id": str(user_id), - "subscription_months": str(int(float(months)) if not traffic_sale else 0), + "subscription_months": str(int(float(months)) if not traffic_sale and not hwid_devices_sale else 0), "payment_db_id": str(payment.payment_id), "sale_mode": sale_mode, "source": "webapp", } if traffic_sale: metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months) + if hwid_devices_sale: + metadata["hwid_devices"] = str(int(float(months))) if _sale_mode_tariff_key(sale_mode): metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode) response = await service.create_payment( @@ -3368,6 +3490,7 @@ async def _create_freekassa_payment( try: traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -3380,6 +3503,7 @@ async def _create_freekassa_payment( sale_mode=sale_mode, tariff_key=_sale_mode_tariff_key(sale_mode), purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, ) success, response_data = await service.create_order( payment_db_id=payment.payment_id, @@ -3444,6 +3568,7 @@ async def _create_platega_payment( try: traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -3456,6 +3581,7 @@ async def _create_platega_payment( sale_mode=sale_mode, tariff_key=_sale_mode_tariff_key(sale_mode), purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, ) months_for_provider = int(float(months)) if not traffic_sale else int(float(traffic_gb or months)) payload = json.dumps( @@ -3465,6 +3591,7 @@ async def _create_platega_payment( "months": months_for_provider if not traffic_sale else 0, "sale_mode": sale_mode, "traffic_gb": _format_number_for_payload(traffic_gb or months) if traffic_sale else None, + "hwid_devices": int(float(months)) if hwid_devices_sale else None, "source": "webapp", "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", } @@ -3531,6 +3658,7 @@ async def _create_severpay_payment( try: traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -3543,6 +3671,7 @@ async def _create_severpay_payment( sale_mode=sale_mode, tariff_key=_sale_mode_tariff_key(sale_mode), purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, ) success, response_data = await service.create_payment( payment_db_id=payment.payment_id, @@ -3596,6 +3725,7 @@ async def _create_stars_payment( bot: Bot = request.app["bot"] try: traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -3608,6 +3738,7 @@ async def _create_stars_payment( sale_mode=sale_mode, tariff_key=_sale_mode_tariff_key(sale_mode), purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, ) payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}" @@ -3793,6 +3924,12 @@ def _traffic_payment_description(traffic_gb: float, lang: str) -> str: return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}" +def _hwid_devices_payment_description(device_count: int, lang: str) -> str: + if lang == "en": + return f"HWID device package +{device_count}" + return f"Докупка устройств HWID +{device_count}" + + def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]: for key in options: try: diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index b136bec..e1a6c53 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -1,4 +1,5 @@ import hashlib +import hashlib import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command @@ -17,6 +18,7 @@ from bot.keyboards.inline.user_keyboards import ( get_tariff_periods_keyboard, get_tariff_packages_keyboard, get_payment_method_keyboard, + get_hwid_device_packages_keyboard, ) from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService @@ -250,6 +252,70 @@ async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: d await callback.answer() +@router.callback_query(F.data == "hwid_devices:list") +async def hwid_devices_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 + max_devices = active.get("max_devices") + if max_devices == 0: + await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True) + return + tariff = config.require(active["tariff_key"]) + packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [] + if not packages: + await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) + return + markup = get_hwid_device_packages_keyboard( + tariff, + packages, + current_lang, + i18n, + settings, + back_callback="main_action:my_devices", + ) + await callback.message.edit_text(get_text("select_hwid_device_package"), reply_markup=markup) + await callback.answer() + + +@router.callback_query(F.data.startswith("hwid_devices:package:")) +async def hwid_devices_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, count_raw = callback.data.split(":", 3) + tariff = config.require(tariff_key) + count = int(count_raw) + package = next( + (pkg for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []) if int(pkg.count) == count), + None, + ) + if not package: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + markup = get_payment_method_keyboard( + count, + package.price, + None, + settings.DEFAULT_CURRENCY_SYMBOL, + current_lang, + i18n, + settings, + sale_mode=f"hwid_devices@{tariff.key}", + ) + await callback.message.edit_text(get_text("choose_payment_method_hwid_devices"), 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) @@ -601,6 +667,18 @@ async def my_subscription_command_handler( callback_data="main_action:my_devices", ) ]) + if settings.tariffs_config and local_sub and local_sub.tariff_key: + try: + tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key) + if tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub: + prepend_rows.append([ + InlineKeyboardButton( + text=get_text("buy_hwid_devices_menu_button"), + callback_data="hwid_devices:list", + ) + ]) + except Exception: + pass # 2) Auto-renew toggle (YooKassa only) if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: @@ -747,6 +825,18 @@ async def my_devices_command_handler( kb = base_markup.inline_keyboard devices_kb = [] + if settings.tariffs_config and active.get("tariff_key") and max_devices_value != 0: + try: + tariff_for_devices = settings.tariffs_config.require(active["tariff_key"]) + if tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub: + devices_kb.append([ + InlineKeyboardButton( + text=get_text("buy_hwid_devices_menu_button"), + callback_data="hwid_devices:list", + ) + ]) + except Exception: + pass for index, device in enumerate(devices_list_raw, start=1): hwid = device.get('hwid') if not hwid: diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py index 43cafd3..2449af7 100644 --- a/bot/handlers/user/subscription/payments_crypto.py +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -56,7 +56,7 @@ async def pay_crypto_callback_handler( payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) if sale_base in {"traffic", "traffic_package", "topup"} - else get_text("payment_description_subscription", months=int(months)) + else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) invoice_url = await cryptopay_service.create_invoice( diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index f521eb1..34766cf 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -65,7 +65,7 @@ async def pay_fk_callback_handler( payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) if sale_base in {"traffic", "traffic_package", "topup"} - else get_text("payment_description_subscription", months=int(months)) + else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" @@ -80,6 +80,7 @@ async def pay_fk_callback_handler( "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None, } try: diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 81f7fa9..d39f720 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -92,7 +92,7 @@ async def pay_platega_callback_handler( payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) if sale_base in {"traffic", "traffic_package", "topup"} - else get_text("payment_description_subscription", months=int(months)) + else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" @@ -107,6 +107,7 @@ async def pay_platega_callback_handler( "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None, } try: diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index 47cfff2..f92ab9f 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -64,7 +64,7 @@ async def pay_severpay_callback_handler( payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) if sale_base in {"traffic", "traffic_package", "topup"} - else get_text("payment_description_subscription", months=int(months)) + else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" @@ -79,6 +79,7 @@ async def pay_severpay_callback_handler( "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None, } try: diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py index 66848b2..e9b5d31 100644 --- a/bot/handlers/user/subscription/payments_stars.py +++ b/bot/handlers/user/subscription/payments_stars.py @@ -57,7 +57,7 @@ async def pay_stars_callback_handler( payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) if sale_base in {"traffic", "traffic_package", "topup"} - else get_text("payment_description_subscription", months=int(months)) + else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) payment_db_id = await stars_service.create_invoice( diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 878dd72..0405416 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -81,12 +81,12 @@ async def _initiate_yk_payment( if not callback.message: return False + sale_base = _sale_mode_base(sale_mode) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) - if _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup"} - else get_text("payment_description_subscription", months=int(months)) + if sale_base in {"traffic", "traffic_package", "topup"} + else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) - sale_base = _sale_mode_base(sale_mode) payment_record_data = { "user_id": user_id, "amount": price_rub, @@ -97,6 +97,7 @@ async def _initiate_yk_payment( "sale_mode": sale_base, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None, } db_payment_record = None diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index fb9c79e..6dbd318 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -251,6 +251,20 @@ def get_tariff_packages_keyboard(tariff: Any, packages: List[Any], lang: str, i1 return builder.as_markup() +def get_hwid_device_packages_keyboard(tariff: Any, packages: List[Any], lang: str, i18n_instance, settings: Settings, back_callback: str = "main_action:my_subscription") -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + for package in packages: + builder.row( + InlineKeyboardButton( + text=_("buy_hwid_devices_button", count=package.count, price=package.price, currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL), + callback_data=f"hwid_devices:package:{tariff.key}:{package.count}", + ) + ) + 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, diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index f8cc004..97c5b9c 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -81,6 +81,21 @@ class SubscriptionService: return int(tariff.monthly_bytes + max(0, topup_balance_bytes)) return self.settings.user_traffic_limit_bytes + def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]: + if tariff and tariff.hwid_device_limit is not None: + return int(tariff.hwid_device_limit) + value = self.settings.USER_HWID_DEVICE_LIMIT + return int(value) if value is not None else None + + @staticmethod + def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: + if base_limit is None: + return None + base_int = max(0, int(base_limit)) + if base_int == 0: + return 0 + return base_int + max(0, int(extra_devices or 0)) + async def _record_payment_context( self, session: AsyncSession, @@ -89,6 +104,7 @@ class SubscriptionService: sale_mode: str, tariff_key: Optional[str], purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, ) -> None: payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) if not payment: @@ -96,6 +112,7 @@ class SubscriptionService: payment.sale_mode = sale_mode payment.tariff_key = tariff_key payment.purchased_gb = purchased_gb + payment.purchased_hwid_devices = purchased_hwid_devices await session.flush() async def get_user_language(self, session: AsyncSession, user_id: int) -> str: @@ -597,6 +614,9 @@ class SubscriptionService: current_used = active_sub.traffic_used_bytes purchase_bytes = self.gb_to_bytes(traffic_gb) + extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0) + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0)) new_balance = remaining_bytes + purchase_bytes new_limit = int(current_used or 0) + new_balance @@ -632,6 +652,8 @@ class SubscriptionService: "period_start_at": None, "is_throttled": False, "effective_monthly_price_rub": None, + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": extra_hwid_devices, } try: @@ -646,6 +668,7 @@ class SubscriptionService: status="ACTIVE", traffic_limit_bytes=new_limit, traffic_limit_strategy="NO_RESET", + hwid_device_limit=effective_hwid_limit, ) if tariff: panel_update_payload["activeInternalSquads"] = tariff.squad_uuids @@ -735,6 +758,15 @@ class SubscriptionService: purchase_bytes = self.gb_to_bytes(traffic_gb) new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes new_limit = int(sub.tier_baseline_bytes or tariff.monthly_bytes) + new_topup_balance + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + effective_hwid_limit = self._effective_hwid_limit( + base_hwid_limit, + int(sub.extra_hwid_devices or 0), + ) updated_sub = await subscription_dal.update_subscription( session, sub.subscription_id, @@ -743,6 +775,7 @@ class SubscriptionService: "traffic_limit_bytes": new_limit, "is_throttled": False, "tariff_key": tariff.key, + "hwid_device_limit": base_hwid_limit, }, ) panel_payload = self._build_panel_update_payload( @@ -750,6 +783,7 @@ class SubscriptionService: expire_at=updated_sub.end_date, status="ACTIVE", traffic_limit_bytes=new_limit, + hwid_device_limit=effective_hwid_limit, ) panel_payload["activeInternalSquads"] = tariff.squad_uuids panel_payload.update(self._panel_identity_payload_for_user(db_user)) @@ -768,6 +802,117 @@ class SubscriptionService: "tariff_key": tariff.key, } + async def activate_hwid_device_topup( + self, + session: AsyncSession, + user_id: int, + device_count: int, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + tariff_key: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + try: + purchased_devices = int(device_count) + except (TypeError, ValueError): + purchased_devices = 0 + if purchased_devices <= 0: + logging.error("HWID device top-up requires positive device count for user %s", user_id) + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = None + if self._tariffs_config(): + tariff = self._resolve_tariff(tariff_key or sub.tariff_key) + packages = ( + [*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars] + if tariff.hwid_device_packages + else [] + ) + if packages and not any(pkg.count == purchased_devices for pkg in packages): + logging.error( + "HWID device package %s is not available for tariff %s", + purchased_devices, + tariff.key, + ) + return None + + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + if base_hwid_limit == 0: + logging.info("Skipping HWID top-up for user %s because current limit is unlimited", user_id) + return { + "subscription_id": sub.subscription_id, + "hwid_device_limit": 0, + "extra_hwid_devices": int(sub.extra_hwid_devices or 0), + "purchased_hwid_devices": 0, + } + + new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices) + await self._record_payment_context( + session, + payment_db_id, + sale_mode="hwid_devices", + tariff_key=tariff.key if tariff else sub.tariff_key, + purchased_hwid_devices=purchased_devices, + ) + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": new_extra_devices, + "tariff_key": tariff.key if tariff else sub.tariff_key, + }, + ) + if not updated_sub: + return None + + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=updated_sub.end_date, + status="ACTIVE", + hwid_device_limit=effective_hwid_limit, + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + updated_panel = await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, + panel_payload, + ) + if not updated_panel or updated_panel.get("error"): + logging.warning( + "Panel user HWID limit update failed for user %s. Response: %s", + user_id, + updated_panel, + ) + return None + + await tariff_dal.create_hwid_device_purchase( + session, + subscription_id=updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_devices=purchased_devices, + ) + return { + "subscription_id": updated_sub.subscription_id, + "hwid_device_limit": effective_hwid_limit, + "extra_hwid_devices": new_extra_devices, + "purchased_hwid_devices": purchased_devices, + "tariff_key": tariff.key if tariff else sub.tariff_key, + } + def calculate_tariff_switch_options(self, sub: Subscription, target_tariff: Tariff) -> Dict[str, Any]: current_tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else self._default_tariff() now = datetime.now(timezone.utc) @@ -823,6 +968,9 @@ class SubscriptionService: now = datetime.now(timezone.utc) update_data: Dict[str, Any] = {"tariff_key": target.key, "is_throttled": False} converted_bytes = None + base_hwid_limit = self._base_hwid_limit_for_tariff(target) + extra_hwid_devices = int(sub.extra_hwid_devices or 0) + update_data["hwid_device_limit"] = base_hwid_limit if target.billing_model == "period": update_data["tier_baseline_bytes"] = target.monthly_bytes @@ -861,6 +1009,7 @@ class SubscriptionService: status="ACTIVE", traffic_limit_bytes=updated.traffic_limit_bytes, traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH", + hwid_device_limit=self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices), ) panel_payload["activeInternalSquads"] = target.squad_uuids panel_payload.update(self._panel_identity_payload_for_user(db_user)) @@ -943,6 +1092,17 @@ class SubscriptionService: payment_db_id=payment_db_id, provider=provider, ) + if sale_mode_base in {"hwid_device", "hwid_devices"}: + target_devices = int(traffic_gb if traffic_gb is not None else months) + return await self.activate_hwid_device_topup( + session=session, + user_id=user_id, + device_count=target_devices, + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + tariff_key=tariff_key, + ) if sale_mode_base == "tariff_upgrade": if not tariff_key: logging.error("Tariff upgrade activation requires tariff_key for user %s", user_id) @@ -1078,9 +1238,12 @@ class SubscriptionService: ) topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0) + extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0) tier_baseline_bytes = tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes effective_monthly_price = float(payment_amount) / max(1, months_int) traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes) + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) sub_payload = { "user_id": user_id, "panel_user_uuid": panel_user_uuid, @@ -1100,6 +1263,8 @@ class SubscriptionService: "period_start_at": None, "is_throttled": False, "effective_monthly_price_rub": effective_monthly_price, + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": extra_hwid_devices, } try: new_or_updated_sub = await subscription_dal.upsert_subscription( @@ -1118,6 +1283,7 @@ class SubscriptionService: status="ACTIVE", traffic_limit_bytes=traffic_limit_bytes, traffic_limit_strategy="MONTH" if tariff else self.settings.USER_TRAFFIC_STRATEGY, + hwid_device_limit=effective_hwid_limit, ) if tariff: panel_update_payload["activeInternalSquads"] = tariff.squad_uuids @@ -1371,7 +1537,13 @@ class SubscriptionService: display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw) hwid_limit = panel_user_data.get("hwidDeviceLimit") if hwid_limit is None: - hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT + if local_active_sub and local_active_sub.hwid_device_limit is not None: + hwid_limit = self._effective_hwid_limit( + local_active_sub.hwid_device_limit, + int(local_active_sub.extra_hwid_devices or 0), + ) + else: + hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT tariff = None if local_active_sub and local_active_sub.tariff_key and self._tariffs_config(): try: @@ -1398,6 +1570,8 @@ class SubscriptionService: "topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0, "period_start_at": local_active_sub.period_start_at if local_active_sub else None, "is_throttled": bool(local_active_sub.is_throttled) if local_active_sub else False, + "base_hwid_device_limit": local_active_sub.hwid_device_limit if local_active_sub else None, + "extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0) if local_active_sub else 0, "user_bot_username": db_user.username, "is_panel_data": True, "max_devices": hwid_limit, @@ -1579,6 +1753,7 @@ class SubscriptionService: traffic_limit_bytes: Optional[int] = None, include_uuid: bool = True, traffic_limit_strategy: Optional[str] = None, + hwid_device_limit: Optional[int] = None, ) -> Dict[str, Any]: payload: Dict[str, Any] = {} if include_uuid and panel_user_uuid: @@ -1590,6 +1765,13 @@ class SubscriptionService: if traffic_limit_bytes is not None: payload["trafficLimitBytes"] = traffic_limit_bytes payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY + if hwid_device_limit is not None: + try: + hwid_limit_int = int(hwid_device_limit) + if hwid_limit_int >= 0: + payload["hwidDeviceLimit"] = hwid_limit_int + except (TypeError, ValueError): + pass if self.settings.parsed_user_squad_uuids: payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids if self.settings.parsed_user_external_squad_uuid: diff --git a/config/tariffs.example.json b/config/tariffs.example.json index db5c4f1..fa6563d 100644 --- a/config/tariffs.example.json +++ b/config/tariffs.example.json @@ -18,6 +18,16 @@ "squad_uuids": ["uuid-1", "uuid-2"], "billing_model": "period", "monthly_gb": 500, + "hwid_device_limit": 5, + "hwid_device_packages": { + "rub": [ + { "count": 1, "price": 99 }, + { "count": 3, "price": 249 } + ], + "stars": [ + { "count": 1, "price": 2500 } + ] + }, "prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 }, "prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 }, "enabled_periods": [1, 3, 6, 12], @@ -32,6 +42,7 @@ }, "squad_uuids": ["uuid-1", "uuid-2"], "billing_model": "traffic", + "hwid_device_limit": 3, "conversion_rate_rub_per_gb": 20, "traffic_packages": { "rub": [ diff --git a/config/tariffs_config.py b/config/tariffs_config.py index eda0106..21ba431 100644 --- a/config/tariffs_config.py +++ b/config/tariffs_config.py @@ -23,6 +23,19 @@ class TrafficPackage(BaseModel): return self +class HwidDevicePackage(BaseModel): + count: int + price: float + + @model_validator(mode="after") + def validate_values(self) -> "HwidDevicePackage": + if self.count <= 0: + raise ValueError("device package count must be greater than zero") + if self.price < 0: + raise ValueError("device package price must be non-negative") + return self + + class PackageSet(BaseModel): rub: List[TrafficPackage] = Field(default_factory=list) stars: List[TrafficPackage] = Field(default_factory=list) @@ -34,6 +47,17 @@ class PackageSet(BaseModel): return bool(self.rub or self.stars) +class HwidDevicePackageSet(BaseModel): + rub: List[HwidDevicePackage] = Field(default_factory=list) + stars: List[HwidDevicePackage] = Field(default_factory=list) + + def for_currency(self, currency: Currency) -> List[HwidDevicePackage]: + return list(getattr(self, currency) or []) + + def has_any(self) -> bool: + return bool(self.rub or self.stars) + + class Tariff(BaseModel): key: str names: Dict[str, str] = Field(default_factory=dict) @@ -50,6 +74,8 @@ class Tariff(BaseModel): traffic_packages: Optional[PackageSet] = None conversion_rate_rub_per_gb: Optional[float] = None + hwid_device_limit: Optional[int] = None + hwid_device_packages: Optional[HwidDevicePackageSet] = None @model_validator(mode="after") def validate_tariff(self) -> "Tariff": @@ -57,6 +83,8 @@ class Tariff(BaseModel): raise ValueError("tariff key must not be empty") self.key = self.key.strip() self.squad_uuids = [uuid.strip() for uuid in self.squad_uuids if uuid.strip()] + if self.hwid_device_limit is not None and self.hwid_device_limit < 0: + raise ValueError(f"tariff {self.key}: hwid_device_limit must be >= 0") if self.billing_model == "period": if self.monthly_gb is None or self.monthly_gb < 0: @@ -119,6 +147,9 @@ class Tariff(BaseModel): packages = self.traffic_packages.rub if self.traffic_packages else [] return min(float(pkg.price) / float(pkg.gb) for pkg in packages) + def has_hwid_device_packages(self) -> bool: + return bool(self.hwid_device_packages and self.hwid_device_packages.has_any()) + class TariffsConfig(BaseModel): default_tariff: str diff --git a/db/dal/tariff_dal.py b/db/dal/tariff_dal.py index 4729b87..21ec902 100644 --- a/db/dal/tariff_dal.py +++ b/db/dal/tariff_dal.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import and_, delete, select from sqlalchemy.ext.asyncio import AsyncSession -from db.models import TariffChange, TrafficTopup, TrafficWarning +from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning async def create_traffic_topup( @@ -26,6 +26,24 @@ async def create_traffic_topup( return record +async def create_hwid_device_purchase( + session: AsyncSession, + *, + subscription_id: int, + payment_id: Optional[int], + purchased_devices: int, +) -> HwidDevicePurchase: + record = HwidDevicePurchase( + subscription_id=subscription_id, + payment_id=payment_id, + purchased_devices=purchased_devices, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record + + async def create_tariff_change( session: AsyncSession, change_data: Dict[str, Any], diff --git a/db/migrator.py b/db/migrator.py index 2a4e58f..ee5d52d 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -320,6 +320,10 @@ def _migration_0011_add_tariffs_schema(connection: Connection) -> None: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN is_throttled BOOLEAN NOT NULL DEFAULT FALSE") if "effective_monthly_price_rub" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN effective_monthly_price_rub NUMERIC") + if "hwid_device_limit" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN hwid_device_limit INTEGER") + if "extra_hwid_devices" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN extra_hwid_devices INTEGER NOT NULL DEFAULT 0") for stmt in sub_statements: connection.execute(text(stmt)) @@ -331,6 +335,8 @@ def _migration_0011_add_tariffs_schema(connection: Connection) -> None: payment_statements.append("ALTER TABLE payments ADD COLUMN tariff_key VARCHAR") if "purchased_gb" not in payment_columns: payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_gb DOUBLE PRECISION") + if "purchased_hwid_devices" not in payment_columns: + payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_hwid_devices INTEGER") for stmt in payment_statements: connection.execute(text(stmt)) @@ -363,6 +369,19 @@ def _migration_0011_add_tariffs_schema(connection: Connection) -> None: """ ) ) + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hwid_device_purchases ( + purchase_id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), + payment_id INTEGER NULL REFERENCES payments(payment_id), + purchased_devices INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) connection.execute( text( """ @@ -393,6 +412,8 @@ def _migration_0011_add_tariffs_schema(connection: Connection) -> None: "CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)", "CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", "CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", + "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", + "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", ]: connection.execute(text(stmt)) diff --git a/db/models.py b/db/models.py index b6c6bfe..6d300d7 100644 --- a/db/models.py +++ b/db/models.py @@ -93,6 +93,8 @@ class Subscription(Base): period_start_at = Column(DateTime(timezone=True), nullable=True) is_throttled = Column(Boolean, nullable=False, default=False, index=True) effective_monthly_price_rub = Column(Numeric, nullable=True) + hwid_device_limit = Column(Integer, nullable=True) + extra_hwid_devices = Column(Integer, nullable=False, default=0) user = relationship("User", back_populates="subscriptions") @@ -167,6 +169,7 @@ class Payment(Base): sale_mode = Column(String, nullable=True, index=True) tariff_key = Column(String, nullable=True, index=True) purchased_gb = Column(Float, nullable=True) + purchased_hwid_devices = Column(Integer, nullable=True) promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=True) @@ -194,6 +197,19 @@ class TrafficTopup(Base): payment = relationship("Payment") +class HwidDevicePurchase(Base): + __tablename__ = "hwid_device_purchases" + + purchase_id = Column(Integer, primary_key=True, autoincrement=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True) + payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True) + purchased_devices = Column(Integer, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + subscription = relationship("Subscription") + payment = relationship("Payment") + + class TrafficWarning(Base): __tablename__ = "traffic_warnings" __table_args__ = ( diff --git a/docs/tariffs.md b/docs/tariffs.md index a1ee9d8..86ac980 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -67,3 +67,16 @@ Legacy поле `subscription_duration_months` остается для совм - Отправляет/дедуплицирует уровни предупреждений из `TARIFF_TRAFFIC_WARNING_LEVELS` (по умолчанию `85,90,95`) через `traffic_warnings`. - При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`. - Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика. + + +## HWID devices + +Tariff config now supports per-tariff device limits and paid device add-ons: + +- `tariffs[].hwid_device_limit`: base HWID device limit for the tariff. `0` means unlimited. Missing/null falls back to `USER_HWID_DEVICE_LIMIT`. +- `tariffs[].hwid_device_packages`: paid add-on packages, for example `{ "count": 1, "price": 99 }` in `rub` or `stars`. +- The bot stores the tariff base limit in `subscriptions.hwid_device_limit` and purchased add-ons in `subscriptions.extra_hwid_devices`. +- Effective panel value is `hwid_device_limit + extra_hwid_devices`; if the base limit is `0`, it remains unlimited and add-ons are ignored. +- Device add-ons use `sale_mode=hwid_devices`, persist `payments.purchased_hwid_devices`, and update Remnawave via `hwidDeviceLimit` after payment. +- Add-ons are available in Web App via `/api/devices/topup-options` + `/api/payments`, and in Telegram via the devices section. +- On tariff change, the base HWID limit is taken from the new tariff, while already purchased extra devices are preserved. diff --git a/locales/en.json b/locales/en.json index 5a2eadd..b6d7cac 100644 --- a/locales/en.json +++ b/locales/en.json @@ -765,5 +765,17 @@ "wa_traffic_reset_yearly": "Yearly reset", "wa_traffic_reset_policy": "Traffic reset policy", "wa_plan_one_year": "1 year", - "wa_link_email_modal_desc": "Enter your email and get a verification code" + "wa_link_email_modal_desc": "Enter your email and get a verification code", + "buy_hwid_devices_menu_button": "+ HWID devices", + "buy_hwid_devices_button": "+{count} HWID for {price} {currency_symbol}", + "select_hwid_device_package": "Select HWID device package:", + "choose_payment_method_hwid_devices": "Choose a payment method for extra HWID devices:", + "no_hwid_device_packages_available": "Extra HWID devices are not configured for this tariff.", + "hwid_devices_unlimited_no_topup": "Your device limit is already unlimited.", + "payment_description_hwid_devices": "Extra HWID devices +{count}", + "wa_buy_hwid_devices": "Buy devices", + "wa_device_topup_for_tariff": "Device packages for {tariff}", + "wa_hwid_devices_package": "+{count} devices", + "wa_no_hwid_device_options": "No device packages available", + "wa_device_topup_options_failed": "Could not load device packages" } diff --git a/locales/ru.json b/locales/ru.json index e98db72..2eaec90 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -765,5 +765,17 @@ "wa_traffic_reset_yearly": "Сброс ежегодно", "wa_traffic_reset_policy": "Стратегия сброса трафика", "wa_plan_one_year": "1 год", - "wa_link_email_modal_desc": "Введите email и получите код подтверждения" + "wa_link_email_modal_desc": "Введите email и получите код подтверждения", + "buy_hwid_devices_menu_button": "+ HWID ??????????", + "buy_hwid_devices_button": "+{count} HWID ?? {price} {currency_symbol}", + "select_hwid_device_package": "???????? ????? HWID ?????????:", + "choose_payment_method_hwid_devices": "???????? ?????? ?????? ??????? HWID ?????????:", + "no_hwid_device_packages_available": "??????? HWID ????????? ??? ????? ?????? ?? ?????????.", + "hwid_devices_unlimited_no_topup": "? ??? ??? ??????????? ????? ?????????.", + "payment_description_hwid_devices": "??????? HWID ????????? +{count}", + "wa_buy_hwid_devices": "???????? ??????????", + "wa_device_topup_for_tariff": "?????? ????????? ??? ?????? {tariff}", + "wa_hwid_devices_package": "+{count} ?????????", + "wa_no_hwid_device_options": "??? ????????? ??????? ?????????", + "wa_device_topup_options_failed": "?? ??????? ????????? ?????? ?????????" } diff --git a/tests/test_tariffs_config.py b/tests/test_tariffs_config.py index 6ddf164..cb2b779 100644 --- a/tests/test_tariffs_config.py +++ b/tests/test_tariffs_config.py @@ -88,3 +88,25 @@ class TariffsConfigTests(unittest.TestCase): with self.assertRaises(ValueError): TariffsConfig.model_validate(data) + + def test_hwid_device_limit_and_packages_load(self): + data = _valid_config() + data["tariffs"][0]["hwid_device_limit"] = 5 + data["tariffs"][0]["hwid_device_packages"] = { + "rub": [{"count": 1, "price": 99}], + "stars": [{"count": 1, "price": 2500}], + } + + config = TariffsConfig.model_validate(data) + + tariff = config.require("standard") + self.assertEqual(tariff.hwid_device_limit, 5) + self.assertTrue(tariff.has_hwid_device_packages()) + self.assertEqual(tariff.hwid_device_packages.rub[0].count, 1) + + def test_negative_hwid_device_limit_rejected(self): + data = _valid_config() + data["tariffs"][0]["hwid_device_limit"] = -1 + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data) diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 976c9be..1af6f17 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -26,6 +26,11 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "squad_uuids": ["uuid"], "billing_model": "period", "monthly_gb": 100, + "hwid_device_limit": 5, + "hwid_device_packages": { + "rub": [{"count": 1, "price": 99}], + "stars": [{"count": 1, "price": 2500}], + }, "prices_rub": {"1": 150}, "prices_stars": {"1": 0}, "enabled_periods": [1], @@ -62,6 +67,8 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual([plan["tariff_key"] for plan in plans], ["standard", "traffic"]) self.assertEqual(plans[0]["sale_mode"], "subscription") self.assertEqual(plans[0]["months"], 1) + self.assertEqual(plans[0]["hwid_device_limit"], 5) + self.assertEqual(plans[0]["hwid_device_packages"][0]["device_count"], 1) self.assertEqual(plans[1]["sale_mode"], "traffic_package") self.assertEqual(plans[1]["traffic_gb"], 50.0) self.assertEqual(plans[1]["stars_price"], 2500)