fix: harden hwid provider payment edge cases
This commit is contained in:
@@ -27,6 +27,19 @@ def _billing_datetime_text(value: Optional[Any]) -> Optional[str]:
|
|||||||
return text
|
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:
|
async def apply_promo_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
payload = await _read_json(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")
|
return _json_error(400, "invalid_plan", "Tariff is not available")
|
||||||
if tariff.billing_model != "period":
|
if tariff.billing_model != "period":
|
||||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||||
try:
|
device_count = _parse_positive_int_units(
|
||||||
device_count = int(
|
|
||||||
float(
|
|
||||||
payment_payload.device_count
|
payment_payload.device_count
|
||||||
if payment_payload.device_count is not None
|
if payment_payload.device_count is not None
|
||||||
else payment_payload.months
|
else payment_payload.months
|
||||||
)
|
)
|
||||||
)
|
if device_count is None:
|
||||||
except (TypeError, ValueError):
|
|
||||||
return _json_error(400, "invalid_plan", "Invalid device package")
|
return _json_error(400, "invalid_plan", "Invalid device package")
|
||||||
if not tariff.hwid_device_packages:
|
if not tariff.hwid_device_packages:
|
||||||
return _json_error(400, "invalid_plan", "Device package is not available")
|
return _json_error(400, "invalid_plan", "Device package is not available")
|
||||||
|
|||||||
@@ -270,6 +270,14 @@ class CryptoPayService:
|
|||||||
referral_service: ReferralService = app["referral_service"]
|
referral_service: ReferralService = app["referral_service"]
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
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:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from .shared import (
|
|||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -382,10 +383,10 @@ class FreeKassaService(HttpClientMixin):
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text="processing_error")
|
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 (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
months = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
success_prefix: Optional[str] = None
|
success_prefix: Optional[str] = None
|
||||||
if provider_payment_id:
|
if provider_payment_id:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from .shared import (
|
|||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
)
|
)
|
||||||
@@ -469,10 +470,10 @@ class HeleketService(HttpClientMixin):
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text="processing_error")
|
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 (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
outcome = await finalize_successful_payment(
|
outcome = await finalize_successful_payment(
|
||||||
PaymentSuccessRequest(
|
PaymentSuccessRequest(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from .shared import (
|
|||||||
payment_failed,
|
payment_failed,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -303,10 +304,10 @@ class PlategaService(HttpClientMixin):
|
|||||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||||
return web.Response(text="ok")
|
return web.Response(text="ok")
|
||||||
|
|
||||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
|
||||||
sale_mode = payment.sale_mode or (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
if status == "CONFIRMED":
|
if status == "CONFIRMED":
|
||||||
if amount_raw is not None:
|
if amount_raw is not None:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from .shared import (
|
|||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -270,12 +271,19 @@ class SeverPayService(HttpClientMixin):
|
|||||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||||
|
|
||||||
resolved_provider_id = provider_payment_id or str(payment.payment_id)
|
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 (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
if status == "success":
|
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:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ from .common import (
|
|||||||
json_error,
|
json_error,
|
||||||
make_translator,
|
make_translator,
|
||||||
mark_payment_failed_creation,
|
mark_payment_failed_creation,
|
||||||
|
parse_positive_int_units,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_link_response,
|
payment_link_response,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
sale_mode_base,
|
sale_mode_base,
|
||||||
sale_mode_is_hwid_devices,
|
sale_mode_is_hwid_devices,
|
||||||
sale_mode_is_traffic,
|
sale_mode_is_traffic,
|
||||||
@@ -103,6 +105,7 @@ __all__ = [
|
|||||||
"lookup_payment_by_order_or_provider_id",
|
"lookup_payment_by_order_or_provider_id",
|
||||||
"make_translator",
|
"make_translator",
|
||||||
"mark_payment_failed_creation",
|
"mark_payment_failed_creation",
|
||||||
|
"parse_positive_int_units",
|
||||||
"notify_admins_payment_received",
|
"notify_admins_payment_received",
|
||||||
"notify_callback_parse_error",
|
"notify_callback_parse_error",
|
||||||
"notify_payment_gateway_failure",
|
"notify_payment_gateway_failure",
|
||||||
@@ -114,6 +117,7 @@ __all__ = [
|
|||||||
"payment_link_message_text",
|
"payment_link_message_text",
|
||||||
"payment_link_response",
|
"payment_link_response",
|
||||||
"payment_record_amounts",
|
"payment_record_amounts",
|
||||||
|
"payment_units_for_activation",
|
||||||
"payment_unavailable",
|
"payment_unavailable",
|
||||||
"post_json_request",
|
"post_json_request",
|
||||||
"quote_hwid_callback_parts",
|
"quote_hwid_callback_parts",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .common import (
|
|||||||
build_payment_description,
|
build_payment_description,
|
||||||
format_human_units,
|
format_human_units,
|
||||||
mark_payment_failed_creation,
|
mark_payment_failed_creation,
|
||||||
|
parse_positive_int_units,
|
||||||
sale_mode_base,
|
sale_mode_base,
|
||||||
sale_mode_is_hwid_devices,
|
sale_mode_is_hwid_devices,
|
||||||
sale_mode_tariff_key,
|
sale_mode_tariff_key,
|
||||||
@@ -124,10 +125,13 @@ async def quote_hwid_callback_parts(
|
|||||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||||
return parts, None
|
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(
|
quote = await subscription_service.quote_hwid_device_topup(
|
||||||
session,
|
session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
device_count=int(parts.months),
|
device_count=device_count,
|
||||||
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||||
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
||||||
currency=currency,
|
currency=currency,
|
||||||
@@ -135,7 +139,7 @@ async def quote_hwid_callback_parts(
|
|||||||
if not quote:
|
if not quote:
|
||||||
return None, None
|
return None, None
|
||||||
quoted_parts = PaymentCallbackParts(
|
quoted_parts = PaymentCallbackParts(
|
||||||
months=parts.months,
|
months=device_count,
|
||||||
price=float(quote.get("price") or 0),
|
price=float(quote.get("price") or 0),
|
||||||
sale_mode=parts.sale_mode,
|
sale_mode=parts.sale_mode,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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 typing import Any, Callable, Optional
|
||||||
|
|
||||||
from aiohttp import web
|
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)
|
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:
|
def format_human_units(value: Any) -> str:
|
||||||
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
||||||
numeric = float(value)
|
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:
|
def json_error(status: int, code: str, message: str) -> web.Response:
|
||||||
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ class StarsService:
|
|||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
sale_mode: str = "subscription",
|
sale_mode: str = "subscription",
|
||||||
) -> None:
|
) -> 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:
|
try:
|
||||||
payment_record = await payment_dal.update_provider_payment_and_status(
|
payment_record = await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
@@ -162,9 +170,6 @@ class StarsService:
|
|||||||
else int(message.from_user.id)
|
else int(message.from_user.id)
|
||||||
)
|
)
|
||||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_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(
|
await finalize_successful_payment(
|
||||||
PaymentSuccessRequest(
|
PaymentSuccessRequest(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from .shared import (
|
|||||||
payment_link_response,
|
payment_link_response,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -531,10 +532,10 @@ class WataService(HttpClientMixin):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
|
||||||
sale_mode = payment.sale_mode or (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||||
outcome = await finalize_successful_payment(
|
outcome = await finalize_successful_payment(
|
||||||
PaymentSuccessRequest(
|
PaymentSuccessRequest(
|
||||||
bot=self.bot,
|
bot=self.bot,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from .shared import (
|
|||||||
make_translator,
|
make_translator,
|
||||||
mark_payment_failed_creation,
|
mark_payment_failed_creation,
|
||||||
notify_admins_payment_received,
|
notify_admins_payment_received,
|
||||||
|
parse_positive_int_units,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_link_response,
|
payment_link_response,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
@@ -444,11 +445,17 @@ def _resolve_yookassa_activation_amounts(
|
|||||||
traffic_amount_gb = (
|
traffic_amount_gb = (
|
||||||
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
||||||
)
|
)
|
||||||
hwid_devices_count = (
|
hwid_devices_count = 0
|
||||||
int(float(hwid_devices_raw))
|
if _metadata_value_present(hwid_devices_raw):
|
||||||
if _metadata_value_present(hwid_devices_raw)
|
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
|
||||||
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
|
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":
|
if sale_mode_base == "subscription":
|
||||||
months_for_activation = int(subscription_months)
|
months_for_activation = int(subscription_months)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||||
from bot.payment_providers import (
|
from bot.payment_providers import (
|
||||||
@@ -16,8 +20,11 @@ from bot.payment_providers import (
|
|||||||
resolve_provider_presentation,
|
resolve_provider_presentation,
|
||||||
)
|
)
|
||||||
from bot.payment_providers.shared import (
|
from bot.payment_providers.shared import (
|
||||||
|
PaymentCallbackParts,
|
||||||
format_number_for_payload,
|
format_number_for_payload,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
|
payment_units_for_activation,
|
||||||
|
quote_hwid_callback_parts,
|
||||||
sale_mode_base,
|
sale_mode_base,
|
||||||
sale_mode_is_hwid_devices,
|
sale_mode_is_hwid_devices,
|
||||||
sale_mode_is_traffic,
|
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.tariff_key == "vip"
|
||||||
assert hwid.hwid_devices_sale
|
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():
|
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 hwid_devices_count == 3
|
||||||
assert months_for_activation == 3
|
assert months_for_activation == 3
|
||||||
assert traffic_gb_for_activation is None
|
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,
|
"amount": 100.0,
|
||||||
"provider_payment_id": "link-id",
|
"provider_payment_id": "link-id",
|
||||||
"purchased_gb": None,
|
"purchased_gb": None,
|
||||||
|
"purchased_hwid_devices": None,
|
||||||
"subscription_duration_months": 1,
|
"subscription_duration_months": 1,
|
||||||
"sale_mode": "subscription",
|
"sale_mode": "subscription",
|
||||||
"user": None,
|
"user": None,
|
||||||
@@ -274,6 +275,64 @@ def test_wata_refresh_finds_paid_transaction_by_order_id_and_finalizes(monkeypat
|
|||||||
assert session.commits == 1
|
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():
|
def test_try_reuse_pending_link_returns_url_for_opened_link():
|
||||||
service = _service(_FakeSession())
|
service = _service(_FakeSession())
|
||||||
payment = _payment(provider_payment_id="link-id")
|
payment = _payment(provider_payment_id="link-id")
|
||||||
|
|||||||
@@ -196,3 +196,61 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(payload["price"], 25)
|
self.assertEqual(payload["price"], 25)
|
||||||
subscription_service.quote_hwid_device_topup.assert_awaited_once()
|
subscription_service.quote_hwid_device_topup.assert_awaited_once()
|
||||||
create_payment.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