From fbb89793cbb0b970bf6411e6068bd8299758e63e Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 3 Jun 2026 23:51:46 +0300 Subject: [PATCH] fix: separate HWID device renewal flows Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout. Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows. Add admin extension controls, docs, demo data, and regression coverage. --- backend/bot/app/web/admin_api_impl/users.py | 8 +- backend/bot/app/web/webapp/billing.py | 65 ++++-- backend/bot/app/web/webapp/payloads.py | 1 + backend/bot/app/web/webapp/serializers.py | 124 ++++++++++- .../bot/handlers/user/subscription/core.py | 42 +++- .../bot/keyboards/inline/user_keyboards.py | 83 +++++-- backend/bot/payment_providers/base.py | 1 + backend/bot/payment_providers/cryptopay.py | 9 +- backend/bot/payment_providers/platega.py | 1 + .../bot/payment_providers/shared/callbacks.py | 23 ++ .../bot/payment_providers/shared/common.py | 18 +- .../bot/payment_providers/shared/success.py | 71 +++++- .../bot/payment_providers/shared/webhooks.py | 2 +- backend/bot/payment_providers/stars.py | 1 + backend/bot/payment_providers/wata.py | 1 + backend/bot/payment_providers/yookassa.py | 205 +++++++++++++++--- .../subscription_service_impl/devices.py | 142 +++++++++++- .../subscription_service_impl/lifecycle.py | 114 ++++++++-- .../subscription_service_impl/payments.py | 3 +- .../subscription_service_impl/renewal.py | 46 ++++ backend/db/dal/payment_dal.py | 23 ++ backend/db/dal/tariff_dal.py | 59 ++++- docs/features/notifications.md | 2 +- docs/features/tariffs.md | 5 +- frontend/src/App.svelte | 2 + .../src/admin/sections/UserDetailModal.svelte | 35 +++ frontend/src/lib/admin/stores/usersStore.js | 4 +- frontend/src/lib/webapp/billingActions.js | 3 +- frontend/src/lib/webapp/demoDataset.js | 76 ++++--- .../src/lib/webapp/stores/billingStore.js | 19 +- frontend/src/styles/admin.css | 31 +++ frontend/src/styles/webapp.css | 37 ++++ frontend/src/webapp/PaymentDialogs.svelte | 137 +++++++++++- frontend/src/webapp/TariffDialogs.svelte | 32 +-- locales/en.json | 27 ++- locales/ru.json | 27 ++- tests/test_admin_user_hwid_limit.py | 73 ++++++- tests/test_auto_renew_wiring.py | 56 ++++- tests/test_hwid_device_bonus_extension.py | 72 ++++++ tests/test_hwid_device_topup.py | 109 ++++++++++ tests/test_hwid_tariff_switch_conversion.py | 91 ++++++++ tests/test_payment_provider_registry.py | 62 +++++- tests/test_payment_webhook_notifications.py | 87 ++++++++ tests/test_subscription_service_behavior.py | 159 ++++++++++++++ tests/test_user_bot_menu.py | 60 +++++ tests/test_webapp_device_topup_options.py | 201 ++++++++++++++++- tests/test_webapp_payment_status.py | 2 +- tests/test_yookassa_hwid_webhook.py | 183 ++++++++++++++++ 48 files changed, 2410 insertions(+), 224 deletions(-) create mode 100644 tests/test_hwid_device_bonus_extension.py create mode 100644 tests/test_payment_webhook_notifications.py diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index ab33723..c6a061d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1515,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: return _error(400, "invalid_days") if days <= 0: return _error(400, "invalid_days") + extend_hwid_devices = payload.get("extend_hwid_devices") + extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices) subscription_service = request.app.get("subscription_service") if subscription_service is None: @@ -1527,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: target_id, days, "admin_extend_subscription_webapp", + extend_hwid_devices=extend_hwid_devices, ) if not new_end: await session.rollback() @@ -1537,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: { "user_id": actor_id, "event_type": "admin_extend_subscription_webapp", - "content": f"+{days}d -> {new_end.isoformat()}", + "content": ( + f"+{days}d -> {new_end.isoformat()} " + f"(hwid={'yes' if extend_hwid_devices else 'no'})" + ), "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index ef1ec17..f91b8f5 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -121,10 +121,11 @@ async def create_payment_route(request: web.Request) -> web.Response: 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 == "hwid_devices_renewal": + return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal") 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: @@ -318,7 +319,7 @@ async def create_payment_route(request: web.Request) -> web.Response: user_id=user_id, device_count=int(payment_units), tariff_key=sale_tariff_key, - renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", + renewal=False, currency=currency, ) if not hwid_quote: @@ -331,6 +332,25 @@ async def create_payment_route(request: web.Request) -> web.Response: else: price = float(hwid_quote["price"]) stars_price = None + elif _sale_mode_base(sale_mode) == "subscription" and bool( + payment_payload.renew_hwid_devices + ): + currency = "stars" if method == "stars" else default_currency + sale_tariff_key = _sale_mode_tariff_key(sale_mode) + if sale_tariff_key: + hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_tariff_key, + months=int(payment_units), + currency=currency, + ) + if hwid_quote: + if method == "stars": + stars_price = int(stars_price or 0) + int(hwid_quote["price"]) + else: + price = float(price or 0) + 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( @@ -691,7 +711,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response: 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) - renewal_available = bool(active and active.get("device_topup_renewal_available")) extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None extra_hwid_valid_until_text = ( active.get("extra_hwid_devices_valid_until_text") if active else None @@ -713,7 +732,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency=default_currency, ) if count in currency_counts @@ -725,7 +744,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency="stars", ) if count in stars_counts @@ -733,28 +752,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response: ) if not currency_quote and not stars_quote: continue - sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" + quote = currency_quote or stars_quote + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") plan = { - "id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", + "id": f"{tariff.key}:hwid:{count}", "tariff_key": tariff.key, "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, - "sale_mode": sale_mode_for_plan, + "sale_mode": "hwid_devices", + "renewal": False, "months": count, "device_count": count, "price": float(currency_quote.get("price") if currency_quote else 0), "currency": default_currency_code, "title": f"+{count}", "subtitle": tariff.name(lang), - "valid_from": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_from") - ), - "valid_until": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_until") - ), - "proration_ratio": float( - (currency_quote or stars_quote).get("proration_ratio") or 0 - ), + "valid_from": _billing_iso_datetime(valid_from), + "valid_from_text": _billing_datetime_text(valid_from), + "valid_until": _billing_iso_datetime(valid_until), + "valid_until_text": _billing_datetime_text(valid_until), + "proration_ratio": float(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"]) @@ -770,10 +788,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response: else int(sub.extra_hwid_devices or 0), "extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until), "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, - "renewal_available": renewal_available, - "renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) - if active and renewal_available - else 0, + "renewal_available": False, + "renewal_recommended_count": 0, "plans": plans, } ) @@ -939,7 +955,11 @@ async def payment_status_route(request: web.Request) -> web.Response: payment = await _refresh_yookassa_payment_status(request, session, payment) payment = await _refresh_wata_payment_status(request, session, payment) if payment.status == "succeeded": - await invalidate_webapp_user_caches(request.app["settings"], user_id) + await invalidate_webapp_user_caches( + request.app["settings"], + user_id, + include_devices=True, + ) return web.json_response( { "ok": True, @@ -1037,6 +1057,7 @@ async def _create_subscription_payment( description=description, sale_mode=sale_mode, traffic_gb=traffic_gb, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote 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") diff --git a/backend/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py index e03221e..eba2a99 100644 --- a/backend/bot/app/web/webapp/payloads.py +++ b/backend/bot/app/web/webapp/payloads.py @@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel): device_count: Any = None tariff_key: Optional[constr(max_length=128)] = None sale_mode: Optional[constr(max_length=64)] = None + renew_hwid_devices: Optional[bool] = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index de07635..1c3294b 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -69,13 +69,30 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A and settings.TRIAL_DURATION_DAYS > 0 and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + plans_payload = _serialize_plans( + settings, + lang, + subscription_options=cached["subscription_options"], + stars_subscription_options=cached["stars_subscription_options"], + traffic_packages=cached["traffic_packages"], + stars_traffic_packages=cached["stars_traffic_packages"], + ) + await _attach_hwid_renewal_quotes_to_plans( + session, + subscription_service, + user_id=user_id, + settings=settings, + active=active, + local_sub=local_sub, + plans=plans_payload, + ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: await session.commit() except Exception: await session.rollback() - lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) telegram_notifications_status = normalize_telegram_notification_status( @@ -128,14 +145,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A ), "bonus_details": _serialize_referral_bonus_details(settings, lang), }, - "plans": _serialize_plans( - settings, - lang, - subscription_options=cached["subscription_options"], - stars_subscription_options=cached["stars_subscription_options"], - traffic_packages=cached["traffic_packages"], - stars_traffic_packages=cached["stars_traffic_packages"], - ), + "plans": plans_payload, "payment_methods": _serialize_payment_methods( settings, request.app, @@ -438,6 +448,102 @@ def _serialize_subscription( } +def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.isoformat() + return str(value) + + +def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + return str(value) + + +async def _attach_hwid_renewal_quotes_to_plans( + session: AsyncSession, + subscription_service: SubscriptionService, + *, + user_id: int, + settings: Settings, + active: Optional[Dict[str, Any]], + local_sub: Optional[Any], + plans: List[Dict[str, Any]], +) -> None: + quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None) + if not callable(quote_method): + return + if not active or not local_sub or not settings.tariffs_config: + return + if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0: + return + + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + for plan in plans: + if str(plan.get("sale_mode") or "subscription") != "subscription": + continue + target_tariff_key = str(plan.get("tariff_key") or "").strip() + if not target_tariff_key: + continue + try: + months = int(plan.get("months") or 0) + except (TypeError, ValueError): + continue + if months <= 0: + continue + try: + currency_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency=default_currency, + ) + stars_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency="stars", + ) + except Exception: + logger.exception( + "Failed to quote HWID renewal for plan %s/%s", + target_tariff_key, + months, + ) + continue + quote = currency_quote or stars_quote + if not quote: + continue + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") + active_until = quote.get("active_until") + renewal = { + "available": True, + "device_count": int(quote.get("device_count") or 0), + "price": float(currency_quote.get("price") if currency_quote else 0), + "currency": default_currency_code, + "valid_from": _webapp_iso_datetime(valid_from), + "valid_from_text": _webapp_datetime_text(valid_from), + "valid_until": _webapp_iso_datetime(valid_until), + "valid_until_text": _webapp_datetime_text(valid_until), + "active_until": _webapp_iso_datetime(active_until), + "active_until_text": _webapp_datetime_text(active_until), + "pricing_period_months": int(quote.get("pricing_period_months") or months), + } + if stars_quote and int(stars_quote.get("price") or 0) > 0: + renewal["stars_price"] = int(stars_quote["price"]) + plan["hwid_renewal"] = renewal + + def _build_install_share_link( request: Optional[web.Request], settings: Settings, diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index d9b3e2f..3ab73bc 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -319,7 +319,11 @@ async def select_tariff_callback( @router.callback_query(F.data.startswith("tariff:period:")) async def select_tariff_period_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") @@ -333,7 +337,9 @@ async def select_tariff_period_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return tariff_key, months_raw = parts[2], parts[3] - callback_context = parts[4] if len(parts) > 4 else None + callback_tokens = [part for part in parts[4:] if part] + callback_context = "bot" if "bot" in callback_tokens else None + renew_hwid_devices = "no_hwid" not in callback_tokens tariff = config.require(tariff_key) months = int(months_raw) default_currency = default_currency_key_for_settings(settings) @@ -343,6 +349,22 @@ async def select_tariff_period_callback( if price_rub is None: await callback.answer(get_text("error_try_again"), show_alert=True) return + hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency=default_currency, + ) + hwid_renewal_stars_quote = ( + await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency="stars", + ) + ) markup = get_payment_method_keyboard( months, price_rub, @@ -354,6 +376,9 @@ async def select_tariff_period_callback( sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}", user_id=callback.from_user.id, + hwid_renewal_quote=hwid_renewal_quote, + hwid_renewal_stars_quote=hwid_renewal_stars_quote, + hwid_renewal_selected=bool(renew_hwid_devices), ) await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) await callback.answer() @@ -577,7 +602,6 @@ async def hwid_devices_list_callback( 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, @@ -585,14 +609,11 @@ 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" + renewal=False, ) await callback.message.edit_text( get_text( - text_key, + "select_hwid_device_package", date=active.get("extra_hwid_devices_valid_until_text") or "", ), reply_markup=markup, @@ -640,6 +661,7 @@ async def hwid_devices_package_callback( 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" + renewal = action == "renewal_package" default_currency = default_currency_key_for_settings(settings) currency_code = default_payment_currency_code_for_settings(settings) currency_quote = await subscription_service.quote_hwid_device_topup( @@ -647,7 +669,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency=default_currency, ) stars_quote = await subscription_service.quote_hwid_device_topup( @@ -655,7 +677,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency="stars", ) if not currency_quote and not stars_quote: diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index d0a5790..0489ff7 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -14,6 +14,13 @@ from config.tariffs_config import ( ) BOT_MENU_CONTEXT = "bot" +HWID_RENEWAL_TOKEN = "hwid_renewal" + + +def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]: + if not sale_mode or "|" not in sale_mode: + return () + return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip()) def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]: @@ -24,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str: sale_mode = sale_mode or "subscription" - if not context or "|" in sale_mode: + if not context or context in sale_mode_tokens(sale_mode): return sale_mode return f"{sale_mode}|{context}" +def sale_mode_with_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or token in sale_mode_tokens(sale_mode): + return sale_mode + return f"{sale_mode}|{token}" + + +def sale_mode_without_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or "|" not in sale_mode: + return sale_mode + base, *tokens = sale_mode.split("|") + kept = [item for item in tokens if item.strip() and item.strip() != token] + return "|".join([base, *kept]) + + +def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool: + return str(token or "").strip() in sale_mode_tokens(sale_mode) + + def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]: - if not sale_mode or "|" not in sale_mode: - return None - context = str(sale_mode).split("|", 1)[1].strip() - return context or None + tokens = sale_mode_tokens(sale_mode) + return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None def callback_suffix_for_context(context: Optional[str]) -> str: @@ -484,6 +511,9 @@ def get_payment_method_keyboard( back_callback: Optional[str] = None, user_id: Optional[int] = None, is_admin: Optional[bool] = None, + hwid_renewal_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_selected: bool = True, ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -492,12 +522,39 @@ def get_payment_method_keyboard( return str(int(val)) if float(val).is_integer() else f"{val:g}" value_str = _format_value(months) - import logging as _kbd_logging - - _kbd_logging.info( - "payment_method_keyboard build: order=%s", - settings.payment_methods_order, - ) + payment_sale_mode = sale_mode + selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote + if selected_hwid_quote: + tariff_key = None + sale_mode_main = str(sale_mode or "").split("|", 1)[0] + if "@" in sale_mode_main: + tariff_key = sale_mode_main.split("@", 1)[1] + context = callback_context_from_sale_mode(sale_mode) + toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"] + if context: + toggle_tokens.append(context) + toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid") + builder.row( + InlineKeyboardButton( + text=_( + "payment_hwid_renewal_toggle_on" + if hwid_renewal_selected + else "payment_hwid_renewal_toggle_off", + count=int(selected_hwid_quote.get("device_count") or 0), + price=( + hwid_renewal_quote.get("price") + if hwid_renewal_quote + else hwid_renewal_stars_quote.get("price") + ), + currency_symbol=currency_symbol_val, + ), + callback_data=":".join(toggle_tokens), + ) + ) + if hwid_renewal_selected: + payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN) + else: + payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN) from bot.payment_providers import get_provider_spec, provider_telegram_button_text for method in settings.payment_methods_order: @@ -518,7 +575,7 @@ def get_payment_method_keyboard( value=value_str, rub_price=price, stars_price=stars_price, - sale_mode=sale_mode, + sale_mode=payment_sale_mode, ) if not callback_data: continue @@ -577,7 +634,7 @@ def get_yk_autopay_choice_keyboard( builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_saved_card_button"), - callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}", ) ) builder.row( diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index 88d7f4f..9f5cf65 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -114,6 +114,7 @@ class WebAppPaymentContext: sale_mode: str currency: str = "RUB" traffic_gb: Optional[float] = None + hwid_device_count: Optional[int] = None hwid_valid_from: Optional[Any] = None hwid_valid_until: Optional[Any] = None hwid_pricing_period_months: Optional[int] = None diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index 94457f5..4efbd18 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -192,6 +192,7 @@ class CryptoPayService: sale_mode: str = "subscription", url_kind: str = "bot", hwid_quote: Optional[dict] = None, + hwid_device_count: Optional[int] = None, currency: Optional[str] = None, ) -> Optional[str]: if not self.configured or not self.client: @@ -210,7 +211,11 @@ class CryptoPayService: return None sale_base = sale_mode_base(sale_mode) - amounts = payment_record_amounts(months=months, sale_mode=sale_mode) + amounts = payment_record_amounts( + months=months, + sale_mode=sale_mode, + hwid_device_count=hwid_device_count, + ) try: payment_record = await payment_dal.create_payment_record( session, @@ -252,6 +257,7 @@ class CryptoPayService: "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, + "hwid_devices": amounts.purchased_hwid_devices, } ) try: @@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if ctx.hwid_valid_from and ctx.hwid_valid_until else None, + hwid_device_count=ctx.hwid_device_count, ) if not url: return payment_failed() diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 9fb8ab6..e4f15fc 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index 66cc7c7..d646df2 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -8,8 +8,10 @@ from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import ( + HWID_RENEWAL_TOKEN, get_payment_url_keyboard, payment_methods_back_callback, + sale_mode_has_token, ) from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal @@ -123,6 +125,27 @@ async def quote_hwid_callback_parts( subscription_service, currency: str = "rub", ) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: + base = sale_mode_base(parts.sale_mode) + if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN): + try: + months = int(parts.months) + except (TypeError, ValueError): + return None, None + quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_mode_tariff_key(parts.sale_mode), + months=months, + currency=currency, + ) + if not quote: + return parts, None + quoted_parts = PaymentCallbackParts( + months=months, + price=float(parts.price or 0) + float(quote.get("price") or 0), + sale_mode=parts.sale_mode, + ) + return quoted_parts, quote if not sale_mode_is_hwid_devices(parts.sale_mode): return parts, None device_count = parse_positive_int_units(parts.months) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 53ac7b9..19680f8 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -100,6 +100,11 @@ 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) + hwid_devices = int(float(months)) if is_hwid else None + if hwid_quote: + quote_devices = parse_positive_int_units(hwid_quote.get("device_count")) + if quote_devices is not None: + hwid_devices = quote_devices payload = { "user_id": user_id, "amount": amount, @@ -111,9 +116,9 @@ def build_payment_record_payload( "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), "purchased_gb": float(months) if is_traffic else None, - "purchased_hwid_devices": int(float(months)) if is_hwid else None, + "purchased_hwid_devices": hwid_devices, } - if hwid_quote and is_hwid: + if hwid_quote and hwid_devices is not None: payload.update( { "hwid_valid_from": hwid_quote.get("valid_from"), @@ -164,14 +169,20 @@ def payment_record_amounts( months: Any, sale_mode: str, traffic_gb: Optional[float] = None, + hwid_device_count: Optional[int] = None, ) -> PaymentRecordAmounts: traffic_sale = sale_mode_is_traffic(sale_mode) hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode) units = traffic_gb if traffic_sale and traffic_gb is not None else months + purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None + if not hwid_devices_sale and hwid_device_count is not None: + parsed_hwid_devices = parse_positive_int_units(hwid_device_count) + if parsed_hwid_devices is not None: + purchased_hwid_devices = parsed_hwid_devices return PaymentRecordAmounts( months=int(float(units)) if traffic_sale else int(float(months)), purchased_gb=float(units) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + purchased_hwid_devices=purchased_hwid_devices, tariff_key=sale_mode_tariff_key(sale_mode), traffic_sale=traffic_sale, hwid_devices_sale=hwid_devices_sale, @@ -281,6 +292,7 @@ async def create_webapp_payment_record( months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) return await create_base_payment_record( ctx.session, diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index 26e342a..317c399 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -156,6 +156,28 @@ def append_hwid_renewal_note( return f"{text}\n\n{note}" +def append_hwid_renewed_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_renewed_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, @@ -320,8 +342,37 @@ async def finalize_successful_payment( req.log_prefix, req.payment.payment_id, ) + try: + await payment_dal.update_payment_status_by_db_id( + req.session, + req.payment.payment_id, + "activation_failed", + ) + await req.session.commit() + except Exception: + await req.session.rollback() + logging.exception( + "%s: failed to mark payment %s activation_failed.", + req.log_prefix, + req.payment.payment_id, + ) return None + try: + from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches + + await invalidate_webapp_user_caches( + req.settings, + req.user_id, + include_devices=True, + ) + except Exception: + logging.exception( + "%s: failed to invalidate webapp caches for user %s.", + req.log_prefix, + req.user_id, + ) + db_user, language = await resolve_user_language( req.session, user_id=req.user_id, @@ -363,12 +414,20 @@ async def finalize_successful_payment( ) ) 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 activation.get("hwid_devices_renewed_count"): + success_text = append_hwid_renewed_note( + success_text, + translator, + count=activation.get("hwid_devices_renewed_count"), + valid_until=final_end_date or activation.get("hwid_devices_renewed_until"), + ) + else: + 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/shared/webhooks.py b/backend/bot/payment_providers/shared/webhooks.py index 474df6b..204614e 100644 --- a/backend/bot/payment_providers/shared/webhooks.py +++ b/backend/bot/payment_providers/shared/webhooks.py @@ -51,7 +51,7 @@ async def notify_user_payment_failed( message_key: str = "payment_failed", ) -> None: """Send the localized ``payment_failed`` text to the user; never raises.""" - db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + db_user = await user_dal.get_user_by_id(session, payment.user_id) language = ( db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE ) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index 0a69ad1..70c7064 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 422db61..3d0109e 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) months_for_lookup = ( reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 3c420fa..65f79b9 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool: return value is not None and str(value).strip() != "" +def _metadata_int(value: Optional[Any]) -> Optional[int]: + if not _metadata_value_present(value): + return None + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return None + + +def _metadata_float(value: Optional[Any]) -> Optional[float]: + if not _metadata_value_present(value): + return None + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]: + if not _metadata_value_present(value): + return None + try: + parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + def _resolve_yookassa_activation_amounts( *, sale_mode_base: str, @@ -559,6 +589,11 @@ async def process_successful_payment( months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 payment_value = float(amount_data.get("value", 0.0)) yk_payment_id_from_hook = payment_info_from_webhook.get("id") + hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from")) + hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until")) + hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months")) + hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio")) + hwid_full_price = _metadata_float(metadata.get("hwid_full_price")) if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0: logging.error( @@ -574,6 +609,19 @@ async def process_successful_payment( yk_payment_id_from_hook, ) return + if sale_mode_base == "subscription" and hwid_devices_count > 0: + if ( + not hwid_valid_from + or not hwid_valid_until + or hwid_valid_from >= hwid_valid_until + or hwid_full_price is None + ): + logging.error( + "YooKassa subscription+HWID payment %s has invalid HWID metadata: %s", + yk_payment_id_from_hook, + metadata, + ) + return payment_record = None # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists @@ -600,6 +648,16 @@ async def process_successful_payment( or f"Auto-renewal for {months_for_record or subscription_months} months", provider="yookassa", provider_payment_id=yk_payment_id_from_hook, + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_hwid_devices=( + hwid_devices_count if hwid_devices_count > 0 else None + ), + 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, ) payment_db_id = payment_record.payment_id except Exception as e_ensure: @@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: return None +def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]: + parts = payload.split(":") + if len(parts) < 2: + return None + try: + months = float(parts[0]) + price = float(parts[1]) + except (ValueError, IndexError): + return None + + page = 0 + sale_mode = "subscription" + if len(parts) > 2: + try: + page = int(parts[2]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" + except ValueError: + sale_mode = parts[2] + return months, price, page, sale_mode + + +def _metadata_iso(value: Any) -> Optional[str]: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + text = str(value).strip() + return text or None + + def _format_saved_payment_method_title( get_text, network: Optional[str], last4: Optional[str], is_default: bool ) -> str: @@ -1363,6 +1451,9 @@ async def _initiate_yk_payment( return False sale_base = _sale_mode_base(sale_mode) + hwid_device_count = None + if hwid_quote: + hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count")) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} @@ -1379,12 +1470,14 @@ async def _initiate_yk_payment( "status": "pending_yookassa", "description": payment_description, "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "sale_mode": sale_base, + "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, - "purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None, + "purchased_hwid_devices": ( + int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count + ), "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") @@ -1430,6 +1523,19 @@ async def _initiate_yk_payment( yookassa_metadata["traffic_gb"] = str(months) if sale_base in HWID_DEVICE_SALE_BASES: yookassa_metadata["hwid_devices"] = str(months) + elif hwid_device_count: + yookassa_metadata["hwid_devices"] = str(hwid_device_count) + if hwid_quote and hwid_device_count: + hwid_metadata = { + "hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")), + "hwid_valid_until": _metadata_iso(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"), + } + yookassa_metadata.update( + {key: str(value) for key, value in hwid_metadata.items() if value is not None} + ) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id @@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler( 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=default_currency_key_for_settings(settings), - ) - 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 = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler( pass return + 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=default_currency_key_for_settings(settings), + ) + 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 + await _initiate_yk_payment( callback, settings=settings, @@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler( return months, price_rub, sale_mode = parsed + hwid_quote = None + 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=default_currency_key_for_settings(settings), + ) + 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 = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1889,6 +2011,7 @@ async def pay_yk_new_card_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() @@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler( pass return - parts = data_payload.split(":") - if len(parts) < 2: + parsed_saved_list = _parse_saved_list_payload(data_payload) + if not parsed_saved_list: logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") try: await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return - - try: - months = float(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - sale_mode = parts[3] if len(parts) > 3 else "subscription" - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return + months, price_rub, page, sale_mode = parsed_saved_list autopay_enabled = bool( settings.yookassa_autopayments_active @@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler( method_identifier = parts[2] user_id = callback.from_user.id + base_months = months + base_price_rub = price_rub + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=user_id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + 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 try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler( price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=False, - back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", + back_callback=( + f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}" + ), payment_method_id=selected_method.provider_payment_method_id, selected_method_internal_id=selected_method.method_id, sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, @@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if amounts.traffic_sale: metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) - if amounts.hwid_devices_sale: - metadata["hwid_devices"] = str(int(float(ctx.months))) + if amounts.purchased_hwid_devices: + metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices)) if amounts.tariff_key: metadata["tariff_key"] = amounts.tariff_key response = await service.create_payment( diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index f19ea3f..917e137 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -116,6 +116,64 @@ class HwidDeviceMixin: packages = package_set.for_currency(currency) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) + @staticmethod + def _quote_hwid_full_period_package_price( + tariff: Tariff, + *, + device_count: int, + period_months: int, + currency: str, + ) -> Optional[Dict[str, Any]]: + package_set = tariff.hwid_device_packages + if not package_set: + return None + try: + target_count = int(device_count) + months = max(1, int(period_months)) + except (TypeError, ValueError): + return None + if target_count <= 0: + return None + + packages = [ + package + for package in package_set.for_currency(currency) + if int(getattr(package, "count", 0) or 0) > 0 + ] + if not packages: + return None + + best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])} + for count in range(1, target_count + 1): + best_for_count: Optional[tuple[float, List[Any]]] = None + for package in packages: + package_count = int(package.count) + previous = best.get(count - package_count) + if previous is None: + continue + price = previous[0] + float(package.price_for_period(months)) + selected = [*previous[1], package] + if best_for_count is None or price < best_for_count[0]: + best_for_count = (price, selected) + if best_for_count is not None: + best[count] = best_for_count + + resolved = best.get(target_count) + if resolved is None: + return None + full_price, selected_packages = resolved + rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency) + if currency == "stars": + rounded_price = float(int(math.ceil(rounded_price))) + return { + "price": rounded_price, + "full_price": float(full_price), + "pricing_period_months": months, + "proration_ratio": 1.0, + "currency": currency, + "package_counts": [int(package.count) for package in selected_packages], + } + def _quote_hwid_package_price( self, *, @@ -128,16 +186,10 @@ class HwidDeviceMixin: ) -> 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 - inferred_period_start = add_months(period_end, -period_months) - if not period_start or period_start >= period_end or period_start < inferred_period_start: - period_start = inferred_period_start - - basis_seconds = max(1.0, (period_end - period_start).total_seconds()) + basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60)) billable_start = max(now, valid_from) billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) - ratio = billable_seconds / basis_seconds + ratio = min(1.0, 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) @@ -230,6 +282,80 @@ class HwidDeviceMixin: ) return quote + async def quote_hwid_device_renewal_for_subscription( + self, + session: AsyncSession, + *, + user_id: int, + target_tariff_key: str, + months: int, + currency: str = "rub", + now: Optional[datetime] = None, + ) -> Optional[Dict[str, Any]]: + try: + period_months = int(months) + except (TypeError, ValueError): + return None + if period_months <= 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 or not sub.end_date: + return None + + now = now or datetime.now(timezone.utc) + subscription_end = self._as_aware_utc(sub.end_date) + if not subscription_end or subscription_end <= now: + return None + + try: + tariff = self._resolve_tariff(target_tariff_key) + except Exception: + return None + if not tariff or tariff.billing_model != "period": + return None + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + if base_hwid_limit in (None, 0): + return None + + entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + at=now, + ) + active_devices = int(entitlement_summary.get("active_devices") or 0) + if active_devices <= 0: + return None + + price_quote = self._quote_hwid_full_period_package_price( + tariff, + device_count=active_devices, + period_months=period_months, + currency=currency, + ) + if not price_quote: + return None + + valid_from = subscription_end + valid_until = add_months(valid_from, period_months) + price_quote.update( + { + "subscription_id": sub.subscription_id, + "tariff_key": tariff.key, + "device_count": active_devices, + "renewal": True, + "valid_from": valid_from, + "valid_until": valid_until, + "active_until": entitlement_summary.get("active_until"), + } + ) + return price_quote + async def activate_hwid_device_topup( self, session: AsyncSession, diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index ff34104..40c62ab 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin: user_id: int, target_tariff_key: str, mode: str, + payment_id: Optional[int] = None, ) -> Optional[Dict[str, Any]]: config = self._tariffs_config() if not config: @@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin: "from_tariff_key": before_tariff_key, "to_tariff_key": target.key, "mode": mode, - "payment_id": None, + "payment_id": payment_id, "days_before": options.get("remaining_days"), "days_after": (updated.end_date - now).days if updated.end_date and target.billing_model == "period" @@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin: user_id, tariff_key, "paid_diff", + payment_id=payment_db_id, ) if result: sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) if sub: - await tariff_dal.create_tariff_change( - session, - { - "subscription_id": sub.subscription_id, - "from_tariff_key": None, - "to_tariff_key": tariff_key, - "mode": "paid_diff", - "payment_id": payment_db_id, - "days_before": None, - "days_after": (sub.end_date - datetime.now(timezone.utc)).days - if sub.end_date - else None, - "converted_bytes": None, - "eff_price_before": None, - "eff_price_after": sub.effective_monthly_price_rub, - }, - ) result["end_date"] = sub.end_date result["is_active"] = sub.is_active db_user = await user_dal.get_user_by_id(session, user_id) @@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin: await self._record_payment_context( session, payment_db_id, - sale_mode=sale_mode_base, + sale_mode=sale_mode, tariff_key=tariff.key if tariff else tariff_key, purchased_gb=None, ) + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + try: + hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0) + except (TypeError, ValueError): + hwid_renewal_devices = 0 + try: + hwid_renewal_price = ( + float(getattr(payment, "hwid_full_price", 0) or 0) + if hwid_renewal_devices > 0 + else 0.0 + ) + except (TypeError, ValueError): + hwid_renewal_price = 0.0 + hwid_renewal_valid_from = self._as_aware_utc( + getattr(payment, "hwid_valid_from", None) if payment else None + ) + hwid_renewal_valid_until = self._as_aware_utc( + getattr(payment, "hwid_valid_until", None) if payment else None + ) db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin: promo_code_id_from_payment = None final_end_date = start_date + timedelta(days=duration_days_total) + if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days: + hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta( + days=applied_promo_bonus_days + ) + if payment: + payment.hwid_valid_until = hwid_renewal_valid_until + elif applied_promo_bonus_days > 0 and current_active_sub: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=current_active_sub.subscription_id, + at=datetime.now(timezone.utc), + subscription_end_before=start_date, + delta=timedelta(days=applied_promo_bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for promo payment bonus of user %s", + user_id, + ) await subscription_dal.deactivate_other_active_subscriptions( session, panel_user_uuid, panel_sub_link_id ) @@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin: premium_topup_balance_bytes, premium_topup_used_bytes, ) - effective_monthly_price = float(payment_amount) / max(1, months_int) + subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price) + effective_monthly_price = subscription_amount_for_pricing / max(1, months_int) regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) traffic_limit_bytes = self._traffic_limit_for_period_tariff( @@ -698,6 +723,31 @@ class SubscriptionLifecycleMixin: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + hwid_devices_renewed_count = 0 + hwid_devices_renewed_until = None + if hwid_renewal_devices > 0: + if ( + hwid_renewal_valid_from + and hwid_renewal_valid_until + and hwid_renewal_valid_from < hwid_renewal_valid_until + ): + await tariff_dal.create_hwid_device_purchase( + session, + subscription_id=new_or_updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_devices=hwid_renewal_devices, + valid_from=hwid_renewal_valid_from, + valid_until=hwid_renewal_valid_until, + ) + hwid_devices_renewed_count = hwid_renewal_devices + hwid_devices_renewed_until = hwid_renewal_valid_until + else: + logging.warning( + "Skipping HWID renewal purchase for payment %s: invalid window %s -> %s", + payment_db_id, + hwid_renewal_valid_from, + hwid_renewal_valid_until, + ) await self._send_payment_success_email( db_user=db_user, @@ -718,8 +768,12 @@ 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, + "hwid_devices_renewal_recommended_count": 0 + if hwid_devices_renewed_count + else extra_hwid_devices, + "hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until, + "hwid_devices_renewed_count": hwid_devices_renewed_count, + "hwid_devices_renewed_until": hwid_devices_renewed_until, } async def extend_active_subscription_days( @@ -728,6 +782,7 @@ class SubscriptionLifecycleMixin: user_id: int, bonus_days: int, reason: str = "bonus", + extend_hwid_devices: bool = True, ) -> Optional[datetime]: reason_lower = (reason or "").lower() apply_main_traffic_limit = any( @@ -798,6 +853,21 @@ class SubscriptionLifecycleMixin: updated_sub_model = await subscription_dal.update_subscription_end_date( session, active_sub.subscription_id, new_end_date_obj ) + if updated_sub_model and extend_hwid_devices: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=active_sub.subscription_id, + at=now_utc, + subscription_end_before=current_end_date, + delta=timedelta(days=bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for %s bonus of user %s", + reason, + user_id, + ) if ( apply_main_traffic_limit diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 012c4c4..3562405 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -38,7 +38,8 @@ class PaymentContextMixin: payment.sale_mode = sale_mode payment.tariff_key = tariff_key payment.purchased_gb = purchased_gb - payment.purchased_hwid_devices = purchased_hwid_devices + if purchased_hwid_devices is not None: + 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: diff --git a/backend/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py index f58e8eb..ed9596d 100644 --- a/backend/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -42,6 +42,8 @@ class RenewalMixin: months = sub.duration_months or 1 currency = default_payment_currency_code_for_settings(self.settings) + tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None + sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription" amount = None tariffs_config = ( self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None @@ -62,11 +64,55 @@ class RenewalMixin: logging.error(f"Auto-renew price missing for {months} months") return False + hwid_quote = None + quote_hwid_renewal = getattr( + self, + "quote_hwid_device_renewal_for_subscription", + None, + ) + if tariff_key and callable(quote_hwid_renewal): + try: + hwid_quote = await quote_hwid_renewal( + session, + user_id=sub.user_id, + target_tariff_key=tariff_key, + months=int(months), + currency=default_currency_key_for_settings(self.settings), + ) + except Exception: + logging.exception( + "Failed to quote HWID devices for auto-renew user %s", + sub.user_id, + ) + hwid_quote = None + if hwid_quote: + amount = float(amount) + float(hwid_quote.get("price") or 0) + metadata = { "user_id": str(sub.user_id), "auto_renew_for_subscription_id": str(sub.subscription_id), "subscription_months": str(months), + "sale_mode": sale_mode, } + if hwid_quote: + metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0)) + for source_key, metadata_key in ( + ("valid_from", "hwid_valid_from"), + ("valid_until", "hwid_valid_until"), + ): + value = hwid_quote.get(source_key) + if value: + metadata[metadata_key] = ( + value.isoformat() if hasattr(value, "isoformat") else str(value) + ) + for key in ( + "pricing_period_months", + "proration_ratio", + "full_price", + ): + value = hwid_quote.get(key) + if value is not None: + metadata[f"hwid_{key}"] = str(value) resp = await yk.create_payment( amount=float(amount), currency=currency, diff --git a/backend/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py index eec2f4e..f47338a 100644 --- a/backend/db/dal/payment_dal.py +++ b/backend/db/dal/payment_dal.py @@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id( description: str, provider: str, provider_payment_id: str, + sale_mode: Optional[str] = None, + 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: """Idempotently create a payment record for a provider event. @@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id( "provider_payment_id": provider_payment_id, "provider": provider, } + optional_fields = { + "sale_mode": sale_mode, + "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, + } + payment_payload.update( + {field: value for field, value in optional_fields.items() if value is not None} + ) return await create_payment_record(session, payment_payload) diff --git a/backend/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py index 0d2c6de..1cd1036 100644 --- a/backend/db/dal/tariff_dal.py +++ b/backend/db/dal/tariff_dal.py @@ -1,5 +1,5 @@ import inspect -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import and_, delete, func, or_, select, update @@ -189,6 +189,63 @@ async def expire_hwid_device_purchases( return result.rowcount or 0 +def _normalize_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +async def extend_hwid_device_purchases_for_subscription_bonus( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, + subscription_end_before: Optional[datetime] = None, + delta: timedelta, +) -> int: + if delta.total_seconds() <= 0: + return 0 + at = _normalize_aware_utc(at or datetime.now(timezone.utc)) + end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None + + target_records: List[HwidDevicePurchase] = [] + if end_before: + tail_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + HwidDevicePurchase.valid_until.is_not(None), + HwidDevicePurchase.valid_until >= end_before, + HwidDevicePurchase.valid_until > at, + or_( + HwidDevicePurchase.valid_from.is_(None), + HwidDevicePurchase.valid_from < end_before, + ), + ) + ) + ) + target_records = list(tail_result.scalars().all()) + + if not target_records: + active_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + *_hwid_active_conditions(subscription_id, at), + HwidDevicePurchase.valid_until.is_not(None), + ) + ) + ) + target_records = list(active_result.scalars().all()) + + for record in target_records: + if record.valid_until is not None: + record.valid_until = _normalize_aware_utc(record.valid_until) + delta + if target_records: + await session.flush() + return len(target_records) + + async def create_tariff_change( session: AsyncSession, change_data: Dict[str, Any], diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 76864fe..8450d4e 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -18,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email. | Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. | -| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. | +| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. | | Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. | | Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. | diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md index 0bd0c50..dd335f8 100644 --- a/docs/features/tariffs.md +++ b/docs/features/tariffs.md @@ -280,7 +280,10 @@ limit_after = current_used + balance_after - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; -- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; +- кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия; +- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки; +- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту; +- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - история докупок пишется в `hwid_device_purchases`; diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 13aaa80..9457e17 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1520,6 +1520,7 @@ selectedPlan: null, selectedTariffKey: "", paymentStep: "tariff", + renewHwidDevices: true, selectedMethod: payload.payment_methods?.[0]?.id || "", })); const currentQuery = currentSearchParams(); @@ -2421,6 +2422,7 @@ bind:paymentStep={$billingStore.paymentStep} bind:selectedMethod={$billingStore.selectedMethod} bind:selectedPlan={$billingStore.selectedPlan} + bind:renewHwidDevices={$billingStore.renewHwidDevices} bind:selectedTariffKey={$billingStore.selectedTariffKey} bind:setPasswordCode={$accountStore.setPasswordCode} bind:setPasswordConfirm={$accountStore.setPasswordConfirm} diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index 1cc4a8a..1bfac32 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -759,6 +759,41 @@ {at("user_btn_extend", {}, "Продлить")} + {#if Number(openedUserDetail?.active_subscription?.extra_hwid_devices || 0) > 0} + + {/if} diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 1442c49..716e1e8 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -21,6 +21,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userDetailLoading: false, userMessageDraft: "", userExtendDays: 30, + userExtendHwidDevices: true, userActionBusy: false, userDeleteOpen: false, userBanConfirmOpen: false, @@ -133,6 +134,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userMessageDraft: "", userMessageConfirmOpen: false, userExtendDays: 30, + userExtendHwidDevices: true, userDetailLoading: true, userDetailTab: "subscription", userReferralsOpen: false, @@ -455,7 +457,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { try { const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, { method: "POST", - body: JSON.stringify({ days }), + body: JSON.stringify({ days, extend_hwid_devices: Boolean(s.userExtendHwidDevices) }), }); if (res?.ok) { onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`)); diff --git a/frontend/src/lib/webapp/billingActions.js b/frontend/src/lib/webapp/billingActions.js index 4e92963..751f952 100644 --- a/frontend/src/lib/webapp/billingActions.js +++ b/frontend/src/lib/webapp/billingActions.js @@ -27,13 +27,14 @@ export function createBillingActions({ api }) { return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) }); } - function planPaymentBody(plan, method) { + function planPaymentBody(plan, method, options = {}) { return { months: plan.months, traffic_gb: plan.traffic_gb, device_count: plan.device_count, tariff_key: plan.tariff_key, sale_mode: plan.sale_mode, + renew_hwid_devices: Boolean(options.renewHwidDevices), method, }; } diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index c667237..734167f 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -127478,22 +127478,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + base: "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", fallback: - "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", effective: - "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.", + base: "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.", fallback: - "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", effective: - "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.", + "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.", override: "", overridden: false, updated_at: null, @@ -127506,22 +127506,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + base: "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", fallback: - "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", effective: - "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.", + base: "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.", fallback: - "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", effective: - "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.", + "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.", override: "", overridden: false, updated_at: null, @@ -127534,18 +127534,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Подписка продлена. Докупите устройства для нового срока.", - fallback: "Подписка продлена. Докупите устройства для нового срока.", - effective: "Подписка продлена. Докупите устройства для нового срока.", + base: "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + fallback: + "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + effective: + "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Subscription renewed. Buy devices again for the new period.", - fallback: "Подписка продлена. Докупите устройства для нового срока.", - effective: "Subscription renewed. Buy devices again for the new period.", + base: "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.", + fallback: + "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + effective: + "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.", override: "", overridden: false, updated_at: null, @@ -134134,22 +134138,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", fallback: - "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", effective: - "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.", + base: "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.", fallback: - "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", effective: - "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.", + "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.", override: "", overridden: false, updated_at: null, @@ -135835,22 +135839,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + base: "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", fallback: - "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", effective: - "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.", + base: "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.", fallback: - "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", effective: - "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.", + "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.", override: "", overridden: false, updated_at: null, @@ -139676,22 +139680,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + base: "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", fallback: - "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", effective: - "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.", + base: "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.", fallback: - "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", effective: - "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.", + "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.", override: "", overridden: false, updated_at: null, diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index 27df0e3..4f5ca65 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -18,6 +18,7 @@ export function createBillingStore({ selectedTariffKey: "", selectedPlan: null, selectedMethod: "", + renewHwidDevices: true, paymentStartedWithActiveSubscription: false, topupModalOpen: false, topupKind: "regular", @@ -73,15 +74,7 @@ export function createBillingStore({ paymentPollToken += 1; } showToast(t("wa_payment_success", {}, "Payment successful")); - 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 || ""); - } + await loadData({ fresh: true }); if ( successContext.initialSubscriptionPayment && typeof onSubscriptionActivated === "function" @@ -163,6 +156,7 @@ export function createBillingStore({ selectedTariffKey: tariffKey, selectedPlan: plan, selectedMethod: s.selectedMethod || defaultMethod, + renewHwidDevices: true, paymentStartedWithActiveSubscription: Boolean(subscription?.active), }; }); @@ -179,6 +173,7 @@ export function createBillingStore({ ...s, selectedTariffKey: key, selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null, + renewHwidDevices: true, })); } @@ -189,6 +184,7 @@ export function createBillingStore({ ...s, selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null, paymentStep: "checkout", + renewHwidDevices: true, }; }); } @@ -355,7 +351,10 @@ export function createBillingStore({ state.update((s) => ({ ...s, payBusy: true })); try { const response = await billing.postPayment( - billing.planPaymentBody(s.selectedPlan, s.selectedMethod) + billing.planPaymentBody(s.selectedPlan, s.selectedMethod, { + renewHwidDevices: + s.renewHwidDevices && Boolean(s.selectedPlan?.hwid_renewal?.available), + }) ); const successContext = paymentSuccessContext(s, response); rememberSubscriptionActivationPending(successContext); diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index d2ae0ca..22ae20e 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -4288,6 +4288,37 @@ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); } +.admin-extend-hwid-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + color: var(--admin-text); + cursor: pointer; +} + +.admin-extend-hwid-option > span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.admin-extend-hwid-option strong { + font-size: 13px; + font-weight: 600; +} + +.admin-extend-hwid-option small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.35; +} + .admin-input-row { display: grid; grid-template-columns: minmax(72px, 1fr) auto; diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 92054ae..aed67b7 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -1189,6 +1189,43 @@ a { overflow-wrap: anywhere; } +.hwid-renewal-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: flex-start; + padding: 11px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 8%, var(--surface-muted)); + box-shadow: inset 0 1px 0 var(--inset-highlight); + cursor: pointer; +} + +.hwid-renewal-option > span { + display: grid; + min-width: 0; + gap: 4px; +} + +.hwid-renewal-option strong { + color: var(--text); + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.hwid-renewal-option small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.hwid-renewal-option .hwid-renewal-warning { + color: var(--warning-text, var(--warning)); +} + .skeleton-row, .skeleton-method, .skeleton-pay-button { diff --git a/frontend/src/webapp/PaymentDialogs.svelte b/frontend/src/webapp/PaymentDialogs.svelte index d38cb2c..8b1e700 100644 --- a/frontend/src/webapp/PaymentDialogs.svelte +++ b/frontend/src/webapp/PaymentDialogs.svelte @@ -10,6 +10,7 @@ import { Tooltip } from "$components/ui/primitives.js"; import Button from "$components/ui/button.svelte"; + import Checkbox from "$components/ui/checkbox.svelte"; import Dialog from "$components/ui/dialog.svelte"; import EmailCodeScreen from "./auth/EmailCodeScreen.svelte"; import Input from "$components/ui/input.svelte"; @@ -52,6 +53,7 @@ export let selectedTariff = null; export let selectedTariffKey = ""; export let selectedTariffPlans = []; + export let renewHwidDevices = true; export let setPasswordBusy = false; export let setPasswordCode = ""; export let setPasswordConfirm = ""; @@ -72,6 +74,81 @@ function priceLabel(plan) { return priceLabelFn(plan, selectedMethod); } + function methodUsesStars() { + return String(selectedMethod || "") + .toLowerCase() + .includes("stars"); + } + function hwidRenewalFor(plan) { + return plan?.hwid_renewal?.available ? plan.hwid_renewal : null; + } + function isSubscriptionPlan(plan) { + const saleMode = String(plan?.sale_mode || "subscription").toLowerCase(); + return saleMode === "subscription"; + } + function hwidRenewalAvailableForMethod(plan) { + const renewal = hwidRenewalFor(plan); + if (!subscription?.active || !isSubscriptionPlan(plan) || !renewal) return false; + if (methodUsesStars()) return Number(renewal.stars_price || 0) > 0; + return Number(renewal.price || 0) > 0; + } + function planWithSelectedHwidRenewal(plan) { + if (!plan || !renewHwidDevices || !hwidRenewalAvailableForMethod(plan)) return plan; + const renewal = hwidRenewalFor(plan); + const withRenewal = { + ...plan, + price: Number(plan.price || 0) + Number(renewal.price || 0), + }; + if (Number(plan.stars_price || 0) > 0 && Number(renewal.stars_price || 0) > 0) { + withRenewal.stars_price = Number(plan.stars_price || 0) + Number(renewal.stars_price || 0); + } + return withRenewal; + } + function paymentPriceLabel(plan) { + return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod); + } + function hwidRenewalPriceLabel(plan = selectedPlan) { + const renewal = hwidRenewalFor(plan); + if (!renewal) return ""; + return priceLabelFn( + { + price: renewal.price || 0, + stars_price: renewal.stars_price, + currency: renewal.currency || plan?.currency, + }, + selectedMethod + ); + } + function showHwidRenewalBlock() { + return hwidRenewalAvailableForMethod(selectedPlan); + } + function showHwidRenewalUnavailableNote() { + return Boolean( + subscription?.active && + Number(subscription?.extra_hwid_devices || 0) > 0 && + isSubscriptionPlan(selectedPlan) && + !showHwidRenewalBlock() + ); + } + function hwidRenewalCount(plan = selectedPlan) { + return Number(hwidRenewalFor(plan)?.device_count || subscription?.extra_hwid_devices || 0); + } + function hwidRenewalHint(plan = selectedPlan) { + const renewal = hwidRenewalFor(plan); + if (renewal?.valid_from_text && renewal?.valid_until_text) { + return t("wa_hwid_devices_renewal_checkbox_hint", { + from: renewal.valid_from_text, + to: renewal.valid_until_text, + }); + } + return t("wa_hwid_devices_renewal_checkbox_hint_short"); + } + function showHwidDesyncNotice() { + return Boolean( + subscription?.device_topup_renewal_available && + subscription?.extra_hwid_devices_valid_until_text + ); + } function planKey(plan) { return planKeyFn(plan); } @@ -197,10 +274,34 @@
{subscriptionPurchaseDescription}
{/if} - {#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0} + {#if showHwidRenewalBlock()} + + {:else if showHwidRenewalUnavailableNote()}
- {t("wa_hwid_devices_renewal_notice", {
+ {t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
@@ -243,7 +344,7 @@
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
- {selectedPlan ? priceLabel(selectedPlan) : ""}
+ {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
{subscriptionPurchaseDescription}
- {t("wa_hwid_devices_renewal_notice", {
+ {t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
@@ -310,7 +435,7 @@
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
- {selectedPlan ? priceLabel(selectedPlan) : ""}
+ {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
- {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", { @@ -397,12 +401,8 @@ onclick={() => (selectedDeviceTopupPlan = plan)} > - {t("wa_hwid_devices_package", { - count: Number(plan.device_count || plan.months || 0), - })} - {plan.subtitle || deviceTopupOptions.tariff_name} + {deviceTopupPlanTitle(plan)} + {deviceTopupPlanHint(plan)}