From 0eeabc7b3aab98b2113fd7d68108ec98922faa1e Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 23 May 2026 22:28:05 +0300 Subject: [PATCH] fix: process yookassa hwid device topups --- .../bot/payment_providers/shared/success.py | 7 ++ backend/bot/payment_providers/yookassa.py | 113 ++++++++++++++++-- .../subscription_service_impl/devices.py | 13 ++ locales/en.json | 1 + locales/ru.json | 1 + tests/test_payment_provider_registry.py | 22 ++++ tests/test_yookassa_hwid_webhook.py | 101 ++++++++++++++++ 7 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 tests/test_yookassa_hwid_webhook.py diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index c47c72d..b00ecd3 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -22,6 +22,7 @@ from db.models import Payment, User from .common import Translator, format_human_units, make_translator, sale_mode_base _TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"} +_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices"} def is_traffic_sale_base(sale_base: str) -> bool: @@ -103,6 +104,12 @@ def build_success_message(payload: SuccessMessage) -> str: end_date=end_text, config_link=payload.config_link_text, ) + if base in _HWID_DEVICE_MODES: + return _( + "payment_successful_hwid_devices_full", + count=format_human_units(payload.months), + config_link=payload.config_link_text, + ) if payload.applied_referee_bonus_days and payload.final_end_date: base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text) return _( diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 719ff6d..ab7bfad 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -414,6 +414,51 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [ "77.75.154.128/25", "2a02:5180::/32", ] +HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices"} + + +def _is_hwid_device_sale_base(sale_mode_base: str) -> bool: + return sale_mode_base in HWID_DEVICE_SALE_BASES + + +def _metadata_value_present(value: Optional[Any]) -> bool: + return value is not None and str(value).strip() != "" + + +def _resolve_yookassa_activation_amounts( + *, + sale_mode_base: str, + subscription_months_raw: Optional[Any], + traffic_gb_raw: Optional[Any], + hwid_devices_raw: Optional[Any], +) -> tuple[float, float, int, int, Optional[float]]: + subscription_months = float(subscription_months_raw or 0) + traffic_amount_gb = ( + float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months + ) + hwid_devices_count = ( + int(float(hwid_devices_raw)) + if _metadata_value_present(hwid_devices_raw) + else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0) + ) + + if sale_mode_base == "subscription": + months_for_activation = int(subscription_months) + elif _is_hwid_device_sale_base(sale_mode_base): + months_for_activation = hwid_devices_count + else: + months_for_activation = int(traffic_amount_gb) + + traffic_gb_for_activation = ( + traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None + ) + return ( + subscription_months, + traffic_amount_gb, + hwid_devices_count, + months_for_activation, + traffic_gb_for_activation, + ) async def process_successful_payment( @@ -431,6 +476,7 @@ async def process_successful_payment( user_id_str = metadata.get("user_id") subscription_months_str = metadata.get("subscription_months") traffic_gb_str = metadata.get("traffic_gb") + hwid_devices_str = metadata.get("hwid_devices") sale_mode = metadata.get("sale_mode") or ( "traffic" if settings.traffic_sale_mode else "subscription" ) @@ -443,7 +489,11 @@ async def process_successful_payment( # we will create/ensure a payment record idempotently using provider payment id. if ( not user_id_str - or (not subscription_months_str and not traffic_gb_str) + or not ( + _metadata_value_present(subscription_months_str) + or _metadata_value_present(traffic_gb_str) + or _metadata_value_present(hwid_devices_str) + ) or (not payment_db_id_str and not auto_renew_subscription_id_str) ): logging.error( @@ -454,8 +504,18 @@ async def process_successful_payment( db_user = None try: user_id = int(user_id_str) - subscription_months = float(subscription_months_str or 0) - traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months + ( + subscription_months, + traffic_amount_gb, + hwid_devices_count, + months_for_activation, + traffic_gb_for_activation, + ) = _resolve_yookassa_activation_amounts( + sale_mode_base=sale_mode_base, + subscription_months_raw=subscription_months_str, + traffic_gb_raw=traffic_gb_str, + hwid_devices_raw=hwid_devices_str, + ) payment_db_id = ( int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None ) @@ -473,6 +533,21 @@ async def process_successful_payment( payment_value = float(amount_data.get("value", 0.0)) yk_payment_id_from_hook = payment_info_from_webhook.get("id") + if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0: + logging.error( + "YooKassa HWID payment %s has invalid device count in metadata: %s", + yk_payment_id_from_hook, + metadata, + ) + if payment_db_id is not None: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id, + "failed_metadata_error", + yk_payment_id_from_hook, + ) + return + payment_record = None # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists if payment_db_id is None and auto_renew_subscription_id_str: @@ -617,9 +692,6 @@ async def process_successful_payment( logging.exception("Failed to persist multi-card YooKassa method from webhook") except Exception: logging.exception("Failed to persist YooKassa payment method from webhook") - months_for_activation = ( - int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb) - ) activation_details = await subscription_service.activate_subscription( session, user_id, @@ -629,12 +701,12 @@ async def process_successful_payment( promo_code_id_from_payment=promo_code_id, provider="yookassa", sale_mode=sale_mode, - traffic_gb=traffic_amount_gb - if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} - else None, + traffic_gb=traffic_gb_for_activation, ) - if not activation_details or not activation_details.get("end_date"): + if not activation_details or ( + sale_mode_base == "subscription" and not activation_details.get("end_date") + ): logging.error( f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501 ) @@ -652,7 +724,7 @@ async def process_successful_payment( ) raise Exception(f"DB Error: Could not update payment record {payment_db_id}") - base_subscription_end_date = activation_details["end_date"] + base_subscription_end_date = activation_details.get("end_date") final_end_date_for_user = base_subscription_end_date applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0) @@ -687,6 +759,11 @@ async def process_successful_payment( if not receipt_item_name: if is_traffic_sale_base(sale_mode_base): receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) + elif _is_hwid_device_sale_base(sale_mode_base): + receipt_item_name = _( + "payment_description_hwid_devices", + count=hwid_devices_count, + ) else: receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format( months=int(subscription_months) @@ -716,7 +793,11 @@ async def process_successful_payment( end_date=final_end_date_for_user.strftime("%Y-%m-%d"), ) include_keyboard = False - elif not final_end_date_for_user and not is_traffic_sale_base(sale_mode_base): + elif ( + sale_mode_base == "subscription" + and not final_end_date_for_user + and not is_traffic_sale_base(sale_mode_base) + ): logging.error( f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501 ) @@ -733,7 +814,11 @@ async def process_successful_payment( months=( traffic_label if is_traffic_sale_base(sale_mode_base) - else int(subscription_months) + else ( + hwid_devices_count + if _is_hwid_device_sale_base(sale_mode_base) + else int(subscription_months) + ) ), base_end_date=base_subscription_end_date, final_end_date=final_end_date_for_user, @@ -1272,6 +1357,8 @@ async def _initiate_yk_payment( } if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: yookassa_metadata["traffic_gb"] = str(months) + if sale_base in HWID_DEVICE_SALE_BASES: + yookassa_metadata["hwid_devices"] = str(months) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index 3673685..999e99d 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -57,6 +57,10 @@ class HwidDeviceMixin: ) return { "subscription_id": sub.subscription_id, + "end_date": sub.end_date, + "is_active": True, + "panel_user_uuid": db_user.panel_user_uuid, + "panel_short_uuid": getattr(sub, "panel_subscription_uuid", None), "hwid_device_limit": 0, "extra_hwid_devices": int(sub.extra_hwid_devices or 0), "purchased_hwid_devices": 0, @@ -102,6 +106,10 @@ class HwidDeviceMixin: ) return None + final_subscription_url = updated_panel.get("subscriptionUrl") + final_panel_short_uuid = updated_panel.get( + "shortUuid", getattr(updated_sub, "panel_subscription_uuid", None) + ) await tariff_dal.create_hwid_device_purchase( session, subscription_id=updated_sub.subscription_id, @@ -110,6 +118,11 @@ class HwidDeviceMixin: ) return { "subscription_id": updated_sub.subscription_id, + "end_date": updated_sub.end_date, + "is_active": True, + "panel_user_uuid": db_user.panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, "hwid_device_limit": effective_hwid_limit, "extra_hwid_devices": new_extra_devices, "purchased_hwid_devices": purchased_devices, diff --git a/locales/en.json b/locales/en.json index 4f49d1b..32afdc5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -72,6 +72,7 @@ "payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.", "payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", + "payment_successful_hwid_devices_full": "✅ Payment successful!\nExtra HWID devices added: +{count}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "config_link_not_available": "not available, contact support", diff --git a/locales/ru.json b/locales/ru.json index aa2014d..ecf9e47 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -72,6 +72,7 @@ "payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.", "payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", + "payment_successful_hwid_devices_full": "✅ Оплата прошла успешно!\nДобавлено HWID устройств: +{count}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "config_link_not_available": "недоступна, обратитесь в поддержку", diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 3ca039a..920f151 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -21,6 +21,7 @@ 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 config.settings import Settings _LEGACY_PROVIDER_FILES = [ @@ -291,3 +292,24 @@ def test_common_sale_mode_helpers_cover_provider_payment_records(): assert hwid.purchased_hwid_devices == 3 assert hwid.tariff_key == "vip" assert hwid.hwid_devices_sale + + +def test_yookassa_hwid_webapp_metadata_uses_device_count_for_activation(): + ( + subscription_months, + traffic_amount_gb, + hwid_devices_count, + months_for_activation, + traffic_gb_for_activation, + ) = _resolve_yookassa_activation_amounts( + sale_mode_base="hwid_devices", + subscription_months_raw="0", + traffic_gb_raw=None, + hwid_devices_raw="3", + ) + + assert subscription_months == 0 + assert traffic_amount_gb == 0 + assert hwid_devices_count == 3 + assert months_for_activation == 3 + assert traffic_gb_for_activation is None diff --git a/tests/test_yookassa_hwid_webhook.py b/tests/test_yookassa_hwid_webhook.py new file mode 100644 index 0000000..4609a62 --- /dev/null +++ b/tests/test_yookassa_hwid_webhook.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +from bot.payment_providers import yookassa + + +class _I18n: + def gettext(self, _lang, key, **kwargs): + if key == "payment_successful_hwid_devices_full": + return f"HWID +{kwargs['count']} {kwargs['config_link']}" + if key == "config_link_not_available": + return "n/a" + return key + + +class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase): + 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") + 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, + "purchased_hwid_devices": 2, + } + ) + ) + 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-hwid-1", + "status": "succeeded", + "paid": True, + "amount": {"value": "120.00", "currency": "RUB"}, + "metadata": { + "user_id": "42", + "subscription_months": "0", + "payment_db_id": "5", + "sale_mode": "hwid_devices@standard", + "hwid_devices": "2", + "source": "webapp", + }, + "description": "Extra HWID devices +2", + } + + with ( + 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), + ) as update_status, + 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, + "ensure_user_install_guide_links", + AsyncMock(return_value=SimpleNamespace(public_share_url=None)), + ), + 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, + AsyncMock(), + ) + + activation_args = subscription_service.activate_subscription.await_args.args + activation_kwargs = subscription_service.activate_subscription.await_args.kwargs + assert activation_args[2] == 2 + assert activation_kwargs["sale_mode"] == "hwid_devices@standard" + assert activation_kwargs["traffic_gb"] is None + update_status.assert_awaited_once() + send_success.assert_awaited_once()