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) : ""} {:else} @@ -261,10 +362,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 || "", })} @@ -310,7 +435,7 @@ disabled={!selectedPlan || !methods.length || payBusy} > {t("wa_pay")} - {selectedPlan ? priceLabel(selectedPlan) : ""} + {selectedPlan ? paymentPriceLabel(selectedPlan) : ""} {/if} diff --git a/frontend/src/webapp/TariffDialogs.svelte b/frontend/src/webapp/TariffDialogs.svelte index bc59390..5443438 100644 --- a/frontend/src/webapp/TariffDialogs.svelte +++ b/frontend/src/webapp/TariffDialogs.svelte @@ -127,6 +127,19 @@ : ""; } + function deviceTopupPlanTitle(plan) { + return t("wa_hwid_devices_package", { + count: Number(plan?.device_count || plan?.months || 0), + }); + } + + function deviceTopupPlanHint(plan) { + if (plan?.valid_until_text) { + return t("wa_hwid_devices_active_until", { date: plan.valid_until_text }); + } + return plan?.subtitle || deviceTopupOptions?.tariff_name || ""; + } + function tariffChangeModalDescription() { if (!changeOptions) return ""; return changeOptions?.current @@ -369,16 +382,7 @@ {#if !deviceTopupOptions} {:else if deviceTopupOptions?.plans?.length} - {#if deviceTopupOptions?.renewal_available} -

-

- {t("wa_hwid_devices_renewal_offer", { - count: Number(deviceTopupOptions.renewal_recommended_count || 0), - date: deviceTopupOptions.extra_hwid_devices_valid_until_text || "", - })} -

-
- {:else if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text} + {#if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && 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)} {priceLabel(plan)} diff --git a/locales/en.json b/locales/en.json index c6e3fb8..bcde007 100644 --- a/locales/en.json +++ b/locales/en.json @@ -948,20 +948,32 @@ "buy_hwid_devices_menu_button": "+ HWID devices", "buy_hwid_devices_button": "+{count} HWID for {price} {currency_symbol}", "select_hwid_device_package": "Select HWID device package:", - "select_hwid_device_renewal_package": "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.", + "select_hwid_device_renewal_package": "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.", "choose_payment_method_hwid_devices": "Choose a payment method for extra HWID devices:", + "payment_hwid_renewal_toggle_on": "✅ Also renew +{count} HWID devices for {price} {currency_symbol}", + "payment_hwid_renewal_toggle_off": "☐ Renew +{count} HWID devices for {price} {currency_symbol}", "no_hwid_device_packages_available": "Extra HWID devices are not configured for this tariff.", "hwid_devices_unlimited_no_topup": "Your device limit is already unlimited.", "payment_description_hwid_devices": "Extra HWID devices +{count}", - "payment_successful_hwid_devices_renewal_note": "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.", - "subscription_hwid_renewal_reminder": "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.", + "payment_successful_hwid_devices_renewal_note": "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.", + "payment_successful_hwid_devices_renewed_note": "Your +{count} extra HWID devices were renewed with the subscription until {date}.", + "subscription_hwid_renewal_reminder": "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.", "wa_buy_hwid_devices": "Buy devices", "wa_device_topup_for_tariff": "Device packages for {tariff}", "wa_hwid_devices_package": "+{count} devices", + "wa_hwid_devices_renewal_package": "Renew +{count} devices", + "wa_hwid_devices_active_until": "Active until {date}", + "wa_hwid_devices_renewal_period": "From {from} to {to}", "wa_hwid_devices_valid_until": "+{count} extra devices are valid until {date}", - "wa_hwid_devices_renewal_notice": "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.", - "wa_hwid_devices_renewal_offer": "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.", - "wa_hwid_devices_renewal_prompt": "Subscription renewed. Buy devices again for the new period.", + "wa_hwid_devices_renewal_notice": "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.", + "wa_hwid_devices_renewal_checkbox": "Also renew +{count} extra devices for {price}", + "wa_hwid_devices_renewal_checkbox_aria": "Renew extra devices with the subscription", + "wa_hwid_devices_renewal_checkbox_hint": "New device period: from {from} to {to}", + "wa_hwid_devices_renewal_checkbox_hint_short": "Devices will be renewed for the new subscription period", + "wa_hwid_devices_desync_notice": "The current device top-up is valid until {date}; before the new period starts, the limit may return to the base tariff limit.", + "wa_hwid_devices_renewal_offer": "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.", + "wa_hwid_devices_renewal_prompt": "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.", + "wa_hwid_devices_renewal_unavailable": "You currently have +{count} extra devices valid until {date}. Device renewal is not available for the selected payment method or tariff.", "wa_no_hwid_device_options": "No device packages available", "wa_device_topup_options_failed": "Could not load device packages", "admin_nav_overview": "Overview", @@ -1392,6 +1404,9 @@ "admin_user_btn_reset_trial": "Reset Trial", "admin_user_label_extend": "Extend Subscription", "admin_user_label_extend_days": "Days", + "admin_user_extend_hwid_devices": "Also extend +{count} HWID devices", + "admin_user_extend_hwid_devices_aria": "Extend purchased HWID devices", + "admin_user_extend_hwid_devices_hint": "Active device top-ups will receive the same number of extra days.", "admin_user_btn_extend": "Extend", "admin_user_label_telegram_msg": "Telegram Message", "admin_user_hint_telegram_msg": "Telegram HTML formatting supported", diff --git a/locales/ru.json b/locales/ru.json index ee6aa30..736a2b7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -948,20 +948,32 @@ "buy_hwid_devices_menu_button": "+ HWID устройства", "buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}", "select_hwid_device_package": "Выберите пакет HWID устройств:", - "select_hwid_device_renewal_package": "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "select_hwid_device_renewal_package": "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", "choose_payment_method_hwid_devices": "Выберите способ оплаты дополнительных HWID устройств:", + "payment_hwid_renewal_toggle_on": "✅ Продлить также +{count} HWID устройств за {price} {currency_symbol}", + "payment_hwid_renewal_toggle_off": "☐ Продлить +{count} HWID устройств за {price} {currency_symbol}", "no_hwid_device_packages_available": "Дополнительные HWID устройства для этого тарифа не настроены.", "hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.", "payment_description_hwid_devices": "Дополнительные HWID устройства +{count}", - "payment_successful_hwid_devices_renewal_note": "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", - "subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "payment_successful_hwid_devices_renewal_note": "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", + "payment_successful_hwid_devices_renewed_note": "Докупленные +{count} HWID устройств продлены вместе с подпиской до {date}.", + "subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", "wa_buy_hwid_devices": "Купить устройства", "wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}", "wa_hwid_devices_package": "+{count} устройств", + "wa_hwid_devices_renewal_package": "Продлить +{count} устройств", + "wa_hwid_devices_active_until": "Действует до {date}", + "wa_hwid_devices_renewal_period": "С {from} до {to}", "wa_hwid_devices_valid_until": "+{count} дополнительных устройств действует до {date}", - "wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", - "wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", - "wa_hwid_devices_renewal_prompt": "Подписка продлена. Докупите устройства для нового срока.", + "wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", + "wa_hwid_devices_renewal_checkbox": "Продлить также +{count} доп. устройств за {price}", + "wa_hwid_devices_renewal_checkbox_aria": "Продлить дополнительные устройства вместе с подпиской", + "wa_hwid_devices_renewal_checkbox_hint": "Новый срок устройств: с {from} до {to}", + "wa_hwid_devices_renewal_checkbox_hint_short": "Устройства будут продлены на новый срок подписки", + "wa_hwid_devices_desync_notice": "Текущая докупка действует до {date}; до начала нового периода лимит может вернуться к базовому.", + "wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", + "wa_hwid_devices_renewal_prompt": "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + "wa_hwid_devices_renewal_unavailable": "Сейчас докуплено +{count} устройств до {date}. Для выбранного способа оплаты или тарифа продление этих устройств недоступно.", "wa_no_hwid_device_options": "Нет доступных пакетов устройств", "wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств", "admin_nav_overview": "Обзор", @@ -1392,6 +1404,9 @@ "admin_user_btn_reset_trial": "Сбросить триал", "admin_user_label_extend": "Продлить подписку", "admin_user_label_extend_days": "Дней", + "admin_user_extend_hwid_devices": "Продлить также +{count} HWID устройств", + "admin_user_extend_hwid_devices_aria": "Продлить докупленные HWID устройства", + "admin_user_extend_hwid_devices_hint": "Срок действующих докупок устройств увеличится на те же дни.", "admin_user_btn_extend": "Продлить", "admin_user_label_telegram_msg": "Сообщение в Telegram", "admin_user_hint_telegram_msg": "Поддерживается HTML-разметка Telegram", diff --git a/tests/test_admin_user_hwid_limit.py b/tests/test_admin_user_hwid_limit.py index d86b13f..30644c2 100644 --- a/tests/test_admin_user_hwid_limit.py +++ b/tests/test_admin_user_hwid_limit.py @@ -1,5 +1,6 @@ import json import unittest +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -118,8 +119,76 @@ class AdminUserHwidLimitRouteTests(unittest.IsolatedAsyncioTestCase): response = await admin_users.admin_user_hwid_device_limit_route(request) self.assertEqual(response.status, 400) - self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit") - subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited() + + +class AdminUserExtendRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_extend_route_can_skip_hwid_device_extension(self): + session = FakeSession() + new_end = datetime(2099, 2, 1, tzinfo=timezone.utc) + subscription_service = SimpleNamespace( + extend_active_subscription_days=AsyncMock(return_value=new_end) + ) + request = FakeRequest( + {"days": 10, "extend_hwid_devices": False}, + session, + subscription_service, + ) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()) as log, + patch.object( + admin_users.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=SimpleNamespace(subscription_id=1)), + ), + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + patch.object(admin_users, "_serialize_subscription", return_value={"ok": True}), + ): + response = await admin_users.admin_user_extend_route(request) + + self.assertEqual(response.status, 200) + subscription_service.extend_active_subscription_days.assert_awaited_once_with( + session, + 42, + 10, + "admin_extend_subscription_webapp", + extend_hwid_devices=False, + ) + self.assertIn("hwid=no", log.await_args.args[1]["content"]) + self.assertTrue(session.committed) + + async def test_extend_route_extends_hwid_devices_by_default(self): + session = FakeSession() + new_end = datetime(2099, 2, 1, tzinfo=timezone.utc) + subscription_service = SimpleNamespace( + extend_active_subscription_days=AsyncMock(return_value=new_end) + ) + request = FakeRequest({"days": 10}, session, subscription_service) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()) as log, + patch.object( + admin_users.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=SimpleNamespace(subscription_id=1)), + ), + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + patch.object(admin_users, "_serialize_subscription", return_value={"ok": True}), + ): + response = await admin_users.admin_user_extend_route(request) + + self.assertEqual(response.status, 200) + subscription_service.extend_active_subscription_days.assert_awaited_once_with( + session, + 42, + 10, + "admin_extend_subscription_webapp", + extend_hwid_devices=True, + ) + self.assertIn("hwid=yes", log.await_args.args[1]["content"]) + self.assertTrue(session.committed) async def test_over_max_limit_is_rejected(self): session = FakeSession() diff --git a/tests/test_auto_renew_wiring.py b/tests/test_auto_renew_wiring.py index 450515f..cc8561e 100644 --- a/tests/test_auto_renew_wiring.py +++ b/tests/test_auto_renew_wiring.py @@ -20,9 +20,10 @@ return. """ import unittest +from datetime import datetime, timezone from types import SimpleNamespace from typing import Any, Dict, List, Optional -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from bot.services.subscription_service_impl.renewal import RenewalMixin @@ -276,6 +277,59 @@ class ChargeRenewalHappyPathTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(yk.calls[0]["metadata"]["subscription_months"], "1") self.assertEqual(yk.calls[0]["amount"], 99.0) + async def test_includes_hwid_device_renewal_in_saved_method_charge(self): + yk = _FakeYooKassaService(response={"id": "auto-pay-8", "status": "pending"}) + mixin = _make_mixin(yk=yk, price_for_months=399.0) + valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc) + valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc) + mixin.quote_hwid_device_renewal_for_subscription = AsyncMock( + return_value={ + "device_count": 2, + "price": 50.0, + "full_price": 50.0, + "valid_from": valid_from, + "valid_until": valid_until, + "pricing_period_months": 1, + "proration_ratio": 1.0, + } + ) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=77, + subscription_id=555, + tariff_key="standard", + duration_months=1, + ), + ) + + self.assertTrue(ok) + self.assertEqual(len(yk.calls), 1) + call = yk.calls[0] + self.assertEqual(call["amount"], 449.0) + meta = call["metadata"] + self.assertEqual(meta["sale_mode"], "subscription@standard") + self.assertEqual(meta["hwid_devices"], "2") + self.assertEqual(meta["hwid_valid_from"], valid_from.isoformat()) + self.assertEqual(meta["hwid_valid_until"], valid_until.isoformat()) + self.assertEqual(meta["hwid_pricing_period_months"], "1") + self.assertEqual(meta["hwid_proration_ratio"], "1.0") + self.assertEqual(meta["hwid_full_price"], "50.0") + mixin.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with( + None, + user_id=77, + target_tariff_key="standard", + months=1, + currency="rub", + ) + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/tests/test_hwid_device_bonus_extension.py b/tests/test_hwid_device_bonus_extension.py new file mode 100644 index 0000000..1dc2d65 --- /dev/null +++ b/tests/test_hwid_device_bonus_extension.py @@ -0,0 +1,72 @@ +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from db.dal import tariff_dal + + +class _ScalarResult: + def __init__(self, records): + self._records = records + + def scalars(self): + return self + + def all(self): + return self._records + + +class HwidDeviceBonusExtensionTests(unittest.IsolatedAsyncioTestCase): + async def test_extends_tail_purchase_when_it_covers_subscription_end(self): + subscription_end = datetime(2099, 2, 1, tzinfo=timezone.utc) + future_purchase = SimpleNamespace( + valid_until=subscription_end, + ) + session = SimpleNamespace( + execute=AsyncMock(return_value=_ScalarResult([future_purchase])), + flush=AsyncMock(), + ) + + updated = await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=10, + at=datetime(2099, 1, 1, tzinfo=timezone.utc), + subscription_end_before=subscription_end, + delta=timedelta(days=7), + ) + + self.assertEqual(updated, 1) + self.assertEqual(future_purchase.valid_until, subscription_end + timedelta(days=7)) + session.flush.assert_awaited_once() + self.assertEqual(session.execute.await_count, 1) + + async def test_extends_active_purchase_when_no_tail_purchase_exists(self): + active_until = datetime(2099, 1, 16, tzinfo=timezone.utc) + active_purchase = SimpleNamespace(valid_until=active_until) + session = SimpleNamespace( + execute=AsyncMock( + side_effect=[ + _ScalarResult([]), + _ScalarResult([active_purchase]), + ] + ), + flush=AsyncMock(), + ) + + updated = await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=10, + at=datetime(2099, 1, 1, tzinfo=timezone.utc), + subscription_end_before=datetime(2099, 2, 1, tzinfo=timezone.utc), + delta=timedelta(days=7), + ) + + self.assertEqual(updated, 1) + self.assertEqual(active_purchase.valid_until, active_until + timedelta(days=7)) + session.flush.assert_awaited_once() + self.assertEqual(session.execute.await_count, 2) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_hwid_device_topup.py b/tests/test_hwid_device_topup.py index e1f67cf..1e2910e 100644 --- a/tests/test_hwid_device_topup.py +++ b/tests/test_hwid_device_topup.py @@ -273,6 +273,115 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(quote["price"], 150) self.assertAlmostEqual(quote["proration_ratio"], 1.0) + async def test_quote_keeps_immediate_and_renewal_windows_separate(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + tmpdir, + _tariffs_config_payload( + hwid_device_packages={ + "rub": [{"count": 1, "price": 50, "prices": {"1": 50}}], + "stars": [], + } + ), + ) + service = _make_service(settings) + sub = _make_sub() + sub.start_date = datetime(2098, 12, 1, tzinfo=timezone.utc) + sub.end_date = datetime(2099, 2, 1, tzinfo=timezone.utc) + user = _make_user() + now = datetime(2099, 1, 2, tzinfo=timezone.utc) + existing_extra_until = datetime(2099, 1, 17, tzinfo=timezone.utc) + + with ( + patch( + "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary", + AsyncMock( + return_value={ + "active_devices": 1, + "active_until": existing_extra_until, + } + ), + ), + ): + immediate = await service.quote_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=1, + tariff_key="standard", + currency="rub", + renewal=False, + now=now, + ) + renewal = await service.quote_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=1, + tariff_key="standard", + currency="rub", + renewal=True, + now=now, + ) + + self.assertIsNotNone(immediate) + self.assertIsNotNone(renewal) + self.assertEqual(immediate["valid_from"], now) + self.assertEqual(immediate["valid_until"], sub.end_date) + self.assertEqual(immediate["price"], 50) + self.assertEqual(renewal["valid_from"], existing_extra_until) + self.assertEqual(renewal["valid_until"], sub.end_date) + self.assertLess(renewal["price"], immediate["price"]) + + async def test_subscription_renewal_quote_prices_current_active_extra_devices(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir, _tariffs_config_payload()) + service = _make_service(settings) + sub = _make_sub() + sub.end_date = datetime(2099, 2, 1, tzinfo=timezone.utc) + user = _make_user() + + with ( + patch( + "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary", + AsyncMock( + return_value={ + "active_devices": 4, + "active_until": datetime(2099, 1, 16, tzinfo=timezone.utc), + } + ), + ), + ): + quote = await service.quote_hwid_device_renewal_for_subscription( + session=AsyncMock(), + user_id=42, + target_tariff_key="standard", + months=1, + currency="rub", + now=datetime(2099, 1, 1, tzinfo=timezone.utc), + ) + + self.assertIsNotNone(quote) + self.assertEqual(quote["device_count"], 4) + self.assertEqual(quote["price"], 170) + self.assertEqual(sorted(quote["package_counts"]), [1, 3]) + self.assertEqual(quote["valid_from"], sub.end_date) + self.assertEqual(quote["valid_until"], datetime(2099, 3, 1, tzinfo=timezone.utc)) + async def test_unlimited_subscriber_returns_noop_payload(self): # hwid_device_limit == 0 means unlimited — top-up makes no sense and must skip. with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_hwid_tariff_switch_conversion.py b/tests/test_hwid_tariff_switch_conversion.py index d3bdb9c..6b7f71d 100644 --- a/tests/test_hwid_tariff_switch_conversion.py +++ b/tests/test_hwid_tariff_switch_conversion.py @@ -185,6 +185,97 @@ class HwidTariffSwitchConversionTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(change_payload["converted_hwid_value_rub"], 50) self.assertEqual(change_payload["converted_hwid_days"], 7) + async def test_paid_switch_records_payment_id_in_single_tariff_change(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _settings(tmpdir) + service = _service(settings) + user = SimpleNamespace( + user_id=42, + telegram_id=42, + panel_user_uuid="panel-user", + email=None, + username="u", + first_name="U", + last_name="L", + ) + sub = SimpleNamespace( + subscription_id=11, + user_id=42, + panel_user_uuid="panel-user", + panel_subscription_uuid="panel-sub", + tariff_key="basic", + start_date=datetime(2099, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2099, 2, 1, tzinfo=timezone.utc), + effective_monthly_price_rub=100, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=0, + topup_balance_bytes=0, + regular_bonus_bytes=0, + regular_unlimited_override=False, + traffic_used_bytes=0, + extra_hwid_devices=0, + hwid_device_limit=3, + ) + updated = SimpleNamespace(**{**sub.__dict__, "tariff_key": "pro"}) + updated.hwid_device_limit = 5 + updated.extra_hwid_devices = 0 + updated.traffic_limit_bytes = 200 * (1024**3) + updated.premium_is_limited = False + updated.effective_monthly_price_rub = 200 + + with ( + patch( + "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch.object( + service, + "calculate_tariff_switch_options_with_hwid", + AsyncMock( + return_value={ + "mode": "period_to_period", + "remaining_days": 20, + "recalc_days": 20, + "paid_diff_rub": 50, + "target_monthly_rub": 200, + "converted_hwid_value_rub": 0, + "converted_hwid_days": 0, + "convertible_hwid_purchase_ids": [], + } + ), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.sum_active_hwid_devices", + AsyncMock(return_value=0), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription", + AsyncMock(return_value=updated), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.create_tariff_change", + AsyncMock(), + ) as create_change, + ): + result = await service.switch_tariff_without_payment( + AsyncMock(), + user_id=42, + target_tariff_key="pro", + mode="paid_diff", + payment_id=99, + ) + + self.assertEqual(result["tariff_key"], "pro") + create_change.assert_awaited_once() + change_payload = create_change.await_args.args[1] + self.assertEqual(change_payload["payment_id"], 99) + self.assertEqual(change_payload["mode"], "paid_diff") + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index b5e002c..fd05e27 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -30,7 +30,10 @@ from bot.payment_providers.shared import ( sale_mode_is_traffic, sale_mode_tariff_key, ) -from bot.payment_providers.yookassa import _resolve_yookassa_activation_amounts +from bot.payment_providers.yookassa import ( + _parse_saved_list_payload, + _resolve_yookassa_activation_amounts, +) from config.settings import Settings _LEGACY_PROVIDER_FILES = [ @@ -201,6 +204,47 @@ def test_provider_presentation_ignores_cross_language_override(): assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "YooKassa" +def test_subscription_hwid_renewal_token_adds_quote_to_callback_parts(): + service = SimpleNamespace( + quote_hwid_device_renewal_for_subscription=AsyncMock( + return_value={ + "device_count": 2, + "price": 50, + "valid_from": "2099-02-01", + "valid_until": "2099-03-01", + } + ) + ) + session = AsyncMock() + + parts, quote = asyncio.run( + quote_hwid_callback_parts( + session=session, + user_id=77, + parts=PaymentCallbackParts( + months=1, + price=100, + sale_mode="subscription@basic|hwid_renewal", + ), + subscription_service=service, + currency="rub", + ) + ) + + assert parts is not None + assert quote is not None + assert parts.months == 1 + assert parts.price == 150 + assert quote["device_count"] == 2 + service.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with( + session, + user_id=77, + target_tariff_key="basic", + months=1, + currency="rub", + ) + + def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch): monkeypatch.setenv("WATA_ENABLED", "True") monkeypatch.setenv("PAYMENT_WATA_TELEGRAM_LABEL_EN", "Wata custom") @@ -502,3 +546,19 @@ def test_yookassa_hwid_metadata_rejects_fractional_device_count(): traffic_gb_raw=None, hwid_devices_raw="1.9", ) + + +def test_yookassa_saved_card_payload_parser_accepts_new_and_legacy_formats(): + assert _parse_saved_list_payload("1:100:0:subscription@vip|hwid_renewal") == ( + 1, + 100, + 0, + "subscription@vip|hwid_renewal", + ) + assert _parse_saved_list_payload("1:100:subscription@vip|hwid_renewal") == ( + 1, + 100, + 0, + "subscription@vip|hwid_renewal", + ) + assert _parse_saved_list_payload("bad:100:subscription") is None diff --git a/tests/test_payment_webhook_notifications.py b/tests/test_payment_webhook_notifications.py new file mode 100644 index 0000000..d0f1067 --- /dev/null +++ b/tests/test_payment_webhook_notifications.py @@ -0,0 +1,87 @@ +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +from bot.payment_providers.shared import webhooks +from bot.payment_providers.shared.success import PaymentSuccessRequest, finalize_successful_payment + + +class _PaymentWithLazyUser: + payment_id = 12 + user_id = 42 + + @property + def user(self): + raise RuntimeError("lazy relationship access is not allowed here") + + +class _I18n: + def gettext(self, _language, key, **_kwargs): + return key + + +class PaymentWebhookNotificationTests(IsolatedAsyncioTestCase): + async def test_failed_payment_notification_loads_user_explicitly(self): + user = SimpleNamespace(user_id=42, language_code="ru", email="u@example.test") + bot = SimpleNamespace(send_message=AsyncMock()) + settings = SimpleNamespace(DEFAULT_LANGUAGE="en", SUBSCRIPTION_MINI_APP_URL="") + + with ( + patch.object( + webhooks.user_dal, + "get_user_by_id", + AsyncMock(return_value=user), + ) as get_user, + patch.object( + webhooks, + "send_user_notification_email", + AsyncMock(), + ) as send_email, + ): + await webhooks.notify_user_payment_failed( + bot=bot, + settings=settings, + i18n=_I18n(), + session=AsyncMock(), + payment=_PaymentWithLazyUser(), + ) + + get_user.assert_awaited_once() + bot.send_message.assert_awaited_once_with(42, "payment_failed") + send_email.assert_awaited_once() + + async def test_finalize_failure_marks_payment_retryable(self): + session = AsyncMock() + payment = SimpleNamespace(payment_id=12, user_id=42, status="succeeded") + subscription_service = SimpleNamespace( + activate_subscription=AsyncMock(side_effect=RuntimeError("panel failed")) + ) + + with patch( + "bot.payment_providers.shared.success.payment_dal.update_payment_status_by_db_id", + AsyncMock(return_value=payment), + ) as update_status: + result = await finalize_successful_payment( + PaymentSuccessRequest( + bot=SimpleNamespace(), + settings=SimpleNamespace(DEFAULT_LANGUAGE="en"), + i18n=_I18n(), + session=session, + subscription_service=subscription_service, + referral_service=SimpleNamespace(), + payment=payment, + user_id=42, + amount=50, + currency="RUB", + sale_mode="hwid_devices@standard", + months=1, + traffic_amount=1, + provider_subscription="platega", + provider_notification="platega", + ) + ) + + self.assertIsNone(result) + session.rollback.assert_awaited_once() + update_status.assert_awaited_once_with(session, 12, "activation_failed") + session.commit.assert_awaited_once() diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index a3eb2a5..5caf06a 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -433,6 +433,103 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas self.assertEqual(kwargs["tariff_key"], "standard") self.assertEqual(kwargs["payment_db_id"], 12) + async def test_activate_subscription_records_hwid_renewal_without_inflating_tariff_price(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("panel-user", "short-uuid", "short", False) + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"subscriptionUrl": "https://panel/sub", "shortUuid": "short"} + ) + service._send_payment_success_email = AsyncMock() + now = datetime.now(timezone.utc) + current_end = now + timedelta(days=20) + current_sub = SimpleNamespace( + subscription_id=10, + end_date=current_end, + tariff_key="standard", + topup_balance_bytes=0, + extra_hwid_devices=1, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=0, + premium_period_start_at=None, + regular_bonus_bytes=0, + regular_unlimited_override=False, + ) + updated_sub = SimpleNamespace(subscription_id=10) + payment = SimpleNamespace( + purchased_hwid_devices=1, + hwid_valid_from=current_end, + hwid_valid_until=current_end + timedelta(days=30), + hwid_full_price=50, + hwid_pricing_period_months=1, + hwid_proration_ratio=1.0, + ) + db_user = SimpleNamespace( + user_id=42, + panel_user_uuid="panel-user", + telegram_id=42, + username="alice", + email=None, + language_code="en", + ) + + with ( + patch( + "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.payment_dal.get_payment_by_db_id", + AsyncMock(return_value=payment), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=current_sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.deactivate_other_active_subscriptions", + AsyncMock(), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.upsert_subscription", + AsyncMock(return_value=updated_sub), + ) as upsert_subscription, + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.get_hwid_device_entitlement_summary", + AsyncMock( + return_value={ + "active_devices": 1, + "active_until": current_end, + } + ), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.create_hwid_device_purchase", + AsyncMock(), + ) as create_hwid_purchase, + ): + result = await service.activate_subscription( + session=AsyncMock(), + user_id=42, + months=1, + payment_amount=150, + payment_db_id=99, + sale_mode="subscription@standard", + ) + + self.assertEqual(result["hwid_devices_renewed_count"], 1) + sub_payload = upsert_subscription.await_args.args[1] + self.assertEqual(sub_payload["effective_monthly_price_rub"], 100) + create_hwid_purchase.assert_awaited_once() + purchase_kwargs = create_hwid_purchase.await_args.kwargs + self.assertEqual(purchase_kwargs["payment_id"], 99) + self.assertEqual(purchase_kwargs["purchased_devices"], 1) + self.assertEqual(purchase_kwargs["valid_from"], current_end) + class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase): async def test_referral_extension_preserves_existing_tariff_limit(self): @@ -479,6 +576,10 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase): "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription", AsyncMock(), ) as update_subscription, + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.extend_hwid_device_purchases_for_subscription_bonus", + AsyncMock(return_value=1), + ) as extend_hwid, ): await service.extend_active_subscription_days( session=AsyncMock(), @@ -488,10 +589,68 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase): ) update_subscription.assert_not_awaited() + extend_hwid.assert_awaited_once() + self.assertEqual(extend_hwid.await_args.kwargs["subscription_id"], 10) + self.assertEqual(extend_hwid.await_args.kwargs["delta"], timedelta(days=3)) payload = service.panel_service.update_user_details_on_panel.await_args.args[1] self.assertNotIn("trafficLimitBytes", payload) self.assertNotIn("trafficLimitStrategy", payload) + async def test_admin_extension_can_skip_hwid_purchase_extension(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + USER_TRAFFIC_LIMIT_GB=999, + ) + service = _make_service(settings) + service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("panel-user", "short-uuid", "short", False) + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"ok": True} + ) + active_sub = SimpleNamespace( + subscription_id=10, + end_date=datetime.now(timezone.utc) + timedelta(days=5), + traffic_limit_bytes=100 * GIB, + tariff_key="standard", + ) + updated_sub = SimpleNamespace( + subscription_id=10, + end_date=active_sub.end_date + timedelta(days=3), + traffic_limit_bytes=100 * GIB, + tariff_key="standard", + ) + + with ( + patch( + "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id", + AsyncMock(return_value=SimpleNamespace(user_id=42)), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=active_sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription_end_date", + AsyncMock(return_value=updated_sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.extend_hwid_device_purchases_for_subscription_bonus", + AsyncMock(return_value=1), + ) as extend_hwid, + ): + await service.extend_active_subscription_days( + session=AsyncMock(), + user_id=42, + bonus_days=3, + reason="admin_extend_subscription_webapp", + extend_hwid_devices=False, + ) + + extend_hwid.assert_not_awaited() + class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase): def _local_active_sub(self) -> SimpleNamespace: diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py index 0d43aad..ff48f77 100644 --- a/tests/test_user_bot_menu.py +++ b/tests/test_user_bot_menu.py @@ -20,9 +20,11 @@ from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_tariff_catalog_keyboard, get_tariff_periods_keyboard, + get_yk_autopay_choice_keyboard, payment_methods_back_callback, payment_options_back_callback, ) +from bot.middlewares.i18n import LOCALE_KEY_ALIASES from config.tariffs_config import TariffsConfig @@ -31,6 +33,7 @@ class JsonI18nStub: self.translations = json.loads(Path("locales/en.json").read_text(encoding="utf-8")) def gettext(self, lang, key, **kwargs): + key = LOCALE_KEY_ALIASES.get(key, key) text = self.translations[key] return text.format(**kwargs) if kwargs else text @@ -248,6 +251,63 @@ class UserBotMenuTests(unittest.TestCase): "subscribe_period:1:bot", ) + def test_payment_navigation_context_ignores_hwid_renewal_token(self): + self.assertEqual( + payment_options_back_callback("subscription@basic|bot|hwid_renewal"), + "tariff:select:basic:bot", + ) + self.assertEqual( + payment_methods_back_callback("1", "subscription@basic|bot|hwid_renewal"), + "tariff:period:basic:1:bot", + ) + + def test_payment_method_keyboard_adds_hwid_renewal_toggle(self): + settings = SimpleNamespace(payment_methods_order=[]) + quote = {"device_count": 2, "price": 50} + + selected = get_payment_method_keyboard( + 1, + 100, + None, + "RUB", + "en", + self.i18n, + settings, + sale_mode="subscription@basic|bot", + hwid_renewal_quote=quote, + hwid_renewal_selected=True, + ) + disabled = get_payment_method_keyboard( + 1, + 100, + None, + "RUB", + "en", + self.i18n, + settings, + sale_mode="subscription@basic|bot", + hwid_renewal_quote=quote, + hwid_renewal_selected=False, + ) + + self.assertIn("tariff:period:basic:1:bot:no_hwid", self._callback_data(selected)) + self.assertIn("tariff:period:basic:1:bot:hwid", self._callback_data(disabled)) + + def test_yookassa_saved_card_choice_keeps_sale_mode_after_page_token(self): + markup = get_yk_autopay_choice_keyboard( + 1, + 100, + "en", + self.i18n, + has_saved_cards=True, + sale_mode="subscription@basic|hwid_renewal", + ) + + self.assertIn( + "pay_yk_saved_list:1:100:0:subscription@basic|hwid_renewal", + self._callback_data(markup), + ) + def test_tariff_back_buttons_return_to_previous_level(self): tariff = SimpleNamespace( key="basic", diff --git a/tests/test_webapp_device_topup_options.py b/tests/test_webapp_device_topup_options.py index 09caeba..46d1f02 100644 --- a/tests/test_webapp_device_topup_options.py +++ b/tests/test_webapp_device_topup_options.py @@ -2,7 +2,7 @@ import json from datetime import datetime, timezone from types import SimpleNamespace from unittest import IsolatedAsyncioTestCase -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch import bot.app.web.subscription_webapp # noqa: F401 from bot.app.web.webapp import billing as billing_module @@ -95,6 +95,101 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase): self.assertEqual(payload["plans"][0]["valid_from"], valid_from.isoformat()) self.assertEqual(payload["plans"][0]["valid_until"], active_until.isoformat()) + async def test_offers_only_immediate_topup_when_existing_extra_expires_early(self): + current_extra_until = datetime(2099, 1, 16, tzinfo=timezone.utc) + subscription_until = datetime(2099, 2, 1, tzinfo=timezone.utc) + tariff = SimpleNamespace( + key="standard", + billing_model="period", + hwid_device_packages=SimpleNamespace( + rub=[SimpleNamespace(count=1)], + stars=[], + ), + name=lambda lang: "Standard", + ) + settings = SimpleNamespace( + MY_DEVICES_SECTION_ENABLED=True, + tariffs_config=SimpleNamespace(require=lambda key: tariff), + DEFAULT_LANGUAGE="en", + DEFAULT_CURRENCY_SYMBOL="RUB", + ) + + async def quote_hwid_device_topup(*args, **kwargs): + if kwargs.get("renewal"): + return { + "price": 25, + "valid_from": current_extra_until, + "valid_until": subscription_until, + "proration_ratio": 0.5, + } + return { + "price": 50, + "valid_from": datetime(2099, 1, 1, tzinfo=timezone.utc), + "valid_until": subscription_until, + "proration_ratio": 1.0, + } + + subscription_service = SimpleNamespace( + get_active_subscription_details=AsyncMock( + return_value={ + "max_devices": 3, + "extra_hwid_devices": 1, + "extra_hwid_devices_valid_until": current_extra_until, + "extra_hwid_devices_valid_until_text": "16.01.2099 00:00", + "device_topup_renewal_available": True, + } + ), + quote_hwid_device_topup=AsyncMock(side_effect=quote_hwid_device_topup), + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(), + "subscription_service": subscription_service, + } + ) + db_user = SimpleNamespace( + is_banned=False, + panel_user_uuid="panel-user", + language_code="en", + ) + sub = SimpleNamespace( + tariff_key="standard", + extra_hwid_devices=1, + ) + + with ( + patch.object(billing_module, "_require_user_id", return_value=42), + patch.object( + billing_module.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch.object( + billing_module.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + ): + response = await billing_module.device_topup_options_route(request) + + self.assertEqual(response.status, 200) + payload = json.loads(response.text) + self.assertEqual([plan["sale_mode"] for plan in payload["plans"]], ["hwid_devices"]) + self.assertEqual([plan["price"] for plan in payload["plans"]], [50]) + self.assertFalse(payload["plans"][0]["renewal"]) + self.assertEqual(payload["plans"][0]["valid_until"], subscription_until.isoformat()) + self.assertEqual(payload["renewal_available"], False) + self.assertEqual(payload["renewal_recommended_count"], 0) + renewal_flags = [ + call.kwargs.get("renewal") + for call in subscription_service.quote_hwid_device_topup.await_args_list + ] + self.assertEqual( + renewal_flags, + [False], + ) + async def test_create_payment_route_quotes_hwid_with_app_subscription_service(self): tariff = SimpleNamespace( key="standard", @@ -197,6 +292,110 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase): subscription_service.quote_hwid_device_topup.assert_awaited_once() create_payment.assert_awaited_once() + async def test_create_payment_route_adds_hwid_renewal_to_subscription_payment(self): + tariff = SimpleNamespace( + key="standard", + billing_model="period", + enabled_periods=[1], + hwid_device_packages=SimpleNamespace( + rub=[SimpleNamespace(count=1)], + stars=[], + ), + period_price=lambda months, currency: 100 if currency == "rub" else None, + ) + settings = SimpleNamespace( + traffic_sale_mode=False, + tariffs_config=SimpleNamespace(require=lambda key: tariff), + DEFAULT_LANGUAGE="en", + DEFAULT_CURRENCY_SYMBOL="RUB", + ADMIN_IDS=[], + ) + hwid_quote = { + "price": 50, + "device_count": 1, + "valid_from": datetime(2099, 1, 1, tzinfo=timezone.utc), + "valid_until": datetime(2099, 2, 1, tzinfo=timezone.utc), + "pricing_period_months": 1, + "proration_ratio": 1.0, + "full_price": 50, + } + subscription_service = SimpleNamespace( + quote_hwid_device_renewal_for_subscription=AsyncMock(return_value=hwid_quote) + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(), + "subscription_service": subscription_service, + } + ) + db_user = SimpleNamespace( + is_banned=False, + panel_user_uuid="panel-user", + language_code="en", + telegram_id=42, + ) + + async def _fake_create_payment(**kwargs): + return billing_module.web.json_response( + { + "ok": True, + "price": kwargs["price"], + "hwid_device_count": kwargs["hwid_quote"]["device_count"], + } + ) + + with ( + patch.object(billing_module, "_require_user_id", return_value=42), + patch.object( + billing_module, + "_enforce_webapp_rate_limit", + AsyncMock(return_value=None), + ), + patch.object( + billing_module, + "_read_json", + AsyncMock( + return_value={ + "method": "yookassa", + "months": 1, + "tariff_key": "standard", + "sale_mode": "subscription", + "renew_hwid_devices": True, + } + ), + ), + patch.object( + billing_module, + "_get_cached_webapp_settings", + return_value={"subscription_options": {}, "stars_subscription_options": {}}, + ), + patch.object( + billing_module.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch.object( + billing_module, + "_create_subscription_payment", + AsyncMock(side_effect=_fake_create_payment), + ) as create_payment, + ): + response = await billing_module.create_payment_route(request) + + self.assertEqual(response.status, 200) + payload = json.loads(response.text) + self.assertEqual(payload["price"], 150) + self.assertEqual(payload["hwid_device_count"], 1) + subscription_service.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with( + ANY, + user_id=42, + target_tariff_key="standard", + months=1, + currency="rub", + ) + create_payment.assert_awaited_once() + async def test_create_payment_route_rejects_fractional_hwid_device_count(self): tariff = SimpleNamespace( key="standard", diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py index ff15414..d2bcebd 100644 --- a/tests/test_webapp_payment_status.py +++ b/tests/test_webapp_payment_status.py @@ -164,7 +164,7 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): ): response = await billing_module.payment_status_route(request) - invalidate_cache.assert_awaited_once_with(settings, 1001) + invalidate_cache.assert_awaited_once_with(settings, 1001, include_devices=True) self.assertEqual(response.status, 200) async def test_wata_pending_payment_refresh_delegates_to_provider_service(self): diff --git a/tests/test_yookassa_hwid_webhook.py b/tests/test_yookassa_hwid_webhook.py index 435e147..fc90ea9 100644 --- a/tests/test_yookassa_hwid_webhook.py +++ b/tests/test_yookassa_hwid_webhook.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from types import SimpleNamespace from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, patch @@ -15,6 +16,81 @@ class _I18n: class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase): + async def test_telegram_subscription_hwid_quote_is_stored_in_yookassa_metadata(self): + valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc) + valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc) + session = AsyncMock() + callback = SimpleNamespace( + from_user=SimpleNamespace(id=42), + message=SimpleNamespace(edit_text=AsyncMock()), + ) + service = SimpleNamespace( + config=SimpleNamespace(DEFAULT_RECEIPT_EMAIL="receipt@example.test"), + create_payment=AsyncMock( + return_value={ + "id": "yk-pay-1", + "status": "pending", + "confirmation_url": "https://pay.example.test/1", + } + ), + ) + hwid_quote = { + "device_count": 2, + "valid_from": valid_from, + "valid_until": valid_until, + "pricing_period_months": 1, + "proration_ratio": 1.0, + "full_price": 50.0, + } + payment = SimpleNamespace(payment_id=123) + + with ( + patch.object( + yookassa.payment_dal, + "create_payment_record", + AsyncMock(return_value=payment), + ) as create_record, + patch.object( + yookassa.payment_dal, + "update_payment_status_by_db_id", + AsyncMock(), + ), + ): + result = await yookassa._initiate_yk_payment( + callback, + settings=SimpleNamespace(), + session=session, + yookassa_service=service, + i18n=_I18n(), + current_lang="en", + get_text=lambda key, **kwargs: key, + user_id=42, + months=1, + price_rub=150, + currency_code_for_yk="RUB", + save_payment_method=False, + back_callback="tariff:period:standard:1", + sale_mode="subscription@standard|hwid_renewal", + hwid_quote=hwid_quote, + ) + + assert result is True + record_payload = create_record.await_args.args[1] + assert record_payload["sale_mode"] == "subscription@standard|hwid_renewal" + assert record_payload["tariff_key"] == "standard" + assert record_payload["purchased_hwid_devices"] == 2 + assert record_payload["hwid_valid_from"] == valid_from + assert record_payload["hwid_valid_until"] == valid_until + metadata = service.create_payment.await_args.kwargs["metadata"] + assert metadata["sale_mode"] == "subscription@standard|hwid_renewal" + assert metadata["hwid_devices"] == "2" + assert metadata["hwid_valid_from"] == valid_from.isoformat() + assert metadata["hwid_valid_until"] == valid_until.isoformat() + assert metadata["hwid_pricing_period_months"] == "1" + assert metadata["hwid_proration_ratio"] == "1.0" + assert metadata["hwid_full_price"] == "50.0" + assert service.create_payment.await_args.kwargs["amount"] == 150 + async def test_webapp_hwid_metadata_activates_device_count_without_end_date(self): payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard") updated_payment = SimpleNamespace(payment_id=5, status="succeeded", tariff_key="standard") @@ -99,3 +175,110 @@ class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase): assert activation_kwargs["traffic_gb"] is None update_status.assert_awaited_once() send_success.assert_awaited_once() + + async def test_auto_renew_hwid_metadata_is_persisted_for_activation(self): + valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc) + valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc) + payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard") + updated_payment = SimpleNamespace(payment_id=5, status="succeeded", tariff_key="standard") + db_user = SimpleNamespace( + user_id=42, + username="alice", + language_code="en", + referred_by_id=None, + ) + subscription_service = SimpleNamespace( + activate_subscription=AsyncMock( + return_value={ + "subscription_id": 11, + "end_date": valid_until, + "hwid_devices_renewed_count": 2, + "hwid_devices_valid_until": valid_until, + } + ) + ) + referral_service = SimpleNamespace( + apply_referral_bonuses_for_payment=AsyncMock(return_value={}) + ) + settings = SimpleNamespace( + traffic_sale_mode=False, + yookassa_autopayments_active=False, + DEFAULT_LANGUAGE="en", + DEFAULT_CURRENCY_SYMBOL="RUB", + LKNPD_RECEIPT_NAME_TRAFFIC="{gb} GB", + LKNPD_RECEIPT_NAME_SUBSCRIPTION="{months} months", + ) + payment_info = { + "id": "yk-auto-hwid-1", + "status": "succeeded", + "paid": True, + "amount": {"value": "449.00", "currency": "RUB"}, + "metadata": { + "user_id": "42", + "subscription_months": "1", + "auto_renew_for_subscription_id": "555", + "sale_mode": "subscription@standard", + "hwid_devices": "2", + "hwid_valid_from": valid_from.isoformat(), + "hwid_valid_until": valid_until.isoformat(), + "hwid_pricing_period_months": "1", + "hwid_proration_ratio": "1.0", + "hwid_full_price": "50.0", + }, + "description": "Auto-renewal for 1 months", + } + + with ( + patch.object( + yookassa.payment_dal, + "get_payment_by_provider_payment_id", + AsyncMock(return_value=None), + ), + patch.object( + yookassa.payment_dal, + "ensure_payment_with_provider_id", + AsyncMock(return_value=payment), + ) as ensure_payment, + patch.object( + yookassa.payment_dal, + "get_payment_by_db_id", + AsyncMock(return_value=payment), + ), + patch.object( + yookassa.payment_dal, + "update_payment_status_by_db_id", + AsyncMock(return_value=updated_payment), + ), + patch.object(yookassa.user_dal, "get_user_by_id", AsyncMock(return_value=db_user)), + patch.object( + yookassa, + "prepare_config_links", + AsyncMock(return_value=("link", "https://example.test/sub")), + ), + patch.object(yookassa, "send_success_message_to_user", AsyncMock()) as send_success, + patch.object(yookassa, "notify_admins_payment_received", AsyncMock()), + ): + await yookassa.process_successful_payment( + AsyncMock(), + AsyncMock(), + payment_info, + _I18n(), + settings, + AsyncMock(), + subscription_service, + referral_service, + ) + + ensure_payment.assert_awaited_once() + ensure_kwargs = ensure_payment.await_args.kwargs + assert ensure_kwargs["sale_mode"] == "subscription@standard" + assert ensure_kwargs["tariff_key"] == "standard" + assert ensure_kwargs["purchased_hwid_devices"] == 2 + assert ensure_kwargs["hwid_valid_from"] == valid_from + assert ensure_kwargs["hwid_valid_until"] == valid_until + assert ensure_kwargs["hwid_pricing_period_months"] == 1 + assert ensure_kwargs["hwid_proration_ratio"] == 1.0 + assert ensure_kwargs["hwid_full_price"] == 50.0 + activation_kwargs = subscription_service.activate_subscription.await_args.kwargs + assert activation_kwargs["sale_mode"] == "subscription@standard" + send_success.assert_awaited_once()