fix: harden hwid provider payment edge cases
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user