From c68cb979646132597ec8be62e49e21574d80c8cf Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 25 May 2026 19:33:50 +0300 Subject: [PATCH] fix: bind HWID top-ups to subscription periods --- backend/bot/app/web/webapp/billing.py | 174 ++++++++++-- backend/bot/app/web/webapp/serializers.py | 48 +++- .../bot/handlers/user/subscription/core.py | 72 ++++- .../bot/keyboards/inline/user_keyboards.py | 13 +- backend/bot/main_bot.py | 5 +- backend/bot/payment_providers/base.py | 5 + backend/bot/payment_providers/cryptopay.py | 40 ++- backend/bot/payment_providers/freekassa.py | 12 + backend/bot/payment_providers/heleket.py | 12 + backend/bot/payment_providers/platega.py | 12 + backend/bot/payment_providers/severpay.py | 12 + .../bot/payment_providers/shared/__init__.py | 4 + .../bot/payment_providers/shared/callbacks.py | 30 +++ .../bot/payment_providers/shared/common.py | 33 ++- .../bot/payment_providers/shared/success.py | 31 ++- backend/bot/payment_providers/stars.py | 25 +- backend/bot/payment_providers/wata.py | 12 + backend/bot/payment_providers/yookassa.py | 43 ++- backend/bot/services/panel_webhook_service.py | 40 ++- .../subscription_service_impl/devices.py | 248 +++++++++++++++++- .../subscription_service_impl/lifecycle.py | 95 ++++++- .../subscription_service_impl/payments.py | 15 ++ .../subscription_service_impl/tariffs.py | 88 +++++++ .../subscription_service_impl/traffic.py | 27 +- backend/bot/services/tariff_worker.py | 59 +++++ backend/config/tariffs_config.py | 26 ++ backend/db/dal/tariff_dal.py | 131 ++++++++- backend/db/database_setup.py | 5 +- backend/db/migrator.py | 107 ++++++++ backend/db/models.py | 17 ++ backend/main_worker.py | 3 +- docs/deployment.md | 8 +- docs/tariffs.md | 44 +++- frontend/src/App.svelte | 1 + frontend/src/lib/admin/tariffDraft.js | 20 +- frontend/src/lib/webapp/billingActions.js | 2 +- frontend/src/lib/webapp/previewMock.js | 6 + .../src/lib/webapp/stores/billingStore.js | 25 +- frontend/src/styles/webapp.css | 7 + frontend/src/webapp/PaymentDialogs.svelte | 20 ++ frontend/src/webapp/TariffDialogs.svelte | 19 ++ .../src/webapp/screens/DevicesScreen.svelte | 8 + locales/en.json | 7 + locales/ru.json | 7 + tests/test_can_topup_devices_flag.py | 30 +++ tests/test_hwid_device_topup.py | 223 +++++++++++++++- tests/test_hwid_device_worker.py | 80 ++++++ tests/test_hwid_tariff_switch_conversion.py | 190 ++++++++++++++ tests/test_tariffs_config.py | 5 +- 49 files changed, 2027 insertions(+), 119 deletions(-) create mode 100644 tests/test_hwid_device_worker.py create mode 100644 tests/test_hwid_tariff_switch_conversion.py diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 6547bbc..f62bae9 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -69,9 +69,14 @@ async def create_payment_route(request: web.Request) -> web.Response: traffic_mode = bool(settings.traffic_sale_mode) sale_mode = "subscription" traffic_gb_for_payment: Optional[float] = None + hwid_quote: Optional[Dict[str, Any]] = None requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) - if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}: + if tariffs_config and requested_sale_mode in { + "hwid_device", + "hwid_devices", + "hwid_devices_renewal", + }: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: return _json_error(400, "invalid_plan", "Tariff is not selected") @@ -79,6 +84,8 @@ async def create_payment_route(request: web.Request) -> web.Response: tariff = tariffs_config.require(tariff_key) except Exception: return _json_error(400, "invalid_plan", "Tariff is not available") + if tariff.billing_model != "period": + return _json_error(400, "invalid_plan", "Device top-up is not available") try: device_count = int( float( @@ -89,23 +96,10 @@ async def create_payment_route(request: web.Request) -> web.Response: ) 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": + if not tariff.hwid_device_packages: 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}" + sale_mode = f"{requested_sale_mode}@{tariff.key}" elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: @@ -253,6 +247,40 @@ async def create_payment_route(request: web.Request) -> web.Response: if not db_user or db_user.is_banned: return _json_error(403, "access_denied", "Access denied") lang = db_user.language_code or settings.DEFAULT_LANGUAGE + if _sale_mode_is_hwid_devices(sale_mode): + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + sale_tariff_key = _sale_mode_tariff_key(sale_mode) + if not sub or not sub.tariff_key or sub.tariff_key != sale_tariff_key: + return _json_error( + 400, "subscription_required", "Active tariff subscription is required" + ) + try: + active_tariff = tariffs_config.require(sub.tariff_key) if tariffs_config else None + except Exception: + active_tariff = None + if not active_tariff or active_tariff.billing_model != "period": + return _json_error(400, "invalid_plan", "Device top-up is not available") + currency = "stars" if method == "stars" else "rub" + hwid_quote = await subscription_service.quote_hwid_device_topup( + session, + user_id=user_id, + device_count=int(payment_units), + tariff_key=sale_tariff_key, + renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", + currency=currency, + ) + if not hwid_quote: + return _json_error(400, "invalid_plan", "Device package is not available") + if method == "stars": + stars_price = int(hwid_quote["price"]) + price = 0.0 + if stars_price <= 0: + return _json_error(400, "invalid_plan", "Stars price is not configured") + else: + price = float(hwid_quote["price"]) + stars_price = None admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) return await _create_subscription_payment( @@ -267,6 +295,7 @@ async def create_payment_route(request: web.Request) -> web.Response: sale_mode=sale_mode, traffic_gb=traffic_gb_for_payment, is_admin=is_admin, + hwid_quote=hwid_quote, ) @@ -460,7 +489,9 @@ async def tariff_change_options_route(request: web.Request) -> web.Response: for tariff in config.enabled_tariffs: if tariff.key == current.key: continue - options = subscription_service.calculate_tariff_switch_options(sub, tariff) + options = await subscription_service.calculate_tariff_switch_options_with_hwid( + session, sub, tariff + ) targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang)) return web.json_response( { @@ -537,7 +568,9 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response: 400, "subscription_required", "Active tariff subscription is required" ) target = config.require(tariff_key) - options = subscription_service.calculate_tariff_switch_options(sub, target) + options = await subscription_service.calculate_tariff_switch_options_with_hwid( + session, sub, target + ) price = float(options.get("paid_diff_rub") or 0) if price <= 0: return _json_error( @@ -579,20 +612,93 @@ async def device_topup_options_route(request: web.Request) -> web.Response: 400, "subscription_required", "Active tariff subscription is required" ) tariff = config.require(sub.tariff_key) + if tariff.billing_model != "period": + return _json_error( + 400, "device_topup_unavailable", "Device top-up is not available" + ) + lang = db_user.language_code or settings.DEFAULT_LANGUAGE 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, - ) + renewal_available = bool(active and active.get("device_topup_renewal_available")) + packages = tariff.hwid_device_packages + rub_counts = {int(package.count) for package in (packages.rub if packages else [])} + stars_counts = {int(package.count) for package in (packages.stars if packages else [])} + plans = [] + for count in sorted(rub_counts | stars_counts): + rub_quote = ( + await subscription_service.quote_hwid_device_topup( + session, + user_id=user_id, + device_count=count, + tariff_key=tariff.key, + renewal=renewal_available, + currency="rub", + ) + if count in rub_counts + else None + ) + stars_quote = ( + await subscription_service.quote_hwid_device_topup( + session, + user_id=user_id, + device_count=count, + tariff_key=tariff.key, + renewal=renewal_available, + currency="stars", + ) + if count in stars_counts + else None + ) + if not rub_quote and not stars_quote: + continue + sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" + plan = { + "id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "billing_model": tariff.billing_model, + "sale_mode": sale_mode_for_plan, + "months": count, + "device_count": count, + "price": float(rub_quote.get("price") if rub_quote else 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": f"+{count}", + "subtitle": tariff.name(lang), + "valid_from": ( + (rub_quote or stars_quote)["valid_from"].isoformat() + if (rub_quote or stars_quote).get("valid_from") + else None + ), + "valid_until": ( + (rub_quote or stars_quote)["valid_until"].isoformat() + if (rub_quote or stars_quote).get("valid_until") + else None + ), + "proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0), + } + if stars_quote and int(stars_quote.get("price") or 0) > 0: + plan["stars_price"] = int(stars_quote["price"]) + plans.append(plan) return web.json_response( { "ok": True, "tariff_key": tariff.key, - "tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE), + "tariff_name": tariff.name(lang), "current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None, - "extra_hwid_devices": int(sub.extra_hwid_devices or 0), + "extra_hwid_devices": int(active.get("extra_hwid_devices") or 0) + if active + else int(sub.extra_hwid_devices or 0), + "extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until") + if active + else None, + "extra_hwid_devices_valid_until_text": active.get( + "extra_hwid_devices_valid_until_text" + ) + if active + else None, + "renewal_available": renewal_available, + "renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) + if active and renewal_available + else 0, "plans": plans, } ) @@ -784,7 +890,11 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool: def _sale_mode_is_hwid_devices(sale_mode: str) -> bool: - return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"} + return _sale_mode_base(sale_mode) in { + "hwid_device", + "hwid_devices", + "hwid_devices_renewal", + } async def _create_subscription_payment( @@ -800,6 +910,7 @@ async def _create_subscription_payment( sale_mode: str = "subscription", traffic_gb: Optional[float] = None, is_admin: bool = False, + hwid_quote: Optional[Dict[str, Any]] = None, ) -> web.Response: settings: Settings = request.app["settings"] sale_mode = str(sale_mode or "subscription") @@ -837,6 +948,15 @@ async def _create_subscription_payment( description=description, sale_mode=sale_mode, traffic_gb=traffic_gb, + hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, + hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, + hwid_pricing_period_months=hwid_quote.get("pricing_period_months") + if hwid_quote + else None, + hwid_proration_ratio=hwid_quote.get("proration_ratio") + if hwid_quote + else None, + hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None, ) ) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 6936e7b..d9bd68f 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -259,7 +259,8 @@ def _serialize_subscription( can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) # max_devices == 0 means unlimited — top-up is pointless in that case. can_topup_devices = bool( - tariff.has_hwid_device_packages() + tariff.billing_model == "period" + and tariff.has_hwid_device_packages() and _coerce_int_or_none(active.get("max_devices")) != 0 ) except Exception: @@ -272,6 +273,19 @@ def _serialize_subscription( share_token = str( install_share_token or getattr(local_sub, "install_share_token", "") or "" ).strip() + extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") + if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None: + extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc) + extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from") + if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None: + extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc) + extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0 + device_topup_renewal_available = bool( + extra_hwid_count > 0 + and extra_hwid_valid_until + and end_date + and extra_hwid_valid_until < end_date + ) return { "active": seconds_left > 0, "status": active.get("status_from_panel") or "UNKNOWN", @@ -322,7 +336,17 @@ def _serialize_subscription( "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, + "extra_hwid_devices": extra_hwid_count, + "extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat() + if extra_hwid_valid_until + else None, + "extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M") + if extra_hwid_valid_until + else None, + "extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat() + if extra_hwid_next_valid_from + else None, + "device_topup_renewal_available": device_topup_renewal_available, "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), "provider": getattr(local_sub, "provider", None), } @@ -378,7 +402,9 @@ def _serialize_plans( tariff, tariff.hwid_device_packages, lang, - ), + ) + if tariff.billing_model == "period" + else [], } if tariff.billing_model == "period": for months in sorted(tariff.enabled_periods): @@ -585,10 +611,14 @@ def _serialize_tariff_change_target( "mode": "recalc_days", "kind": "free", "title": "recalc_days", - "days_after": int(options.get("recalc_days") or 0), - "remaining_days": int(options.get("remaining_days") or 0), - } - ) + "days_after": int(options.get("recalc_days") or 0), + "remaining_days": int(options.get("remaining_days") or 0), + "converted_hwid_value_rub": float( + options.get("converted_hwid_value_rub") or 0 + ), + "converted_hwid_days": int(options.get("converted_hwid_days") or 0), + } + ) paid_diff = float(options.get("paid_diff_rub") or 0) if paid_diff > 0: actions.append( @@ -608,6 +638,10 @@ def _serialize_tariff_change_target( "title": "convert_days_to_gb", "converted_gb": float(options.get("converted_gb") or 0), "remaining_days": int(options.get("remaining_days") or 0), + "converted_hwid_value_rub": float( + options.get("converted_hwid_value_rub") or 0 + ), + "converted_hwid_gb": float(options.get("converted_hwid_gb") or 0), } ) actions.extend( diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index 09de085..a894948 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -525,10 +525,14 @@ async def hwid_devices_list_callback( await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True) return tariff = config.require(active["tariff_key"]) + if tariff.billing_model != "period": + await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) + return 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 + renewal_available = bool(active.get("device_topup_renewal_available")) markup = get_hwid_device_packages_keyboard( tariff, packages, @@ -536,14 +540,31 @@ async def hwid_devices_list_callback( i18n, settings, back_callback="main_action:my_devices", + renewal=renewal_available, + ) + text_key = ( + "select_hwid_device_renewal_package" + if renewal_available + else "select_hwid_device_package" + ) + await callback.message.edit_text( + get_text( + text_key, + date=active.get("extra_hwid_devices_valid_until_text") or "", + ), + reply_markup=markup, ) - 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:")) +@router.callback_query(F.data.startswith("hwid_devices:renewal_package:")) async def hwid_devices_package_callback( - callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession + callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings, + session: AsyncSession, + subscription_service: SubscriptionService, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: JsonI18n = i18n_data.get("i18n_instance") @@ -552,8 +573,11 @@ async def hwid_devices_package_callback( 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) + _, action, tariff_key, count_raw = callback.data.split(":", 3) tariff = config.require(tariff_key) + if tariff.billing_model != "period": + await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) + return count = int(count_raw) package = next( ( @@ -566,15 +590,37 @@ async def hwid_devices_package_callback( if not package: await callback.answer(get_text("error_try_again"), show_alert=True) return + sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" + rub_quote = await subscription_service.quote_hwid_device_topup( + session, + user_id=callback.from_user.id, + device_count=count, + tariff_key=tariff.key, + renewal=action == "renewal_package", + currency="rub", + ) + stars_quote = await subscription_service.quote_hwid_device_topup( + session, + user_id=callback.from_user.id, + device_count=count, + tariff_key=tariff.key, + renewal=action == "renewal_package", + currency="stars", + ) + if not rub_quote and not stars_quote: + await callback.answer(get_text("error_try_again"), show_alert=True) + return markup = get_payment_method_keyboard( count, - package.price, - None, + float(rub_quote.get("price") if rub_quote else 0), + int(stars_quote["price"]) + if stars_quote and int(stars_quote.get("price") or 0) > 0 + else None, settings.DEFAULT_CURRENCY_SYMBOL, current_lang, i18n, settings, - sale_mode=f"hwid_devices@{tariff.key}", + sale_mode=f"{sale_mode_base}@{tariff.key}", back_callback="hwid_devices:list", user_id=callback.from_user.id, ) @@ -654,7 +700,9 @@ async def tariff_change_select_callback( if not db_sub: await callback.answer("Error", show_alert=True) return - options = subscription_service.calculate_tariff_switch_options(db_sub, target) + options = await subscription_service.calculate_tariff_switch_options_with_hwid( + session, db_sub, target + ) rows = [] if options["mode"] == "period_to_period": rows.append( @@ -741,7 +789,9 @@ async def tariff_change_confirm_apply_callback( if not db_sub: await callback.answer("Error", show_alert=True) return - options = subscription_service.calculate_tariff_switch_options(db_sub, target) + options = await subscription_service.calculate_tariff_switch_options_with_hwid( + session, db_sub, target + ) if mode == "recalc_days": action_text = f"после перехода останется {options.get('recalc_days', 0)} дн." elif mode == "convert_days_to_gb": @@ -1162,7 +1212,8 @@ async def my_subscription_command_handler( try: tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key) if ( - tariff_for_devices.hwid_device_packages + tariff_for_devices.billing_model == "period" + and tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub ): prepend_rows.append( @@ -1378,7 +1429,8 @@ async def my_devices_command_handler( try: tariff_for_devices = settings.tariffs_config.require(active["tariff_key"]) if ( - tariff_for_devices.hwid_device_packages + tariff_for_devices.billing_model == "period" + and tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub ): devices_kb.append( diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 255b27f..bbd45df 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -57,8 +57,9 @@ def payment_methods_back_callback( return f"tariff:package:{tariff_key}:{value}" if sale_base == "premium_topup" and tariff_key: return f"tariff:premium_package:{tariff_key}:{value}" - if sale_base in {"hwid_device", "hwid_devices"} and tariff_key: - return f"hwid_devices:package:{tariff_key}:{value}" + if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key: + action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package" + return f"hwid_devices:{action}:{tariff_key}:{value}" if sale_base == "tariff_upgrade" and tariff_key: amount = str(price) if price is not None else value return f"tariff_change:pay:{tariff_key}:{amount}" @@ -79,7 +80,7 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str: return f"tariff:select:{tariff_key}{context_suffix}" if sale_base in {"topup", "premium_topup"}: return "tariff_topup:list" - if sale_base in {"hwid_device", "hwid_devices"}: + if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}: return "hwid_devices:list" return subscription_options_callback(context) @@ -425,6 +426,7 @@ def get_hwid_device_packages_keyboard( i18n_instance, settings: Settings, back_callback: str = "main_action:my_subscription", + renewal: bool = False, ) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -437,7 +439,10 @@ def get_hwid_device_packages_keyboard( price=package.price, currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL, ), - callback_data=f"hwid_devices:package:{tariff.key}:{package.count}", + callback_data=( + f"hwid_devices:{'renewal_package' if renewal else 'package'}:" + f"{tariff.key}:{package.count}" + ), ) ) builder.row( diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index f0d153c..987416d 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -13,10 +13,9 @@ from bot.infra.redis import close_redis from bot.middlewares.i18n import JsonI18n from bot.routers import build_root_router from bot.services.locale_override_service import load_locale_overrides -from bot.services.settings_override_service import load_overrides_from_db from bot.utils.message_queue import init_queue_manager from config.settings import Settings -from db.database_setup import init_db_connection +from db.database_setup import init_db, init_db_connection TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0 @@ -267,7 +266,7 @@ async def run_bot(settings_param: Settings): if local_async_session_factory is None: logging.critical("Failed to initialize database connection and session factory. Exiting.") return - await load_overrides_from_db(settings_param, local_async_session_factory) + await init_db(settings_param, local_async_session_factory) dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory) i18n_instance = extra["i18n_instance"] await load_locale_overrides(i18n_instance, local_async_session_factory) diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index e8e023b..cc407e3 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -113,6 +113,11 @@ class WebAppPaymentContext: description: str sale_mode: str traffic_gb: Optional[float] = None + hwid_valid_from: Optional[Any] = None + hwid_valid_until: Optional[Any] = None + hwid_pricing_period_months: Optional[int] = None + hwid_proration_ratio: Optional[float] = None + hwid_full_price: Optional[float] = None EnabledPredicate = Callable[[Any], bool] diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index 39922be..ac4a3ce 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -38,9 +38,12 @@ from .shared import ( parse_payment_callback, payment_failed, payment_link_response, + payment_record_amounts, payment_unavailable, + quote_hwid_callback_parts, render_payment_link, sale_mode_base, + sale_mode_is_traffic, sale_mode_tariff_key, ) @@ -155,13 +158,14 @@ class CryptoPayService: description: str, sale_mode: str = "subscription", url_kind: str = "bot", + hwid_quote: Optional[dict] = None, ) -> Optional[str]: if not self.configured or not self.client: logging.error("CryptoPayService not configured") return None sale_base = sale_mode_base(sale_mode) - is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} + amounts = payment_record_amounts(months=months, sale_mode=sale_mode) try: payment_record = await payment_dal.create_payment_record( session, @@ -177,7 +181,17 @@ class CryptoPayService: "provider": "cryptopay", "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), - "purchased_gb": float(months) if is_traffic else None, + "purchased_gb": amounts.purchased_gb, + "purchased_hwid_devices": amounts.purchased_hwid_devices, + "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, + "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") + if hwid_quote + else None, + "hwid_proration_ratio": hwid_quote.get("proration_ratio") + if hwid_quote + else None, + "hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None, }, ) await session.commit() @@ -192,7 +206,7 @@ class CryptoPayService: "subscription_months": str(months), "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, - "traffic_gb": str(months) if is_traffic else None, + "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, } ) try: @@ -363,6 +377,16 @@ async def pay_crypto_callback_handler( return parts = parse_payment_callback(callback.data or "") + if not parts: + await notify_callback_parse_error(callback, translator) + return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=cryptopay_service.subscription_service, + currency="rub", + ) if not parts: await notify_callback_parse_error(callback, translator) return @@ -375,6 +399,7 @@ async def pay_crypto_callback_handler( amount=parts.price, description=payment_description, sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) if invoice_url: @@ -424,6 +449,15 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: description=ctx.description, sale_mode=ctx.sale_mode, url_kind="web", + hwid_quote={ + "valid_from": ctx.hwid_valid_from, + "valid_until": ctx.hwid_valid_until, + "pricing_period_months": ctx.hwid_pricing_period_months, + "proration_ratio": ctx.hwid_proration_ratio, + "full_price": ctx.hwid_full_price, + } + if ctx.hwid_valid_from and ctx.hwid_valid_until + else None, ) if not url: return payment_failed() diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 2df1fc0..00af132 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -50,6 +50,7 @@ from .shared import ( payment_failed, payment_unavailable, post_json_request, + quote_hwid_callback_parts, render_link_or_fail, ) @@ -470,6 +471,16 @@ async def pay_fk_callback_handler( logging.error("Invalid pay_fk data in callback: %s", callback.data) await notify_callback_parse_error(callback, translator) return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=freekassa_service.subscription_service, + currency="rub", + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return currency_code = ( getattr(freekassa_service, "default_currency", None) @@ -486,6 +497,7 @@ async def pay_fk_callback_handler( months=parts.months, provider="freekassa", sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) try: diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index b681b63..5ebc66f 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -49,6 +49,7 @@ from .shared import ( parse_payment_callback, payment_failed, payment_unavailable, + quote_hwid_callback_parts, render_link_or_fail, ) @@ -565,6 +566,16 @@ async def pay_heleket_callback_handler( logging.error("Invalid pay_heleket data in callback: %s", callback.data) await notify_callback_parse_error(callback, translator) return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=heleket_service.subscription_service, + currency="rub", + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() payment_description = describe_payment(translator, parts) @@ -577,6 +588,7 @@ async def pay_heleket_callback_handler( months=parts.months, provider="heleket", sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) try: diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 59e9c4e..674cc2b 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -46,6 +46,7 @@ from .shared import ( payment_record_amounts, payment_unavailable, post_json_request, + quote_hwid_callback_parts, render_link_or_fail, safe_callback_answer, ) @@ -475,6 +476,16 @@ async def pay_platega_callback_handler( logging.error("Invalid pay_platega data in callback: %s", callback.data) await notify_callback_parse_error(callback, translator) return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=platega_service.subscription_service, + currency="rub", + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_description = describe_payment(translator, parts) @@ -487,6 +498,7 @@ async def pay_platega_callback_handler( months=parts.months, provider="platega", sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) try: diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 2d28c65..7ec7ce5 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -47,6 +47,7 @@ from .shared import ( payment_failed, payment_unavailable, post_json_request, + quote_hwid_callback_parts, render_link_or_fail, ) @@ -414,6 +415,16 @@ async def pay_severpay_callback_handler( logging.error("Invalid pay_severpay data in callback: %s", callback.data) await notify_callback_parse_error(callback, translator) return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=severpay_service.subscription_service, + currency="rub", + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_description = describe_payment(translator, parts) @@ -426,6 +437,7 @@ async def pay_severpay_callback_handler( months=parts.months, provider="severpay", sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) try: diff --git a/backend/bot/payment_providers/shared/__init__.py b/backend/bot/payment_providers/shared/__init__.py index ca88f5f..c74247b 100644 --- a/backend/bot/payment_providers/shared/__init__.py +++ b/backend/bot/payment_providers/shared/__init__.py @@ -15,6 +15,7 @@ from .callbacks import ( notify_service_unavailable, parse_payment_callback, payment_link_message_text, + quote_hwid_callback_parts, render_link_or_fail, render_payment_link, safe_callback_answer, @@ -55,6 +56,7 @@ from .success import ( PaymentSuccessOutcome, PaymentSuccessRequest, SuccessMessage, + append_hwid_renewal_note, build_success_message, finalize_successful_payment, is_traffic_sale_base, @@ -82,6 +84,7 @@ __all__ = [ "build_payment_description", "build_payment_record_payload", "build_success_message", + "append_hwid_renewal_note", "coerce_payment_db_id", "create_base_payment_record", "create_webapp_payment_record", @@ -113,6 +116,7 @@ __all__ = [ "payment_record_amounts", "payment_unavailable", "post_json_request", + "quote_hwid_callback_parts", "render_link_or_fail", "render_payment_link", "resolve_inviter_name", diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index e7dd891..4a8afb1 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -21,6 +21,8 @@ from .common import ( format_human_units, mark_payment_failed_creation, sale_mode_base, + sale_mode_is_hwid_devices, + sale_mode_tariff_key, ) @@ -112,6 +114,34 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str ) +async def quote_hwid_callback_parts( + *, + session: AsyncSession, + user_id: int, + parts: PaymentCallbackParts, + subscription_service, + currency: str = "rub", +) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: + if not sale_mode_is_hwid_devices(parts.sale_mode): + return parts, None + quote = await subscription_service.quote_hwid_device_topup( + session, + user_id=user_id, + device_count=int(parts.months), + tariff_key=sale_mode_tariff_key(parts.sale_mode), + renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal", + currency=currency, + ) + if not quote: + return None, None + quoted_parts = PaymentCallbackParts( + months=parts.months, + price=float(quote.get("price") or 0), + sale_mode=parts.sale_mode, + ) + return quoted_parts, quote + + def payment_link_message_text( translator: Translator, parts: PaymentCallbackParts, diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 5adb93c..afc09e6 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -60,7 +60,7 @@ def build_payment_description( "payment_description_traffic", traffic_gb=human_value if human_value is not None else format_human_units(months), ) - if base in {"hwid_device", "hwid_devices"}: + if base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}: return translator("payment_description_hwid_devices", count=int(float(months))) return translator("payment_description_subscription", months=int(float(months))) @@ -75,6 +75,7 @@ def build_payment_record_payload( months: Any, provider: str, sale_mode: str, + hwid_quote: Optional[dict] = None, ) -> dict: """Assemble the payment-record dict that every callback handler used to inline. @@ -85,7 +86,7 @@ def build_payment_record_payload( base = sale_mode_base(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode) is_hwid = sale_mode_is_hwid_devices(sale_mode) - return { + payload = { "user_id": user_id, "amount": amount, "currency": currency, @@ -98,6 +99,17 @@ def build_payment_record_payload( "purchased_gb": float(months) if is_traffic else None, "purchased_hwid_devices": int(float(months)) if is_hwid else None, } + if hwid_quote and is_hwid: + payload.update( + { + "hwid_valid_from": hwid_quote.get("valid_from"), + "hwid_valid_until": hwid_quote.get("valid_until"), + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months"), + "hwid_proration_ratio": hwid_quote.get("proration_ratio"), + "hwid_full_price": hwid_quote.get("full_price"), + } + ) + return payload @dataclass(frozen=True) @@ -119,7 +131,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool: def sale_mode_is_hwid_devices(sale_mode: str) -> bool: - return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"} + return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} def sale_mode_tariff_key(sale_mode: str) -> Optional[str]: @@ -194,6 +206,11 @@ async def create_base_payment_record( tariff_key: Optional[str] = None, purchased_gb: Optional[float] = None, purchased_hwid_devices: Optional[int] = None, + hwid_valid_from: Optional[Any] = None, + hwid_valid_until: Optional[Any] = None, + hwid_pricing_period_months: Optional[int] = None, + hwid_proration_ratio: Optional[float] = None, + hwid_full_price: Optional[float] = None, ) -> Payment: payment = await payment_dal.create_payment_record( session, @@ -209,6 +226,11 @@ async def create_base_payment_record( "tariff_key": tariff_key, "purchased_gb": purchased_gb, "purchased_hwid_devices": purchased_hwid_devices, + "hwid_valid_from": hwid_valid_from, + "hwid_valid_until": hwid_valid_until, + "hwid_pricing_period_months": hwid_pricing_period_months, + "hwid_proration_ratio": hwid_proration_ratio, + "hwid_full_price": hwid_full_price, }, ) await session.commit() @@ -241,6 +263,11 @@ async def create_webapp_payment_record( tariff_key=amounts.tariff_key, purchased_gb=amounts.purchased_gb, purchased_hwid_devices=amounts.purchased_hwid_devices, + hwid_valid_from=ctx.hwid_valid_from, + hwid_valid_until=ctx.hwid_valid_until, + hwid_pricing_period_months=ctx.hwid_pricing_period_months, + hwid_proration_ratio=ctx.hwid_proration_ratio, + hwid_full_price=ctx.hwid_full_price, ) diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index 76060d8..d78c0fe 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -19,7 +19,7 @@ from db.models import Payment, User from .common import Translator, format_human_units, make_translator, sale_mode_base _TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"} -_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices"} +_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"} def is_traffic_sale_base(sale_base: str) -> bool: @@ -128,6 +128,28 @@ def build_success_message(payload: SuccessMessage) -> str: ) +def append_hwid_renewal_note( + text: str, + translator: Translator, + *, + count: Any, + valid_until: Optional[datetime], +) -> str: + try: + count_int = int(count or 0) + except (TypeError, ValueError): + count_int = 0 + if count_int <= 0: + return text + date_text = valid_until.strftime("%Y-%m-%d") if valid_until else "" + note = translator( + "payment_successful_hwid_devices_renewal_note", + count=format_human_units(count_int), + date=date_text, + ) + return f"{text}\n\n{note}" + + async def send_success_message_to_user( *, bot: Bot, @@ -333,6 +355,13 @@ async def finalize_successful_payment( inviter_name=inviter_name, ) ) + if is_subscription and activation: + success_text = append_hwid_renewal_note( + success_text, + translator, + count=activation.get("hwid_devices_renewal_recommended_count"), + valid_until=activation.get("hwid_devices_valid_until"), + ) if req.text_prefix: success_text = f"{req.text_prefix}\n{success_text}" diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index 980f2e8..aba76fb 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -35,6 +35,7 @@ from .shared import ( payment_failed, payment_record_amounts, payment_unavailable, + quote_hwid_callback_parts, safe_callback_answer, sale_mode_base, sale_mode_tariff_key, @@ -80,9 +81,10 @@ class StarsService: stars_price: int, description: str, sale_mode: str = "subscription", + hwid_quote: Optional[dict] = None, ) -> Optional[int]: + amounts = payment_record_amounts(months=months, sale_mode=sale_mode) sale_base = sale_mode_base(sale_mode) - is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} payment_record_data = { "user_id": user_id, "amount": float(stars_price), @@ -93,7 +95,15 @@ class StarsService: "provider": "telegram_stars", "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), - "purchased_gb": float(months) if is_traffic else None, + "purchased_gb": amounts.purchased_gb, + "purchased_hwid_devices": amounts.purchased_hwid_devices, + "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, + "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") + if hwid_quote + else None, + "hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None, + "hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None, } try: db_payment_record = await payment_dal.create_payment_record( @@ -206,6 +216,16 @@ async def pay_stars_callback_handler( return parts = parse_payment_callback(callback.data or "") + if not parts: + await notify_callback_parse_error(callback, translator) + return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=stars_service.subscription_service, + currency="stars", + ) if not parts: await notify_callback_parse_error(callback, translator) return @@ -221,6 +241,7 @@ async def pay_stars_callback_handler( stars_price=stars_price, description=payment_description, sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) if payment_db_id: diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 7c5194a..65875f1 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -53,6 +53,7 @@ from .shared import ( payment_record_amounts, payment_unavailable, post_json_request, + quote_hwid_callback_parts, render_link_or_fail, render_payment_link, safe_callback_answer, @@ -856,6 +857,16 @@ async def pay_wata_callback_handler( logging.error("Invalid pay_wata data in callback: %s", callback.data) await notify_callback_parse_error(callback, translator) return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=wata_service.subscription_service, + currency="rub", + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_description = describe_payment(translator, parts) @@ -901,6 +912,7 @@ async def pay_wata_callback_handler( months=parts.months, provider="wata", sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, ) try: diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index ffd7de5..21c0b78 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -52,7 +52,9 @@ from .base import ( provider_runtime_enabled, ) from .shared import ( + PaymentCallbackParts, SuccessMessage, + append_hwid_renewal_note, build_success_message, create_webapp_payment_record, format_human_units, @@ -65,6 +67,7 @@ from .shared import ( payment_link_response, payment_record_amounts, payment_unavailable, + quote_hwid_callback_parts, resolve_inviter_name, send_success_message_to_user, ) @@ -419,7 +422,7 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [ "77.75.154.128/25", "2a02:5180::/32", ] -HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices"} +HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"} def _is_hwid_device_sale_base(sale_mode_base: str) -> bool: @@ -832,6 +835,14 @@ async def process_successful_payment( ) include_keyboard = True + if sale_mode_base == "subscription" and activation_details: + details_message = append_hwid_renewal_note( + details_message, + translator, + count=activation_details.get("hwid_devices_renewal_recommended_count"), + valid_until=activation_details.get("hwid_devices_valid_until"), + ) + install_share_url = None if include_keyboard: install_links = await ensure_user_install_guide_links(session, settings, user_id) @@ -1288,6 +1299,7 @@ async def _initiate_yk_payment( payment_method_id: Optional[str] = None, selected_method_internal_id: Optional[int] = None, sale_mode: str = "subscription", + hwid_quote: Optional[dict] = None, ) -> bool: """Create payment record and initiate YooKassa payment (new card or saved card).""" if not callback.message: @@ -1299,7 +1311,7 @@ async def _initiate_yk_payment( if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else ( get_text("payment_description_hwid_devices", count=int(months)) - if sale_base in {"hwid_device", "hwid_devices"} + if sale_base in HWID_DEVICE_SALE_BASES else get_text("payment_description_subscription", months=int(months)) ) ) @@ -1316,8 +1328,15 @@ async def _initiate_yk_payment( if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, "purchased_hwid_devices": int(months) - if sale_base in {"hwid_device", "hwid_devices"} + if sale_base in HWID_DEVICE_SALE_BASES else None, + "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, + "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") + if hwid_quote + else None, + "hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None, + "hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None, } db_payment_record = None @@ -1634,6 +1653,23 @@ async def pay_yk_callback_handler( return months, price_rub, sale_mode = parsed + hwid_quote = None + if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES: + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency="rub", + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = "RUB" autopay_enabled = bool( @@ -1710,6 +1746,7 @@ async def pay_yk_callback_handler( save_payment_method=autopay_enabled and autopay_require_binding, back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() diff --git a/backend/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py index ab01ce7..94c10e6 100644 --- a/backend/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import ( ) from bot.middlewares.i18n import JsonI18n from config.settings import Settings -from db.dal import user_dal +from db.dal import tariff_dal, user_dal from .email_auth_service import EmailAuthService from .email_templates import render_subscription_expiring @@ -63,13 +63,46 @@ class PanelWebhookService: **kwargs, ): _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) + extra_text = str(kwargs.pop("extra_text", "") or "").strip() try: + text = _(message_key, **kwargs) + if extra_text: + text = f"{text}\n\n{extra_text}" await self.bot.send_message( - user_id, _(message_key, **kwargs), reply_markup=reply_markup + user_id, text, reply_markup=reply_markup ) except Exception: logging.exception("Failed to send notification to %s", user_id) + async def _hwid_renewal_note(self, internal_user_id: int, lang: str) -> str: + try: + from db.dal import subscription_dal + + async with self.async_session_factory() as session: + sub = await subscription_dal.get_active_subscription_by_user_id( + session, internal_user_id + ) + if not sub: + return "" + summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + ) + count = int(summary.get("active_devices") or sub.extra_hwid_devices or 0) + if count <= 0: + return "" + active_until = summary.get("active_until") or sub.end_date + date_text = active_until.strftime("%Y-%m-%d") if active_until else "" + except Exception: + logging.exception("Failed to build HWID renewal note for user %s", internal_user_id) + return "" + return self.i18n.gettext( + lang, + "subscription_hwid_renewal_reminder", + count=count, + date=date_text, + ) + async def handle_event(self, event_name: str, user_payload: dict): telegram_id = user_payload.get("telegramId") if not telegram_id: @@ -97,6 +130,7 @@ class PanelWebhookService: if event_name in EVENT_MAP: days_left, msg_key = EVENT_MAP[event_name] + hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang) if days_left == 1: # Trigger auto-renew via SubscriptionService (wired in at factory) try: @@ -148,6 +182,7 @@ class PanelWebhookService: "autorenew_48h_charge_tomorrow_notice", reply_markup=cancel_kb, user_name=first_name, + extra_text=hwid_renewal_note, ) return await self._send_message( @@ -157,6 +192,7 @@ class PanelWebhookService: reply_markup=markup, user_name=first_name, end_date=user_payload.get("expireAt", "")[:10], + extra_text=hwid_renewal_note, ) if days_left == 3 and user_email: await self._send_subscription_expiring_email( diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index 999e99d..0d82b52 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -3,6 +3,190 @@ from ._runtime import * # noqa: F403,F405 class HwidDeviceMixin: + @staticmethod + def _as_aware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + async def _active_hwid_extra_devices_for_sub( + self, + session: AsyncSession, + sub: Subscription, + *, + at: Optional[datetime] = None, + ) -> int: + try: + return await tariff_dal.sum_active_hwid_devices( + session, + subscription_id=sub.subscription_id, + at=at or datetime.now(timezone.utc), + ) + except Exception: + logging.exception( + "Failed to recalculate active HWID devices for subscription %s", + getattr(sub, "subscription_id", None), + ) + return int(getattr(sub, "extra_hwid_devices", 0) or 0) + + async def _hwid_topup_validity_window( + self, + session: AsyncSession, + sub: Subscription, + *, + renewal: bool, + now: datetime, + ) -> Optional[Tuple[datetime, datetime, Dict[str, Any]]]: + valid_until = self._as_aware_utc(getattr(sub, "end_date", None)) + if not valid_until or valid_until <= now: + return None + + summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + at=now, + ) + valid_from = now + if renewal: + active_until = self._as_aware_utc(summary.get("active_until")) + if active_until and now < active_until < valid_until: + valid_from = active_until + elif active_until and active_until >= valid_until: + return None + return valid_from, valid_until, summary + + @staticmethod + def _round_hwid_price(value: float, *, currency: str) -> float: + if value <= 0: + return 0.0 + if currency == "stars": + return float(math.ceil(value)) + return math.ceil(float(value) * 100) / 100 + + @staticmethod + def _find_hwid_package(tariff: Tariff, device_count: int, currency: str) -> Optional[Any]: + package_set = tariff.hwid_device_packages + if not package_set: + return None + packages = package_set.for_currency("stars" if currency == "stars" else "rub") + return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) + + def _quote_hwid_package_price( + self, + *, + sub: Subscription, + package: Any, + valid_from: datetime, + valid_until: datetime, + now: datetime, + currency: str, + ) -> Dict[str, Any]: + period_months = max(1, int(getattr(sub, "duration_months", None) or 1)) + full_price = float(package.price_for_period(period_months)) + period_start = self._as_aware_utc(getattr(sub, "start_date", None)) + period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until + if not period_start or period_start >= period_end: + period_start = valid_from + period_end = valid_until + + basis_seconds = max(1.0, (period_end - period_start).total_seconds()) + billable_start = max(now, valid_from) + billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) + ratio = billable_seconds / basis_seconds + raw_price = full_price * ratio + price = self._round_hwid_price(raw_price, currency=currency) + min_price = getattr(package, "min_price", None) + if raw_price > 0 and min_price is not None: + price = max(price, self._round_hwid_price(float(min_price), currency=currency)) + if currency == "stars": + price = float(int(math.ceil(price))) + + return { + "price": price, + "full_price": full_price, + "pricing_period_months": period_months, + "proration_ratio": ratio, + "valid_from": valid_from, + "valid_until": valid_until, + "billable_seconds": billable_seconds, + "period_seconds": basis_seconds, + "currency": currency, + } + + async def quote_hwid_device_topup( + self, + session: AsyncSession, + *, + user_id: int, + device_count: int, + tariff_key: Optional[str] = None, + renewal: bool = False, + currency: str = "rub", + now: Optional[datetime] = None, + ) -> Optional[Dict[str, Any]]: + try: + purchased_devices = int(device_count) + except (TypeError, ValueError): + return None + if purchased_devices <= 0: + 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 = self._resolve_tariff(tariff_key or sub.tariff_key) + if not tariff or tariff.billing_model != "period": + 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: + return None + + package = self._find_hwid_package(tariff, purchased_devices, currency) + if not package: + return None + + now = now or datetime.now(timezone.utc) + window = await self._hwid_topup_validity_window( + session, + sub, + renewal=renewal, + now=now, + ) + if not window: + return None + valid_from, valid_until, entitlement_summary = window + quote = self._quote_hwid_package_price( + sub=sub, + package=package, + valid_from=valid_from, + valid_until=valid_until, + now=now, + currency="stars" if currency == "stars" else "rub", + ) + quote.update( + { + "subscription_id": sub.subscription_id, + "tariff_key": tariff.key, + "device_count": purchased_devices, + "renewal": renewal, + "active_extra_devices": int(entitlement_summary.get("active_devices") or 0), + "active_until": entitlement_summary.get("active_until"), + } + ) + return quote + async def activate_hwid_device_topup( self, session: AsyncSession, @@ -12,6 +196,7 @@ class HwidDeviceMixin: payment_db_id: int, provider: str = "yookassa", tariff_key: Optional[str] = None, + renewal: bool = False, ) -> Optional[Dict[str, Any]]: try: purchased_devices = int(device_count) @@ -33,6 +218,14 @@ class HwidDeviceMixin: tariff = None if self._tariffs_config(): tariff = self._resolve_tariff(tariff_key or sub.tariff_key) + if tariff.billing_model != "period": + logging.info( + "Skipping HWID top-up for user %s because tariff %s is %s", + user_id, + tariff.key, + tariff.billing_model, + ) + return None packages = ( [*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars] if tariff.hwid_device_packages @@ -66,14 +259,60 @@ class HwidDeviceMixin: "purchased_hwid_devices": 0, } - new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices + now = datetime.now(timezone.utc) + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + at=now, + ) + valid_from = self._as_aware_utc(getattr(payment, "hwid_valid_from", None)) + valid_until = self._as_aware_utc(getattr(payment, "hwid_valid_until", None)) + if valid_from and valid_until: + if valid_until <= now or valid_from >= valid_until: + logging.error( + "Frozen HWID quote is no longer valid for user %s " + "(payment_id=%s, valid_from=%s, valid_until=%s)", + user_id, + payment_db_id, + valid_from, + valid_until, + ) + return None + else: + window = await self._hwid_topup_validity_window( + session, + sub, + renewal=renewal, + now=now, + ) + if window: + valid_from, valid_until, entitlement_summary = window + if not valid_from or not valid_until: + logging.error( + "HWID top-up has no valid subscription window for user %s " + "(subscription_id=%s, renewal=%s)", + user_id, + sub.subscription_id, + renewal, + ) + return None + + active_extra_devices = int(entitlement_summary.get("active_devices") or 0) + starts_now = valid_from <= now < valid_until + new_extra_devices = active_extra_devices + (purchased_devices if starts_now else 0) 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", + sale_mode="hwid_devices_renewal" if renewal else "hwid_devices", tariff_key=tariff.key if tariff else sub.tariff_key, purchased_hwid_devices=purchased_devices, + hwid_valid_from=valid_from, + hwid_valid_until=valid_until, + hwid_pricing_period_months=getattr(payment, "hwid_pricing_period_months", None), + hwid_proration_ratio=getattr(payment, "hwid_proration_ratio", None), + hwid_full_price=getattr(payment, "hwid_full_price", None), ) updated_sub = await subscription_dal.update_subscription( session, @@ -115,6 +354,8 @@ class HwidDeviceMixin: subscription_id=updated_sub.subscription_id, payment_id=payment_db_id, purchased_devices=purchased_devices, + valid_from=valid_from, + valid_until=valid_until, ) return { "subscription_id": updated_sub.subscription_id, @@ -127,4 +368,7 @@ class HwidDeviceMixin: "extra_hwid_devices": new_extra_devices, "purchased_hwid_devices": purchased_devices, "tariff_key": tariff.key if tariff else sub.tariff_key, + "hwid_devices_valid_from": valid_from, + "hwid_devices_valid_until": valid_until, + "hwid_devices_renewal": renewal, } diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 52425f6..b3b6d5b 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -23,8 +23,15 @@ class SubscriptionLifecycleMixin: if not sub: return None before_tariff_key = sub.tariff_key - options = self.calculate_tariff_switch_options(sub, target) now = datetime.now(timezone.utc) + options = await self.calculate_tariff_switch_options_with_hwid(session, sub, target) + converted_hwid_purchase_ids = list(options.get("convertible_hwid_purchase_ids") or []) + if converted_hwid_purchase_ids: + await tariff_dal.expire_hwid_device_purchases( + session, + purchase_ids=converted_hwid_purchase_ids, + at=now, + ) premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) premium_baseline = target.premium_monthly_bytes @@ -44,8 +51,20 @@ class SubscriptionLifecycleMixin: } converted_bytes = None base_hwid_limit = self._base_hwid_limit_for_tariff(target) - extra_hwid_devices = int(sub.extra_hwid_devices or 0) + try: + extra_hwid_devices = await tariff_dal.sum_active_hwid_devices( + session, + subscription_id=sub.subscription_id, + at=now, + ) + except Exception: + logging.exception( + "Failed to recalculate HWID devices during tariff switch for user %s", + user_id, + ) + extra_hwid_devices = int(sub.extra_hwid_devices or 0) update_data["hwid_device_limit"] = base_hwid_limit + update_data["extra_hwid_devices"] = extra_hwid_devices if target.billing_model == "period": update_data["tier_baseline_bytes"] = target.monthly_bytes @@ -154,6 +173,8 @@ class SubscriptionLifecycleMixin: if updated.end_date and target.billing_model == "period" else None, "converted_bytes": converted_bytes, + "converted_hwid_value_rub": options.get("converted_hwid_value_rub"), + "converted_hwid_days": options.get("converted_hwid_days"), "eff_price_before": sub.effective_monthly_price_rub, "eff_price_after": updated.effective_monthly_price_rub, }, @@ -236,7 +257,7 @@ class SubscriptionLifecycleMixin: payment_db_id=payment_db_id, provider=provider, ) - if sale_mode_base in {"hwid_device", "hwid_devices"}: + if sale_mode_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}: target_devices = int(traffic_gb if traffic_gb is not None else months) return await self.activate_hwid_device_topup( session=session, @@ -246,6 +267,7 @@ class SubscriptionLifecycleMixin: payment_db_id=payment_db_id, provider=provider, tariff_key=tariff_key, + renewal=sale_mode_base == "hwid_devices_renewal", ) if sale_mode_base == "tariff_upgrade": if not tariff_key: @@ -378,7 +400,25 @@ class SubscriptionLifecycleMixin: ) 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) + extra_hwid_devices = 0 + hwid_devices_valid_until = None + if current_active_sub: + try: + hwid_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=current_active_sub.subscription_id, + at=datetime.now(timezone.utc), + ) + extra_hwid_devices = int(hwid_summary.get("active_devices") or 0) + hwid_devices_valid_until = hwid_summary.get("active_until") + except Exception: + logging.exception( + "Failed to recalculate active HWID devices for renewal of user %s", + user_id, + ) + extra_hwid_devices = int( + getattr(current_active_sub, "extra_hwid_devices", 0) or 0 + ) premium_topup_balance_bytes = int( getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0 ) @@ -497,6 +537,8 @@ class SubscriptionLifecycleMixin: "subscription_url": final_subscription_url, "applied_promo_bonus_days": applied_promo_bonus_days, "tariff_key": tariff.key if tariff else None, + "hwid_devices_renewal_recommended_count": extra_hwid_devices, + "hwid_devices_valid_until": hwid_devices_valid_until, } async def extend_active_subscription_days( @@ -760,6 +802,43 @@ class SubscriptionLifecycleMixin: if local_active_sub else False ) + hwid_entitlement_summary: Dict[str, Any] = {} + active_extra_hwid_devices = ( + int(local_active_sub.extra_hwid_devices or 0) if local_active_sub else 0 + ) + if local_active_sub: + try: + hwid_entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=local_active_sub.subscription_id, + at=datetime.now(timezone.utc), + ) + active_extra_hwid_devices = int( + hwid_entitlement_summary.get("active_devices") or 0 + ) + if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0): + await subscription_dal.update_subscription( + session, + local_active_sub.subscription_id, + {"extra_hwid_devices": active_extra_hwid_devices}, + ) + local_active_sub.extra_hwid_devices = active_extra_hwid_devices + except Exception: + logging.exception( + "Failed to load HWID entitlement summary for subscription %s", + local_active_sub.subscription_id, + ) + base_hwid_limit_for_payload = ( + local_active_sub.hwid_device_limit + if local_active_sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + expected_hwid_limit = self._effective_hwid_limit( + base_hwid_limit_for_payload, + active_extra_hwid_devices, + ) + if expected_hwid_limit is not None: + hwid_limit = expected_hwid_limit return { "user_id": panel_user_data.get("uuid"), @@ -820,9 +899,11 @@ class SubscriptionLifecycleMixin: "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, + "extra_hwid_devices": active_extra_hwid_devices, + "extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"), + "extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get( + "next_valid_from" + ), "user_bot_username": db_user.username, "is_panel_data": True, "max_devices": hwid_limit, diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index b82bcb0..32d1683 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -26,6 +26,11 @@ class PaymentContextMixin: tariff_key: Optional[str], purchased_gb: Optional[float] = None, purchased_hwid_devices: Optional[int] = None, + hwid_valid_from: Optional[datetime] = None, + hwid_valid_until: Optional[datetime] = None, + hwid_pricing_period_months: Optional[int] = None, + hwid_proration_ratio: Optional[float] = None, + hwid_full_price: Optional[float] = None, ) -> None: payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) if not payment: @@ -34,6 +39,16 @@ class PaymentContextMixin: payment.tariff_key = tariff_key payment.purchased_gb = purchased_gb payment.purchased_hwid_devices = purchased_hwid_devices + if hwid_valid_from is not None: + payment.hwid_valid_from = hwid_valid_from + if hwid_valid_until is not None: + payment.hwid_valid_until = hwid_valid_until + if hwid_pricing_period_months is not None: + payment.hwid_pricing_period_months = hwid_pricing_period_months + if hwid_proration_ratio is not None: + payment.hwid_proration_ratio = hwid_proration_ratio + if hwid_full_price is not None: + payment.hwid_full_price = hwid_full_price await session.flush() async def get_user_language(self, session: AsyncSession, user_id: int) -> str: diff --git a/backend/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py index 7ed111b..38a2700 100644 --- a/backend/bot/services/subscription_service_impl/tariffs.py +++ b/backend/bot/services/subscription_service_impl/tariffs.py @@ -370,3 +370,91 @@ class TariffMixin: } return {"mode": "traffic_to_period", "remaining_days": remaining_days} + + @staticmethod + def _aware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + async def _hwid_conversion_credit( + self, + session: AsyncSession, + sub: Subscription, + *, + at: datetime, + ) -> Dict[str, Any]: + entries = await tariff_dal.get_hwid_device_value_entries( + session, + subscription_id=sub.subscription_id, + at=at, + ) + value_rub = 0.0 + purchase_ids: List[int] = [] + skipped_devices = 0 + for entry in entries: + currency = str(entry.get("currency") or "").upper() + if currency in {"XTR", "STARS", "STAR"}: + skipped_devices += int(entry.get("purchased_devices") or 0) + continue + amount = float(entry.get("amount") or 0) + if amount <= 0: + continue + valid_from = ( + self._aware_utc(entry.get("valid_from")) + or self._aware_utc(entry.get("created_at")) + or at + ) + valid_until = self._aware_utc(entry.get("valid_until")) + if not valid_until or valid_until <= at or valid_from >= valid_until: + continue + total_seconds = max(1.0, (valid_until - valid_from).total_seconds()) + remaining_start = max(at, valid_from) + remaining_seconds = max(0.0, (valid_until - remaining_start).total_seconds()) + if remaining_seconds <= 0: + continue + value_rub += amount * (remaining_seconds / total_seconds) + purchase_ids.append(int(entry["purchase_id"])) + return { + "value_rub": value_rub, + "purchase_ids": purchase_ids, + "skipped_devices": skipped_devices, + } + + async def calculate_tariff_switch_options_with_hwid( + self, + session: AsyncSession, + sub: Subscription, + target_tariff: Tariff, + ) -> Dict[str, Any]: + options = dict(self.calculate_tariff_switch_options(sub, target_tariff)) + now = datetime.now(timezone.utc) + credit = await self._hwid_conversion_credit(session, sub, at=now) + value_rub = float(credit.get("value_rub") or 0) + options["converted_hwid_value_rub"] = round(value_rub, 2) + options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or []) + options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0) + if value_rub <= 0: + return options + + if options.get("mode") == "period_to_period": + target_monthly = float(options.get("target_monthly_rub") or 0) + hwid_days = ( + math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0 + ) + options["converted_hwid_days"] = max(0, hwid_days) + options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days) + options["paid_diff_rub"] = max( + 0, + math.ceil(float(options.get("paid_diff_rub") or 0) - value_rub), + ) + return options + + if options.get("mode") == "period_to_traffic": + rub_per_gb = float(options.get("rub_per_gb") or 0) + hwid_gb = math.floor(value_rub / rub_per_gb) if rub_per_gb > 0 else 0 + options["converted_hwid_gb"] = max(0, hwid_gb) + options["converted_gb"] = int(options.get("converted_gb") or 0) + max(0, hwid_gb) + return options diff --git a/backend/bot/services/subscription_service_impl/traffic.py b/backend/bot/services/subscription_service_impl/traffic.py index d461bd1..a3b7e49 100644 --- a/backend/bot/services/subscription_service_impl/traffic.py +++ b/backend/bot/services/subscription_service_impl/traffic.py @@ -53,7 +53,11 @@ class TrafficMixin: 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) + extra_hwid_devices = ( + await self._active_hwid_extra_devices_for_sub(session, active_sub) + if active_sub + else 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)) @@ -222,10 +226,8 @@ class TrafficMixin: 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), - ) + extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub) + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) updated_sub = await subscription_dal.update_subscription( session, sub.subscription_id, @@ -235,6 +237,7 @@ class TrafficMixin: "is_throttled": False, "tariff_key": tariff.key, "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": extra_hwid_devices, }, ) panel_payload = self._build_panel_update_payload( @@ -499,10 +502,8 @@ class TrafficMixin: 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), - ) + extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub) + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) updated_sub = await subscription_dal.update_subscription( session, sub.subscription_id, @@ -511,6 +512,7 @@ class TrafficMixin: "traffic_limit_bytes": new_limit, "is_throttled": False, "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": extra_hwid_devices, }, ) panel_payload = self._build_panel_update_payload( @@ -580,10 +582,9 @@ class TrafficMixin: 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), - ) + extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub) + sub.extra_hwid_devices = extra_hwid_devices + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) panel_payload = self._build_panel_update_payload( panel_user_uuid=db_user.panel_user_uuid, expire_at=sub.end_date, diff --git a/backend/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py index a2dccb3..a286ec4 100644 --- a/backend/bot/services/tariff_worker.py +++ b/backend/bot/services/tariff_worker.py @@ -215,6 +215,7 @@ class TariffTrafficWorker: if tariff.billing_model == "period": await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) + await self._sync_hwid_device_limit(session, sub, tariff, panel_data) await self._maybe_warn_or_throttle( session, sub, @@ -377,6 +378,64 @@ class TariffTrafficWorker: sub.panel_user_uuid, payload, log_response=False ) + async def _sync_hwid_device_limit( + self, + session: AsyncSession, + sub: Subscription, + tariff, + panel_data: dict, + ) -> None: + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self.subscription_service._base_hwid_limit_for_tariff(tariff) + ) + active_extra = await tariff_dal.sum_active_hwid_devices( + session, + subscription_id=sub.subscription_id, + at=datetime.now(timezone.utc), + ) + update_data = {} + if sub.hwid_device_limit != base_hwid_limit: + update_data["hwid_device_limit"] = base_hwid_limit + if int(sub.extra_hwid_devices or 0) != active_extra: + update_data["extra_hwid_devices"] = active_extra + if update_data: + for key, value in update_data.items(): + setattr(sub, key, value) + + effective_limit = self.subscription_service._effective_hwid_limit( + base_hwid_limit, + active_extra, + ) + if effective_limit is None: + return + try: + panel_limit = panel_data.get("hwidDeviceLimit") + panel_limit_int = int(panel_limit) if panel_limit is not None else None + except (TypeError, ValueError): + panel_limit_int = None + if panel_limit_int == effective_limit: + return + + payload = self.subscription_service._build_panel_update_payload( + panel_user_uuid=sub.panel_user_uuid, + expire_at=sub.end_date, + hwid_device_limit=effective_limit, + include_default_squads=False, + ) + updated_panel = await self.panel_service.update_user_details_on_panel( + sub.panel_user_uuid, + payload, + log_response=False, + ) + if not updated_panel or updated_panel.get("error"): + logging.warning( + "TariffTrafficWorker: failed to sync HWID limit for subscription %s: %s", + sub.subscription_id, + updated_panel, + ) + async def _maybe_warn_or_throttle( self, session: AsyncSession, diff --git a/backend/config/tariffs_config.py b/backend/config/tariffs_config.py index 358e05c..19b81e4 100644 --- a/backend/config/tariffs_config.py +++ b/backend/config/tariffs_config.py @@ -25,6 +25,8 @@ class TrafficPackage(BaseModel): class HwidDevicePackage(BaseModel): count: int price: float + prices: Dict[str, float] = Field(default_factory=dict) + min_price: Optional[float] = None @model_validator(mode="after") def validate_values(self) -> "HwidDevicePackage": @@ -32,8 +34,32 @@ class HwidDevicePackage(BaseModel): raise ValueError("device package count must be greater than zero") if self.price < 0: raise ValueError("device package price must be non-negative") + normalized_prices: Dict[str, float] = {} + for period, value in (self.prices or {}).items(): + period_key = str(period).strip() + if not period_key: + raise ValueError("device package price period must not be empty") + try: + period_months = int(period_key) + except (TypeError, ValueError) as exc: + raise ValueError("device package price period must be an integer") from exc + if period_months <= 0: + raise ValueError("device package price period must be positive") + if float(value) < 0: + raise ValueError("device package period price must be non-negative") + normalized_prices[str(period_months)] = float(value) + self.prices = normalized_prices + if self.min_price is not None and self.min_price < 0: + raise ValueError("device package min_price must be non-negative") return self + def price_for_period(self, months: int) -> float: + months_int = max(1, int(months or 1)) + value = self.prices.get(str(months_int)) + if value is not None: + return float(value) + return float(self.price) * months_int + class PackageSet(BaseModel): rub: List[TrafficPackage] = Field(default_factory=list) diff --git a/backend/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py index 2da5976..15b8770 100644 --- a/backend/db/dal/tariff_dal.py +++ b/backend/db/dal/tariff_dal.py @@ -1,9 +1,11 @@ +import inspect +from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from sqlalchemy import and_, delete, func, select +from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning +from db.models import HwidDevicePurchase, Payment, TariffChange, TrafficTopup, TrafficWarning async def create_traffic_topup( @@ -52,11 +54,15 @@ async def create_hwid_device_purchase( subscription_id: int, payment_id: Optional[int], purchased_devices: int, + valid_from: Optional[datetime] = None, + valid_until: Optional[datetime] = None, ) -> HwidDevicePurchase: record = HwidDevicePurchase( subscription_id=subscription_id, payment_id=payment_id, purchased_devices=purchased_devices, + valid_from=valid_from or datetime.now(timezone.utc), + valid_until=valid_until, ) session.add(record) await session.flush() @@ -64,6 +70,127 @@ async def create_hwid_device_purchase( return record +def _hwid_active_conditions(subscription_id: int, at: datetime) -> List[Any]: + return [ + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + or_(HwidDevicePurchase.valid_from.is_(None), HwidDevicePurchase.valid_from <= at), + or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at), + ] + + +async def _resolve_result_value(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +async def sum_active_hwid_devices( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, +) -> int: + at = at or datetime.now(timezone.utc) + result = await session.execute( + select(func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0)).where( + and_(*_hwid_active_conditions(subscription_id, at)) + ) + ) + return int(await _resolve_result_value(result.scalar()) or 0) + + +async def get_hwid_device_entitlement_summary( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, +) -> Dict[str, Any]: + at = at or datetime.now(timezone.utc) + active_result = await session.execute( + select( + func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0), + func.max(HwidDevicePurchase.valid_until), + ).where(and_(*_hwid_active_conditions(subscription_id, at))) + ) + active_devices, active_until = await _resolve_result_value(active_result.one()) + future_result = await session.execute( + select(func.min(HwidDevicePurchase.valid_from)).where( + and_( + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + HwidDevicePurchase.valid_from > at, + ) + ) + ) + return { + "active_devices": int(active_devices or 0), + "active_until": active_until, + "next_valid_from": await _resolve_result_value(future_result.scalar_one_or_none()), + } + + +async def get_hwid_device_value_entries( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, +) -> List[Dict[str, Any]]: + at = at or datetime.now(timezone.utc) + result = await session.execute( + select( + HwidDevicePurchase.purchase_id, + HwidDevicePurchase.purchased_devices, + HwidDevicePurchase.valid_from, + HwidDevicePurchase.valid_until, + HwidDevicePurchase.created_at, + Payment.amount, + Payment.currency, + ) + .outerjoin(Payment, Payment.payment_id == HwidDevicePurchase.payment_id) + .where( + and_( + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at), + ) + ) + ) + entries = [] + rows = await _resolve_result_value(result.all()) + for row in rows: + entries.append( + { + "purchase_id": row[0], + "purchased_devices": row[1], + "valid_from": row[2], + "valid_until": row[3], + "created_at": row[4], + "amount": row[5], + "currency": row[6], + } + ) + return entries + + +async def expire_hwid_device_purchases( + session: AsyncSession, + *, + purchase_ids: List[int], + at: Optional[datetime] = None, +) -> int: + ids = [int(item) for item in purchase_ids if item is not None] + if not ids: + return 0 + at = at or datetime.now(timezone.utc) + result = await session.execute( + update(HwidDevicePurchase) + .where(HwidDevicePurchase.purchase_id.in_(ids)) + .values(valid_until=at) + ) + return result.rowcount or 0 + + async def create_tariff_change( session: AsyncSession, change_data: Dict[str, Any], diff --git a/backend/db/database_setup.py b/backend/db/database_setup.py index 43aa367..8ec0bf0 100644 --- a/backend/db/database_setup.py +++ b/backend/db/database_setup.py @@ -1,5 +1,6 @@ import logging +from sqlalchemy import text from sqlalchemy.engine import make_url from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import sessionmaker @@ -10,6 +11,7 @@ from db.models import Base from .migrator import run_database_migrations async_engine = None +DB_INIT_ADVISORY_LOCK_ID = 817512404897421337 def redacted_database_url(database_url: str) -> str: @@ -71,6 +73,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): ) async with async_engine.begin() as conn: + await conn.execute(text(f"SELECT pg_advisory_xact_lock({DB_INIT_ADVISORY_LOCK_ID})")) await conn.run_sync(Base.metadata.create_all) await conn.run_sync(run_database_migrations) logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.") @@ -83,8 +86,6 @@ async def init_db(settings: Settings, session_factory: sessionmaker): logging.warning(f"Failed to load setting overrides on startup: {e_overrides}") async with session_factory() as session: - from sqlalchemy import text - from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status try: diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 56fc9cb..931ac1b 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -919,6 +919,103 @@ def _migration_0028_add_locale_overrides(connection: Connection) -> None: ) +def _migration_0029_add_hwid_device_purchase_validity(connection: Connection) -> None: + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names: + return + + columns: Set[str] = { + col["name"] for col in inspector.get_columns("hwid_device_purchases") + } + if "valid_from" not in columns: + connection.execute( + text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ") + ) + if "valid_until" not in columns: + connection.execute( + text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_until TIMESTAMPTZ") + ) + + connection.execute( + text( + """ + UPDATE hwid_device_purchases hp + SET + valid_from = COALESCE(hp.valid_from, hp.created_at, s.start_date, NOW()), + valid_until = COALESCE(hp.valid_until, s.end_date) + FROM subscriptions s + WHERE hp.subscription_id = s.subscription_id + AND (hp.valid_from IS NULL OR hp.valid_until IS NULL) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO hwid_device_purchases ( + subscription_id, + payment_id, + purchased_devices, + valid_from, + valid_until + ) + SELECT + s.subscription_id, + NULL, + s.extra_hwid_devices, + COALESCE(s.start_date, NOW()), + s.end_date + FROM subscriptions s + WHERE COALESCE(s.extra_hwid_devices, 0) > 0 + AND s.end_date IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM hwid_device_purchases hp + WHERE hp.subscription_id = s.subscription_id + ) + """ + ) + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_window " + "ON hwid_device_purchases (subscription_id, valid_from, valid_until)" + ) + ) + + +def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None: + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + if "payments" in table_names: + payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} + payment_additions = { + "hwid_valid_from": "TIMESTAMPTZ", + "hwid_valid_until": "TIMESTAMPTZ", + "hwid_pricing_period_months": "INTEGER", + "hwid_proration_ratio": "DOUBLE PRECISION", + "hwid_full_price": "DOUBLE PRECISION", + } + for column, ddl_type in payment_additions.items(): + if column not in payment_columns: + connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}")) + + if "tariff_changes" in table_names: + change_columns: Set[str] = { + col["name"] for col in inspector.get_columns("tariff_changes") + } + change_additions = { + "converted_hwid_value_rub": "NUMERIC", + "converted_hwid_days": "INTEGER", + } + for column, ddl_type in change_additions.items(): + if column not in change_columns: + connection.execute( + text(f"ALTER TABLE tariff_changes ADD COLUMN {column} {ddl_type}") + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1071,6 +1168,16 @@ MIGRATIONS: List[Migration] = [ description="Persist runtime overrides for localization strings", upgrade=_migration_0028_add_locale_overrides, ), + Migration( + id="0029_add_hwid_device_purchase_validity", + description="Track validity windows for HWID device top-ups", + upgrade=_migration_0029_add_hwid_device_purchase_validity, + ), + Migration( + id="0030_add_hwid_pricing_metadata", + description="Persist quoted HWID top-up pricing windows and conversion audit", + upgrade=_migration_0030_add_hwid_pricing_metadata, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index afd4a79..b8859a1 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -203,6 +203,11 @@ class Payment(Base): tariff_key = Column(String, nullable=True, index=True) purchased_gb = Column(Float, nullable=True) purchased_hwid_devices = Column(Integer, nullable=True) + hwid_valid_from = Column(DateTime(timezone=True), nullable=True) + hwid_valid_until = Column(DateTime(timezone=True), nullable=True) + hwid_pricing_period_months = Column(Integer, nullable=True) + hwid_proration_ratio = Column(Float, nullable=True) + hwid_full_price = Column(Float, nullable=True) promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) @@ -229,6 +234,14 @@ class TrafficTopup(Base): class HwidDevicePurchase(Base): __tablename__ = "hwid_device_purchases" + __table_args__ = ( + Index( + "ix_hwid_device_purchases_subscription_window", + "subscription_id", + "valid_from", + "valid_until", + ), + ) purchase_id = Column(Integer, primary_key=True, autoincrement=True) subscription_id = Column( @@ -236,6 +249,8 @@ class HwidDevicePurchase(Base): ) payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True) purchased_devices = Column(Integer, nullable=False) + valid_from = Column(DateTime(timezone=True), nullable=True) + valid_until = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) subscription = relationship("Subscription") @@ -276,6 +291,8 @@ class TariffChange(Base): days_before = Column(Integer, nullable=True) days_after = Column(Integer, nullable=True) converted_bytes = Column(BigInteger, nullable=True) + converted_hwid_value_rub = Column(Numeric, nullable=True) + converted_hwid_days = Column(Integer, nullable=True) eff_price_before = Column(Numeric, nullable=True) eff_price_after = Column(Numeric, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/main_worker.py b/backend/main_worker.py index 951ee91..59cff48 100644 --- a/backend/main_worker.py +++ b/backend/main_worker.py @@ -24,7 +24,6 @@ from bot.payment_providers.yookassa import ( process_successful_payment, ) from bot.services.locale_override_service import load_locale_overrides -from bot.services.settings_override_service import load_overrides_from_db from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager from config.settings import get_settings @@ -32,7 +31,7 @@ from config.settings import get_settings async def _build_worker_context(settings): session_factory = database_setup.init_db_connection(settings) - await load_overrides_from_db(settings, session_factory) + await database_setup.init_db(settings, session_factory) bot = Bot( token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), diff --git a/docs/deployment.md b/docs/deployment.md index 350d014..574437f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,8 +21,9 @@ docker compose logs -f backend worker frontend - `worker` только после успешных миграций; - `frontend` как отдельный nginx-образ без Python runtime. -Миграции не запускаются внутри backend. Их выполняет отдельный сервис `migrate`, поэтому старт -приложения не создает гонки на схеме БД. +Основной путь миграций — отдельный сервис `migrate`. `backend` и `worker` также выполняют +безопасную проверку схемы на старте под PostgreSQL advisory lock, поэтому прямой запуск сервиса +без compose тоже применит недостающие миграции и не создаст гонку на схеме БД. ## Готовые папки запуска @@ -71,7 +72,8 @@ docker compose logs migrate ``` `backend` и `worker` зависят от `migrate` через `service_completed_successfully`; если миграции -падают, приложение не стартует поверх неподготовленной БД. +падают, приложение не стартует поверх неподготовленной БД. При прямом запуске `backend` или +`worker` без compose тот же `init_db` применяет недостающие миграции перед стартом логики сервиса. ## Сервисы diff --git a/docs/tariffs.md b/docs/tariffs.md index 095c92a..b85ca81 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -66,6 +66,17 @@ JSON-каталог может содержать несколько тариф "rub": [{ "gb": 10, "price": 99 }], "stars": [{ "gb": 10, "price": 2500 }] }, + "hwid_device_packages": { + "rub": [ + { + "count": 1, + "price": 99, + "prices": { "1": 99, "3": 249 }, + "min_price": 20 + } + ], + "stars": [{ "count": 1, "price": 50, "prices": { "1": 50, "3": 130 } }] + }, "enabled": true } ] @@ -88,7 +99,7 @@ JSON-каталог может содержать несколько тариф | `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. | | `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | | `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | -| `tariffs[].hwid_device_packages` | Пакеты докупки устройств: `{ "count": 1, "price": 99 }`. | +| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. | Для `period`-тарифа также используются: @@ -210,8 +221,15 @@ limit_after = current_used + balance_after { "hwid_device_limit": 5, "hwid_device_packages": { - "rub": [{ "count": 1, "price": 99 }], - "stars": [{ "count": 1, "price": 2500 }] + "rub": [ + { + "count": 1, + "price": 99, + "prices": { "1": 99, "3": 249, "6": 449, "12": 799 }, + "min_price": 20 + } + ], + "stars": [{ "count": 1, "price": 50, "prices": { "1": 50, "3": 130 } }] } } ``` @@ -219,11 +237,17 @@ limit_after = current_used + balance_after Правила: - `hwid_device_limit` хранит базовый лимит тарифа; -- `extra_hwid_devices` хранит количество докупленных устройств; -- эффективный лимит равен `hwid_device_limit + extra_hwid_devices`; +- `extra_hwid_devices` хранит только текущую активную сумму докупленных устройств; +- срок действия каждой докупки хранится в `hwid_device_purchases.valid_from` / `valid_until`; +- эффективный лимит равен `hwid_device_limit + active extra_hwid_devices`; - базовый лимит `0` означает безлимит, в Remnawave отправляется `hwidDeviceLimit = 0`; - при безлимитном базовом лимите докупка устройств не применяется; -- при смене тарифа базовый лимит берется из целевого тарифа, а докупленные устройства сохраняются; +- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; +- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; +- для Telegram Stars цена округляется вверх до целого Stars, для RUB — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; +- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; +- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; +- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная RUB-стоимость HWID-докупок конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - история докупок пишется в `hwid_device_purchases`; - платеж хранит количество устройств в `payments.purchased_hwid_devices`. @@ -237,9 +261,9 @@ limit_after = current_used + balance_after | Переход | Поведение | | --- | --- | -| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Количество дней округляется вниз. | -| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; после оплаты применяется целевой тариф. | -| `period -> traffic` | Остаток оплаченных дней конвертируется в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. | +| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная RUB-стоимость HWID-докупок добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | +| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная RUB-стоимость HWID-докупок уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | +| `period -> traffic` | Остаток оплаченных дней и неиспользованная RUB-стоимость HWID-докупок конвертируются в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. | | `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | При смене тарифа бот меняет: @@ -262,6 +286,8 @@ limit_after = current_used + balance_after | `tariff_key` | Ключ тарифа, к которому относится платеж. | | `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | | `purchased_hwid_devices` | Количество устройств при докупке HWID. | +| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. | +| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. | | `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. | В callback и metadata платежных провайдеров `sale_mode` может передаваться с суффиксом тарифа, например `subscription@standard` или `topup@standard`. При активации платежа тариф сохраняется отдельно в `tariff_key`. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c19b3c5..a9022de 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1240,6 +1240,7 @@ stripTopupQueryFromUrl(); } } + return payload; } async function loadPublicInstall(shareToken) { diff --git a/frontend/src/lib/admin/tariffDraft.js b/frontend/src/lib/admin/tariffDraft.js index 8c2b0eb..416cb58 100644 --- a/frontend/src/lib/admin/tariffDraft.js +++ b/frontend/src/lib/admin/tariffDraft.js @@ -49,6 +49,8 @@ export function rowsFromPackages(packageSet, currency, valueKey) { return (packageSet?.[currency] || []).map((pkg) => ({ [valueKey]: pkg[valueKey], price: pkg.price, + prices: pkg.prices ? structuredCloneSafe(pkg.prices) : undefined, + min_price: pkg.min_price ?? "", })); } @@ -115,10 +117,20 @@ export function compactMap(obj) { export function packagesFromRows(rows, valueKey) { return (rows || []) - .map((row) => ({ - [valueKey]: parseNumber(row[valueKey]), - price: parseNumber(row.price), - })) + .map((row) => { + const pkg = { + [valueKey]: parseNumber(row[valueKey]), + price: parseNumber(row.price), + }; + if (row.prices && typeof row.prices === "object") { + pkg.prices = structuredCloneSafe(row.prices); + } + const minPrice = parseNumber(row.min_price); + if (minPrice !== null) { + pkg.min_price = minPrice; + } + return pkg; + }) .filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0); } diff --git a/frontend/src/lib/webapp/billingActions.js b/frontend/src/lib/webapp/billingActions.js index a85353b..4e92963 100644 --- a/frontend/src/lib/webapp/billingActions.js +++ b/frontend/src/lib/webapp/billingActions.js @@ -53,7 +53,7 @@ export function createBillingActions({ api }) { months: plan.device_count || plan.months, device_count: plan.device_count || plan.months, tariff_key: plan.tariff_key || fallbackTariffKey, - sale_mode: "hwid_devices", + sale_mode: plan.sale_mode || "hwid_devices", method, }; } diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index 2a014c8..849ebff 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -669,6 +669,10 @@ export function applyPreviewMock(kind) { tariff_key: "standard", tariff_name: "Стандарт", current_limit: 5, + extra_hwid_devices: 2, + extra_hwid_devices_valid_until_text: "01.06.2026 12:00", + renewal_available: false, + renewal_recommended_count: 0, plans: [ { id: "standard:hwid:1", @@ -702,6 +706,8 @@ export function applyPreviewMock(kind) { ...DEV_MOCK.data.subscription, active: true, max_devices: 5, + extra_hwid_devices: 2, + extra_hwid_devices_valid_until_text: "01.06.2026 12:00", }; } else if (mode === "trial") { DEV_MOCK.data.settings.traffic_mode = false; diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index 7728118..324dedc 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -43,9 +43,14 @@ export function createBillingStore({ function isSubscriptionSale(plan) { const saleMode = String(plan?.sale_mode || "subscription").toLowerCase(); - return !["traffic", "traffic_package", "topup", "premium_topup", "hwid_devices"].includes( - saleMode - ); + return ![ + "traffic", + "traffic_package", + "topup", + "premium_topup", + "hwid_devices", + "hwid_devices_renewal", + ].includes(saleMode); } function paymentSuccessContext(s, response = {}) { @@ -53,6 +58,8 @@ export function createBillingStore({ paymentId: response.payment_id || "", initialSubscriptionPayment: !s.paymentStartedWithActiveSubscription && isSubscriptionSale(s.selectedPlan), + renewalSubscriptionPayment: + s.paymentStartedWithActiveSubscription && isSubscriptionSale(s.selectedPlan), }; } @@ -64,7 +71,15 @@ export function createBillingStore({ paymentPollToken += 1; } showToast(t("wa_payment_success", {}, "Payment successful")); - await loadData({ fresh: true }); + const payload = await loadData({ fresh: true }); + if ( + successContext.renewalSubscriptionPayment && + payload?.subscription?.device_topup_renewal_available && + payload?.subscription?.can_topup_devices + ) { + showToast(t("wa_hwid_devices_renewal_prompt")); + openDeviceTopupModal(payload.payment_methods?.[0]?.id || ""); + } if ( successContext.initialSubscriptionPayment && typeof onSubscriptionActivated === "function" @@ -190,6 +205,8 @@ export function createBillingStore({ state.update((s) => ({ ...s, deviceTopupModalOpen: true, + deviceTopupOptions: null, + selectedDeviceTopupPlan: null, selectedMethod: s.selectedMethod || defaultMethod, })); loadDeviceTopupOptions(); diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index fdc0dff..a0cc70d 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -591,6 +591,13 @@ a { margin-top: 0; } +.devices-topup-validity { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + .devices-list { display: grid; gap: 8px; diff --git a/frontend/src/webapp/PaymentDialogs.svelte b/frontend/src/webapp/PaymentDialogs.svelte index ae817ab..0de688b 100644 --- a/frontend/src/webapp/PaymentDialogs.svelte +++ b/frontend/src/webapp/PaymentDialogs.svelte @@ -197,6 +197,16 @@
{subscriptionPurchaseDescription}
{/if} + {#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0} ++ {t("wa_hwid_devices_renewal_notice", { + count: Number(subscription.extra_hwid_devices || 0), + date: subscription.extra_hwid_devices_valid_until_text || "", + })} +
++ {t("wa_hwid_devices_renewal_notice", { + count: Number(subscription.extra_hwid_devices || 0), + date: subscription.extra_hwid_devices_valid_until_text || "", + })} +
++ {t("wa_hwid_devices_renewal_offer", { + count: Number(deviceTopupOptions.renewal_recommended_count || 0), + date: deviceTopupOptions.extra_hwid_devices_valid_until_text || "", + })} +
++ {t("wa_hwid_devices_valid_until", { + count: Number(deviceTopupOptions.extra_hwid_devices || 0), + date: deviceTopupOptions.extra_hwid_devices_valid_until_text, + })} +
++ {t("wa_hwid_devices_valid_until", { + count: Number(subscription.extra_hwid_devices || 0), + date: subscription.extra_hwid_devices_valid_until_text, + })} +
+ {/if} {#if subscription?.active && subscription?.max_devices !== 0 && subscription?.can_topup_devices}