mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 21:01:17 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
214 lines
7.3 KiB
Python
214 lines
7.3 KiB
Python
"""Async client for PayPalych (Pal24) API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from app.config import settings
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Pal24APIError(Exception):
|
|
"""Base error for Pal24 API operations."""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Pal24Response:
|
|
"""Wrapper for Pal24 API responses."""
|
|
|
|
success: bool
|
|
data: dict[str, Any]
|
|
status: int
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any], status: int) -> Pal24Response:
|
|
success = bool(payload.get('success', status < 400))
|
|
return cls(success=success, data=payload, status=status)
|
|
|
|
def raise_for_status(self, endpoint: str) -> None:
|
|
if not self.success:
|
|
detail = self.data.get('message') or self.data.get('error')
|
|
raise Pal24APIError(f'Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}')
|
|
|
|
|
|
class Pal24Client:
|
|
"""Async client implementing PayPalych API methods."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_token: str | None = None,
|
|
base_url: str | None = None,
|
|
timeout: int | None = None,
|
|
) -> None:
|
|
self.api_token = api_token or settings.PAL24_API_TOKEN
|
|
self.base_url = (base_url or settings.PAL24_BASE_URL or '').rstrip('/') + '/'
|
|
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
|
|
|
|
if not self.api_token:
|
|
logger.warning('Pal24Client initialized without API token')
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return bool(self.api_token and self.base_url)
|
|
|
|
async def _request(
|
|
self,
|
|
method: str,
|
|
endpoint: str,
|
|
*,
|
|
json_payload: dict[str, Any] | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
) -> Pal24Response:
|
|
if not self.is_configured:
|
|
raise Pal24APIError('Pal24 client is not configured')
|
|
|
|
url = f'{self.base_url}{endpoint.lstrip("/")}'
|
|
headers = {
|
|
'Authorization': f'Bearer {self.api_token}',
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
}
|
|
|
|
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
|
|
try:
|
|
async with (
|
|
aiohttp.ClientSession(timeout=timeout) as session,
|
|
session.request(
|
|
method,
|
|
url,
|
|
headers=headers,
|
|
json=json_payload,
|
|
params=params,
|
|
) as response,
|
|
):
|
|
status = response.status
|
|
try:
|
|
payload = await response.json(content_type=None)
|
|
except aiohttp.ContentTypeError:
|
|
text_body = await response.text()
|
|
logger.error(
|
|
'Pal24 API returned non-JSON response for %s: %s',
|
|
endpoint,
|
|
text_body,
|
|
)
|
|
raise Pal24APIError(f'Pal24 API returned non-JSON response: {text_body}') from None
|
|
|
|
result = Pal24Response.from_payload(payload, status)
|
|
if status >= 400 or not result.success:
|
|
logger.error(
|
|
'Pal24 API error %s %s: %s',
|
|
status,
|
|
endpoint,
|
|
payload,
|
|
)
|
|
result.raise_for_status(endpoint)
|
|
|
|
return result
|
|
|
|
except TimeoutError as error:
|
|
logger.error('Pal24 API request timeout for %s: %s', endpoint, error)
|
|
raise Pal24APIError(f'Pal24 API request timeout for {endpoint}') from error
|
|
except aiohttp.ClientError as error:
|
|
logger.error('Pal24 API client error for %s: %s', endpoint, error)
|
|
raise Pal24APIError(str(error)) from error
|
|
|
|
# API methods -----------------------------------------------------------------
|
|
|
|
async def create_bill(
|
|
self,
|
|
*,
|
|
amount: Decimal,
|
|
shop_id: str,
|
|
order_id: str | None = None,
|
|
description: str | None = None,
|
|
currency_in: str = 'RUB',
|
|
type_: str = 'normal',
|
|
**kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
'amount': str(amount),
|
|
'shop_id': shop_id,
|
|
'currency_in': currency_in,
|
|
'type': type_,
|
|
}
|
|
|
|
if order_id:
|
|
payload['order_id'] = order_id
|
|
if description:
|
|
payload['description'] = description
|
|
|
|
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
|
|
|
response = await self._request('POST', 'bill/create', json_payload=payload)
|
|
return response.data
|
|
|
|
async def get_bill_status(self, bill_id: str) -> dict[str, Any]:
|
|
response = await self._request('GET', 'bill/status', params={'id': bill_id})
|
|
return response.data
|
|
|
|
async def toggle_bill_activity(self, bill_id: str, active: bool) -> dict[str, Any]:
|
|
payload = {'id': bill_id, 'active': 1 if active else 0}
|
|
response = await self._request('POST', 'bill/toggle_activity', json_payload=payload)
|
|
return response.data
|
|
|
|
async def search_payments(self, **params: Any) -> dict[str, Any]:
|
|
response = await self._request('GET', 'payment/search', params=params)
|
|
return response.data
|
|
|
|
async def get_payment_status(self, payment_id: str) -> dict[str, Any]:
|
|
response = await self._request('GET', 'payment/status', params={'id': payment_id})
|
|
return response.data
|
|
|
|
async def get_balance(self) -> dict[str, Any]:
|
|
response = await self._request('GET', 'merchant/balance')
|
|
return response.data
|
|
|
|
async def search_bills(self, **params: Any) -> dict[str, Any]:
|
|
response = await self._request('GET', 'bill/search', params=params)
|
|
return response.data
|
|
|
|
async def get_bill_payments(self, bill_id: str) -> dict[str, Any]:
|
|
response = await self._request('GET', 'bill/payments', params={'id': bill_id})
|
|
return response.data
|
|
|
|
# Helpers ---------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def calculate_signature(out_sum: str, inv_id: str, api_token: str | None = None) -> str:
|
|
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
|
|
if not token:
|
|
raise Pal24APIError('Pal24 signature token is not configured')
|
|
raw = f'{out_sum}:{inv_id}:{token}'.encode()
|
|
return hashlib.md5(raw).hexdigest().upper()
|
|
|
|
@staticmethod
|
|
def verify_signature(
|
|
out_sum: str,
|
|
inv_id: str,
|
|
signature: str,
|
|
api_token: str | None = None,
|
|
) -> bool:
|
|
try:
|
|
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
|
|
except Pal24APIError:
|
|
logger.error('Pal24 signature verification failed: missing token')
|
|
return False
|
|
return expected == signature.upper()
|
|
|
|
@staticmethod
|
|
def normalize_amount(amount_kopeks: int) -> Decimal:
|
|
try:
|
|
return (Decimal(amount_kopeks) / Decimal(100)).quantize(Decimal('0.01'))
|
|
except (InvalidOperation, TypeError) as error:
|
|
raise Pal24APIError(f'Invalid amount: {amount_kopeks}') from error
|