Files

251 lines
7.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}"