first commit
This commit is contained in:
250
bot/services/wata.py
Normal file
250
bot/services/wata.py
Normal file
@@ -0,0 +1,250 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user