fix: process yookassa hwid device topups

This commit is contained in:
3252a8
2026-05-23 22:28:05 +03:00
parent 31eb5c06ad
commit 0eeabc7b3a
7 changed files with 245 additions and 13 deletions
@@ -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 _(
+99 -12
View File
@@ -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 (
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
@@ -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,
+1
View File
@@ -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<code>{config_link}</code>\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<code>{config_link}</code>\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<code>{config_link}</code>\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<code>{config_link}</code>\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",
+1
View File
@@ -72,6 +72,7 @@
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_hwid_devices_full": "✅ Оплата прошла успешно!\nДобавлено HWID устройств: +{count}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"config_link_not_available": "недоступна, обратитесь в поддержку",
+22
View File
@@ -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
+101
View File
@@ -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()