251 lines
7.6 KiB
Python
251 lines
7.6 KiB
Python
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}"
|