fix: harden hwid provider payment edge cases

This commit is contained in:
3252a8
2026-05-27 18:31:14 +03:00
parent f2fc335221
commit 75586d8883
16 changed files with 444 additions and 25 deletions
+15 -5
View File
@@ -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(
device_count = _parse_positive_int_units(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
)
except (TypeError, ValueError):
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")
@@ -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,
+2 -1
View File
@@ -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:
+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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:
+9 -1
View File
@@ -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,
@@ -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",
@@ -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,
)
+29 -1
View File
@@ -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)
+8 -3
View File
@@ -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(
+2 -1
View File
@@ -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,
+12 -5
View File
@@ -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)
+45
View File
@@ -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",
)
+179
View File
@@ -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",
)
)
+59
View File
@@ -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")
+58
View File
@@ -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()