from base64 import b64decode from dataclasses import dataclass from datetime import datetime, timedelta, timezone from decimal import Decimal import json import logging from urllib.parse import urlencode from uuid import uuid4 import aiohttp from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from decouple import config logger = logging.getLogger(__name__) class WataAPIError(Exception): def __init__(self, message: str): super().__init__(message) self.message = message @dataclass(slots=True) class WataPaymentLink: id: str url: str status: str @dataclass(slots=True) class WataTransaction: id: str status: str error_code: str | None error_description: str | None payment_time: str | None class WataClient: def __init__(self): self.api_base_url = config( "WATA_API_BASE_URL", default="https://api.wata.pro/api/h2h" ).rstrip("/") self.api_token = config("WATA_API_TOKEN") self.webapp_base_url = config( "WEBAPP_BASE_URL", default="http://127.0.0.1:8000" ).rstrip("/") self.link_ttl_hours = int(config("WATA_LINK_TTL_HOURS", default=24)) self.request_timeout = aiohttp.ClientTimeout(total=60) self._public_key_pem: str | None = None self.is_mock_mode = self.api_token.strip().lower() == "mock" async def create_payment_link( self, amount: Decimal, order_id: str, description: str, success_redirect_url: str, fail_redirect_url: str, ) -> WataPaymentLink: if self.is_mock_mode: logger.info( "Mock payment link created", extra={"order_id": order_id, "amount": str(amount)}, ) return WataPaymentLink( id=f"mock-{uuid4().hex[:12]}", url=self._build_mock_payment_url( order_id=order_id, success_redirect_url=success_redirect_url, fail_redirect_url=fail_redirect_url, ), status="Opened", ) payload = { "amount": float(amount), "currency": "RUB", "description": description, "orderId": order_id, "successRedirectUrl": success_redirect_url, "failRedirectUrl": fail_redirect_url, "expirationDateTime": ( datetime.now(timezone.utc) + timedelta(hours=self.link_ttl_hours) ).isoformat(), } response_data = await self._request( method="POST", path="/links", expected_status=200, json_data=payload, ) return WataPaymentLink( id=response_data["id"], url=response_data["url"], status=response_data["status"], ) async def find_transaction_by_order_id(self, order_id: str) -> WataTransaction | None: if self.is_mock_mode: return None response_data = await self._request( method="GET", path="/transactions/", expected_status=200, params={"orderId": order_id, "maxResultCount": 1}, ) items = response_data.get("items", []) if not items: return None transaction = items[0] return WataTransaction( id=transaction["id"], status=transaction["status"], error_code=transaction.get("errorCode"), error_description=transaction.get("errorDescription"), payment_time=transaction.get("paymentTime"), ) async def verify_webhook_signature( self, raw_body: bytes, signature_header: str ) -> bool: if self.is_mock_mode: return True if not signature_header: return False public_key_pem = await self._get_public_key() public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8")) try: public_key.verify( b64decode(signature_header), raw_body, padding.PKCS1v15(), hashes.SHA512(), ) except (InvalidSignature, ValueError): return False return True async def _get_public_key(self) -> str: if self._public_key_pem is not None: return self._public_key_pem response_data = await self._request( method="GET", path="/public-key", expected_status=200, with_auth=False, ) self._public_key_pem = response_data["value"] return self._public_key_pem async def _request( self, method: str, path: str, expected_status: int, params: dict | None = None, json_data: dict | None = None, with_auth: bool = True, ) -> dict: headers = {"Content-Type": "application/json"} if with_auth: headers["Authorization"] = f"Bearer {self.api_token}" request_url = f"{self.api_base_url}{path}" async with aiohttp.ClientSession(timeout=self.request_timeout) as session: try: async with session.request( method=method, url=request_url, headers=headers, params=params, json=json_data, ) as response: response_text = await response.text() except aiohttp.ClientError as exc: logger.exception( "WATA request failed because of transport error", extra={"method": method, "path": path}, ) raise WataAPIError("Платёжный сервис временно недоступен.") from exc try: response_data = json.loads(response_text) except json.JSONDecodeError: response_data = None if response_data is None: response_data = {"raw_response": response_text} if response.status != expected_status: logger.error( "WATA request failed: %s %s returned %s. Response: %s", method, request_url, response.status, response_data, ) error_payload = response_data.get("error") if isinstance(response_data, dict) else None error_message = "Не удалось создать ссылку на оплату. Попробуйте позже." if error_payload and error_payload.get("details"): error_message = error_payload["details"] elif error_payload and error_payload.get("message"): error_message = error_payload["message"] raise WataAPIError(error_message) return response_data def _build_mock_payment_url( self, order_id: str, success_redirect_url: str, fail_redirect_url: str, ) -> str: query_string = urlencode( { "success_redirect_url": success_redirect_url, "fail_redirect_url": fail_redirect_url, } ) return f"{self.webapp_base_url}/mock/wata/pay/{order_id}?{query_string}" def build_telegram_payment_return_url(bot_username: str, order_id: str, success: bool) -> str: payload_prefix = "payment" if success else "payment_failed" query_string = urlencode({"start": f"{payload_prefix}_{order_id}"}) return f"https://t.me/{bot_username}?{query_string}"