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()