diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 2bbf27b..07e3629 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -27,6 +27,19 @@ def _billing_datetime_text(value: Optional[Any]) -> Optional[str]: return text +def _parse_positive_int_units(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if not number.is_integer(): + return None + integer = int(number) + return integer if integer > 0 else None + + async def apply_promo_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) payload = await _read_json(request) @@ -110,15 +123,12 @@ async def create_payment_route(request: web.Request) -> web.Response: return _json_error(400, "invalid_plan", "Tariff is not available") if tariff.billing_model != "period": return _json_error(400, "invalid_plan", "Device top-up is not available") - try: - device_count = int( - float( - payment_payload.device_count - if payment_payload.device_count is not None - else payment_payload.months - ) - ) - except (TypeError, ValueError): + device_count = _parse_positive_int_units( + payment_payload.device_count + if payment_payload.device_count is not None + else payment_payload.months + ) + if device_count is None: return _json_error(400, "invalid_plan", "Invalid device package") if not tariff.hwid_device_packages: return _json_error(400, "invalid_plan", "Device package is not available") diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index ac4a3ce..0aa5e62 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -270,6 +270,14 @@ class CryptoPayService: referral_service: ReferralService = app["referral_service"] async with async_session_factory() as session: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error("CryptoPay webhook: payment %s not found.", payment_db_id) + return + if payment.status == "succeeded": + logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id) + return + try: await payment_dal.update_provider_payment_and_status( session, diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 00af132..5bdaf4b 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -49,6 +49,7 @@ from .shared import ( parse_payment_callback, payment_failed, payment_unavailable, + payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, @@ -382,10 +383,10 @@ class FreeKassaService(HttpClientMixin): ) return web.Response(status=500, text="processing_error") - months = payment.purchased_gb or payment.subscription_duration_months or 1 sale_mode = payment.sale_mode or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) + months = payment_units_for_activation(payment, sale_mode) success_prefix: Optional[str] = None if provider_payment_id: diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 5ebc66f..55b6fd7 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -49,6 +49,7 @@ from .shared import ( parse_payment_callback, payment_failed, payment_unavailable, + payment_units_for_activation, quote_hwid_callback_parts, render_link_or_fail, ) @@ -469,10 +470,10 @@ class HeleketService(HttpClientMixin): ) return web.Response(status=500, text="processing_error") - payment_units = payment.purchased_gb or payment.subscription_duration_months or 1 sale_mode = payment.sale_mode or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) + payment_units = payment_units_for_activation(payment, sale_mode) outcome = await finalize_successful_payment( PaymentSuccessRequest( diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 674cc2b..bb74dc1 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -45,6 +45,7 @@ from .shared import ( payment_failed, payment_record_amounts, payment_unavailable, + payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, @@ -303,10 +304,10 @@ class PlategaService(HttpClientMixin): if payment.status == "succeeded" and status == "CONFIRMED": return web.Response(text="ok") - payment_months = payment.purchased_gb or payment.subscription_duration_months or 1 sale_mode = payment.sale_mode or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) + payment_months = payment_units_for_activation(payment, sale_mode) if status == "CONFIRMED": if amount_raw is not None: diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 7ec7ce5..0844fb2 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -46,6 +46,7 @@ from .shared import ( parse_payment_callback, payment_failed, payment_unavailable, + payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, @@ -270,12 +271,19 @@ class SeverPayService(HttpClientMixin): return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) resolved_provider_id = provider_payment_id or str(payment.payment_id) - payment_months = payment.purchased_gb or payment.subscription_duration_months or 1 sale_mode = payment.sale_mode or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) + payment_months = payment_units_for_activation(payment, sale_mode) if status == "success": + if payment.status == "succeeded": + logging.info( + "SeverPay webhook: payment %s already succeeded.", + payment.payment_id, + ) + return web.json_response({"status": True}) + try: await payment_dal.update_provider_payment_and_status( session, diff --git a/backend/bot/payment_providers/shared/__init__.py b/backend/bot/payment_providers/shared/__init__.py index c74247b..e8b4e82 100644 --- a/backend/bot/payment_providers/shared/__init__.py +++ b/backend/bot/payment_providers/shared/__init__.py @@ -36,10 +36,12 @@ from .common import ( json_error, make_translator, mark_payment_failed_creation, + parse_positive_int_units, payment_failed, payment_link_response, payment_record_amounts, payment_unavailable, + payment_units_for_activation, sale_mode_base, sale_mode_is_hwid_devices, sale_mode_is_traffic, @@ -103,6 +105,7 @@ __all__ = [ "lookup_payment_by_order_or_provider_id", "make_translator", "mark_payment_failed_creation", + "parse_positive_int_units", "notify_admins_payment_received", "notify_callback_parse_error", "notify_payment_gateway_failure", @@ -114,6 +117,7 @@ __all__ = [ "payment_link_message_text", "payment_link_response", "payment_record_amounts", + "payment_units_for_activation", "payment_unavailable", "post_json_request", "quote_hwid_callback_parts", diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index 4a8afb1..66cc7c7 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -20,6 +20,7 @@ from .common import ( build_payment_description, format_human_units, mark_payment_failed_creation, + parse_positive_int_units, sale_mode_base, sale_mode_is_hwid_devices, sale_mode_tariff_key, @@ -124,10 +125,13 @@ async def quote_hwid_callback_parts( ) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: if not sale_mode_is_hwid_devices(parts.sale_mode): return parts, None + device_count = parse_positive_int_units(parts.months) + if device_count is None: + return None, None quote = await subscription_service.quote_hwid_device_topup( session, user_id=user_id, - device_count=int(parts.months), + device_count=device_count, tariff_key=sale_mode_tariff_key(parts.sale_mode), renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal", currency=currency, @@ -135,7 +139,7 @@ async def quote_hwid_callback_parts( if not quote: return None, None quoted_parts = PaymentCallbackParts( - months=parts.months, + months=device_count, price=float(quote.get("price") or 0), sale_mode=parts.sale_mode, ) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index afc09e6..45ee4f8 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from decimal import ROUND_HALF_UP, Decimal +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from typing import Any, Callable, Optional from aiohttp import web @@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool: return format_decimal_amount(left, places) == format_decimal_amount(right, places) +def parse_positive_int_units(value: Any) -> Optional[int]: + """Return a positive integer only when the input represents whole units exactly.""" + if isinstance(value, bool): + return None + try: + decimal_value = Decimal(str(value).strip()) + except (InvalidOperation, ValueError): + return None + if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value(): + return None + integer_value = int(decimal_value) + return integer_value if integer_value > 0 else None + + def format_human_units(value: Any) -> str: """Render numeric units the way the UI expects: integers w/o decimals, floats with %g.""" numeric = float(value) @@ -164,6 +178,20 @@ def payment_record_amounts( ) +def payment_units_for_activation(payment: Any, sale_mode: str) -> Any: + """Resolve purchased units from a payment record for webhook activation.""" + base = sale_mode_base(sale_mode) + if sale_mode_is_traffic(base): + return getattr(payment, "purchased_gb", None) or getattr( + payment, "subscription_duration_months", None + ) or 1 + if sale_mode_is_hwid_devices(base): + return getattr(payment, "purchased_hwid_devices", None) or getattr( + payment, "subscription_duration_months", None + ) or 1 + return getattr(payment, "subscription_duration_months", None) or 1 + + def json_error(status: int, code: str, message: str) -> web.Response: return web.json_response({"ok": False, "error": code, "message": message}, status=status) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index aba76fb..743e7e7 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -143,6 +143,14 @@ class StarsService: i18n_data: dict, sale_mode: str = "subscription", ) -> None: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error("Stars: payment %s not found.", payment_db_id) + return + if payment.status == "succeeded": + logging.info("Stars: payment %s already succeeded.", payment_db_id) + return + try: payment_record = await payment_dal.update_provider_payment_and_status( session, @@ -162,9 +170,6 @@ class StarsService: else int(message.from_user.id) ) payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) - if not payment: - logging.error("Stars: payment %s vanished after status update.", payment_db_id) - return await finalize_successful_payment( PaymentSuccessRequest( diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 65875f1..7846cb6 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -52,6 +52,7 @@ from .shared import ( payment_link_response, payment_record_amounts, payment_unavailable, + payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, @@ -531,10 +532,10 @@ class WataService(HttpClientMixin): ) return None - payment_units = payment.purchased_gb or payment.subscription_duration_months or 1 sale_mode = payment.sale_mode or ( "traffic" if self.settings.traffic_sale_mode else "subscription" ) + payment_units = payment_units_for_activation(payment, sale_mode) outcome = await finalize_successful_payment( PaymentSuccessRequest( bot=self.bot, diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 2d8fddd..1fff5cc 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -63,6 +63,7 @@ from .shared import ( make_translator, mark_payment_failed_creation, notify_admins_payment_received, + parse_positive_int_units, payment_failed, payment_link_response, payment_record_amounts, @@ -444,11 +445,17 @@ def _resolve_yookassa_activation_amounts( 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) - ) + hwid_devices_count = 0 + if _metadata_value_present(hwid_devices_raw): + parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw) + if parsed_hwid_devices is None: + raise ValueError("Invalid HWID device count") + hwid_devices_count = parsed_hwid_devices + elif _is_hwid_device_sale_base(sale_mode_base): + parsed_hwid_devices = parse_positive_int_units(subscription_months_raw) + if parsed_hwid_devices is None: + raise ValueError("Invalid HWID device count") + hwid_devices_count = parsed_hwid_devices if sale_mode_base == "subscription": months_for_activation = int(subscription_months) diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 6ea37e4..85465b0 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -1,6 +1,10 @@ +import asyncio import importlib from pathlib import Path from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard from bot.payment_providers import ( @@ -16,8 +20,11 @@ from bot.payment_providers import ( resolve_provider_presentation, ) from bot.payment_providers.shared import ( + PaymentCallbackParts, format_number_for_payload, payment_record_amounts, + payment_units_for_activation, + quote_hwid_callback_parts, sale_mode_base, sale_mode_is_hwid_devices, sale_mode_is_traffic, @@ -375,6 +382,13 @@ def test_common_sale_mode_helpers_cover_provider_payment_records(): assert hwid.tariff_key == "vip" assert hwid.hwid_devices_sale + payment = SimpleNamespace( + purchased_gb=None, + purchased_hwid_devices=3, + subscription_duration_months=None, + ) + assert payment_units_for_activation(payment, "hwid_devices@vip") == 3 + def test_yookassa_hwid_webapp_metadata_uses_device_count_for_activation(): ( @@ -395,3 +409,34 @@ def test_yookassa_hwid_webapp_metadata_uses_device_count_for_activation(): assert hwid_devices_count == 3 assert months_for_activation == 3 assert traffic_gb_for_activation is None + + +def test_hwid_callback_quote_rejects_fractional_device_count(): + subscription_service = SimpleNamespace(quote_hwid_device_topup=AsyncMock()) + + quoted_parts, quote = asyncio.run( + quote_hwid_callback_parts( + session=AsyncMock(), + user_id=42, + parts=PaymentCallbackParts( + months=1.9, + price=50, + sale_mode="hwid_devices@vip", + ), + subscription_service=subscription_service, + ) + ) + + assert quoted_parts is None + assert quote is None + subscription_service.quote_hwid_device_topup.assert_not_awaited() + + +def test_yookassa_hwid_metadata_rejects_fractional_device_count(): + with pytest.raises(ValueError): + _resolve_yookassa_activation_amounts( + sale_mode_base="hwid_devices", + subscription_months_raw="0", + traffic_gb_raw=None, + hwid_devices_raw="1.9", + ) diff --git a/tests/test_payment_webhook_idempotency.py b/tests/test_payment_webhook_idempotency.py new file mode 100644 index 0000000..a7b94bc --- /dev/null +++ b/tests/test_payment_webhook_idempotency.py @@ -0,0 +1,179 @@ +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from bot.payment_providers import cryptopay, severpay, stars + + +class _FakeSession: + def __call__(self): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + pass + + async def rollback(self): + pass + + +class _FakeJsonRequest: + def __init__(self, payload): + self._payload = payload + + async def json(self): + return self._payload + + +def test_cryptopay_duplicate_success_webhook_does_not_finalize_again(monkeypatch): + session = _FakeSession() + payment = SimpleNamespace(payment_id=77, status="succeeded") + update = SimpleNamespace( + payload=SimpleNamespace( + payload=json.dumps( + { + "user_id": "42", + "subscription_months": "0", + "payment_db_id": "77", + "sale_mode": "hwid_devices@standard", + } + ), + invoice_id=9001, + amount=100, + asset="USDT", + ) + ) + app = { + "async_session_factory": session, + "bot": SimpleNamespace(), + "settings": SimpleNamespace(traffic_sale_mode=False, DEFAULT_CURRENCY_SYMBOL="RUB"), + "i18n": SimpleNamespace(), + "subscription_service": SimpleNamespace(), + "referral_service": SimpleNamespace(), + } + + monkeypatch.setattr( + cryptopay.payment_dal, + "get_payment_by_db_id", + AsyncMock(return_value=payment), + ) + monkeypatch.setattr( + cryptopay.payment_dal, + "update_provider_payment_and_status", + AsyncMock(side_effect=AssertionError("duplicate webhook must not update payment")), + ) + monkeypatch.setattr( + cryptopay, + "finalize_successful_payment", + AsyncMock(side_effect=AssertionError("duplicate webhook must not finalize")), + ) + + service = SimpleNamespace(settings=SimpleNamespace(traffic_sale_mode=False)) + asyncio.run(cryptopay.CryptoPayService._invoice_paid_handler(service, update, app)) + + +def test_severpay_duplicate_success_webhook_does_not_finalize_again(monkeypatch): + session = _FakeSession() + payment = SimpleNamespace( + payment_id=88, + user_id=42, + status="succeeded", + sale_mode="hwid_devices@standard", + purchased_hwid_devices=3, + purchased_gb=None, + subscription_duration_months=None, + amount=150.0, + currency="RUB", + user=None, + ) + + async def lookup_payment(_session, *, order_id_raw=None, provider_payment_id=None): + assert _session is session + assert order_id_raw == "88" + assert provider_payment_id == "sev-1" + return payment + + monkeypatch.setattr(severpay, "lookup_payment_by_order_or_provider_id", lookup_payment) + monkeypatch.setattr( + severpay.payment_dal, + "update_provider_payment_and_status", + AsyncMock(side_effect=AssertionError("duplicate webhook must not update payment")), + ) + monkeypatch.setattr( + severpay, + "finalize_successful_payment", + AsyncMock(side_effect=AssertionError("duplicate webhook must not finalize")), + ) + + service = SimpleNamespace( + configured=True, + _validate_signature=lambda _payload: True, + async_session_factory=session, + settings=SimpleNamespace(traffic_sale_mode=False), + bot=SimpleNamespace(), + i18n=SimpleNamespace(), + subscription_service=SimpleNamespace(), + referral_service=SimpleNamespace(), + ) + response = asyncio.run( + severpay.SeverPayService.webhook_route( + service, + _FakeJsonRequest( + { + "type": "payin", + "data": { + "id": "sev-1", + "order_id": "88", + "status": "success", + }, + } + ), + ) + ) + + assert response.status == 200 + + +def test_stars_duplicate_success_message_does_not_finalize_again(monkeypatch): + session = _FakeSession() + payment = SimpleNamespace(payment_id=99, status="succeeded") + message = SimpleNamespace( + successful_payment=SimpleNamespace(provider_payment_charge_id="stars-charge-1"), + from_user=SimpleNamespace(id=42), + ) + + monkeypatch.setattr( + stars.payment_dal, + "get_payment_by_db_id", + AsyncMock(return_value=payment), + ) + monkeypatch.setattr( + stars.payment_dal, + "update_provider_payment_and_status", + AsyncMock(side_effect=AssertionError("duplicate stars payment must not update")), + ) + monkeypatch.setattr( + stars, + "finalize_successful_payment", + AsyncMock(side_effect=AssertionError("duplicate stars payment must not finalize")), + ) + + service = SimpleNamespace() + asyncio.run( + stars.StarsService.process_successful_payment( + service, + session=session, + message=message, + payment_db_id=99, + months=3, + stars_amount=150, + i18n_data={}, + sale_mode="hwid_devices@standard", + ) + ) diff --git a/tests/test_wata_webhook.py b/tests/test_wata_webhook.py index 98f32f5..b1f885f 100644 --- a/tests/test_wata_webhook.py +++ b/tests/test_wata_webhook.py @@ -46,6 +46,7 @@ def _payment(**overrides): "amount": 100.0, "provider_payment_id": "link-id", "purchased_gb": None, + "purchased_hwid_devices": None, "subscription_duration_months": 1, "sale_mode": "subscription", "user": None, @@ -274,6 +275,64 @@ def test_wata_refresh_finds_paid_transaction_by_order_id_and_finalizes(monkeypat assert session.commits == 1 +def test_wata_hwid_payment_finalizes_purchased_device_count(monkeypatch): + session = _FakeSession() + payment = _payment( + provider="wata", + provider_payment_id="link-id", + sale_mode="hwid_devices@standard", + subscription_duration_months=None, + purchased_hwid_devices=3, + ) + finalized = [] + service = _service(session) + + async def search_transactions(*, order_id=None, payment_link_id=None, status=None, limit=5): + return True, { + "items": [ + { + "id": "tx-paid", + "status": "Paid", + "orderId": "465", + "amount": 100, + "currency": "RUB", + "paymentLinkId": "link-id", + } + ] + } + + async def get_payment_by_db_id(_session, payment_id): + assert payment_id == 465 + return payment + + async def update_provider_payment_and_status( + _session, + payment_id, + provider_payment_id, + status, + ): + payment.provider_payment_id = provider_payment_id + payment.status = status + + async def finalize_successful_payment(request): + finalized.append((request.months, request.traffic_amount, request.sale_mode)) + return SimpleNamespace() + + service.search_transactions = search_transactions + monkeypatch.setattr(wata.payment_dal, "get_payment_by_db_id", get_payment_by_db_id) + monkeypatch.setattr( + wata.payment_dal, + "update_provider_payment_and_status", + update_provider_payment_and_status, + ) + monkeypatch.setattr(wata, "finalize_successful_payment", finalize_successful_payment) + + result = asyncio.run(service.refresh_payment_status(session, payment)) + + assert result is payment + assert finalized == [(3, 3.0, "hwid_devices@standard")] + + def test_try_reuse_pending_link_returns_url_for_opened_link(): service = _service(_FakeSession()) payment = _payment(provider_payment_id="link-id") diff --git a/tests/test_webapp_device_topup_options.py b/tests/test_webapp_device_topup_options.py index 6f97fe3..09caeba 100644 --- a/tests/test_webapp_device_topup_options.py +++ b/tests/test_webapp_device_topup_options.py @@ -196,3 +196,61 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase): self.assertEqual(payload["price"], 25) subscription_service.quote_hwid_device_topup.assert_awaited_once() create_payment.assert_awaited_once() + + async def test_create_payment_route_rejects_fractional_hwid_device_count(self): + tariff = SimpleNamespace( + key="standard", + billing_model="period", + enabled_periods=[1], + hwid_device_packages=SimpleNamespace( + rub=[SimpleNamespace(count=1)], + stars=[], + ), + ) + settings = SimpleNamespace( + traffic_sale_mode=False, + tariffs_config=SimpleNamespace(require=lambda key: tariff), + DEFAULT_LANGUAGE="en", + DEFAULT_CURRENCY_SYMBOL="RUB", + ) + subscription_service = SimpleNamespace(quote_hwid_device_topup=AsyncMock()) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(), + "subscription_service": subscription_service, + } + ) + + 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.9, + "device_count": 1.9, + "tariff_key": "standard", + "sale_mode": "hwid_devices", + } + ), + ), + patch.object( + billing_module, + "_get_cached_webapp_settings", + return_value={"subscription_options": {}, "stars_subscription_options": {}}, + ), + ): + response = await billing_module.create_payment_route(request) + + self.assertEqual(response.status, 400) + payload = json.loads(response.text) + self.assertEqual(payload["error"], "invalid_plan") + subscription_service.quote_hwid_device_topup.assert_not_awaited()