411 lines
12 KiB
Python
411 lines
12 KiB
Python
"""
|
|
Pally API Asynchronous SDK
|
|
|
|
This module provides a strictly typed, asynchronous client for the Pally API.
|
|
It is built on top of aiohttp for high-performance async requests and pydantic
|
|
for robust data validation.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ==========================================
|
|
# 1. ENUMS & CONSTANTS
|
|
# ==========================================
|
|
|
|
|
|
class Currency(StrEnum):
|
|
RUB = "RUB"
|
|
USD = "USD"
|
|
EUR = "EUR"
|
|
USDT = "USDT"
|
|
|
|
|
|
class BillType(StrEnum):
|
|
NORMAL = "normal"
|
|
MULTI = "multi"
|
|
|
|
|
|
class BillStatus(StrEnum):
|
|
NEW = "NEW"
|
|
PROCESS = "PROCESS"
|
|
UNDERPAID = "UNDERPAID"
|
|
SUCCESS = "SUCCESS"
|
|
OVERPAID = "OVERPAID"
|
|
FAIL = "FAIL"
|
|
|
|
|
|
class PaymentStatus(StrEnum):
|
|
NEW = "NEW"
|
|
PROCESS = "PROCESS"
|
|
UNDERPAID = "UNDERPAID"
|
|
SUCCESS = "SUCCESS"
|
|
OVERPAID = "OVERPAID"
|
|
FAIL = "FAIL"
|
|
|
|
|
|
class Locale(StrEnum):
|
|
EN = "en"
|
|
RU = "ru"
|
|
|
|
|
|
# ==========================================
|
|
# 2. EXCEPTIONS
|
|
# ==========================================
|
|
|
|
|
|
class PallyError(Exception):
|
|
"""Base exception for all Pally API errors."""
|
|
|
|
pass
|
|
|
|
|
|
class PallyAPIError(PallyError):
|
|
"""Raised when the API responds with an error HTTP status code."""
|
|
|
|
def __init__(self, status_code: int, message: str):
|
|
self.status_code = status_code
|
|
self.message = message
|
|
super().__init__(f"Pally API Error {status_code}: {message}")
|
|
|
|
|
|
# ==========================================
|
|
# 3. PYDANTIC MODELS (DATA STRUCTURES)
|
|
# ==========================================
|
|
|
|
|
|
class BasePallyModel(BaseModel):
|
|
"""Base Pydantic model with default configurations."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
|
|
|
|
# --- General Responses ---
|
|
class PaginationLinks(BasePallyModel):
|
|
prev: str | None = None
|
|
next: str | None = None
|
|
|
|
|
|
class PaginationMeta(BasePallyModel):
|
|
path: str
|
|
per_page: int
|
|
next_cursor: str | None = None
|
|
prev_cursor: str | None = None
|
|
|
|
|
|
# --- Bill Models ---
|
|
class BillCreateResponse(BasePallyModel):
|
|
success: bool
|
|
link_url: str
|
|
link_page_url: str
|
|
bill_id: str
|
|
|
|
|
|
class Bill(BasePallyModel):
|
|
id: str
|
|
order_id: str | None = None
|
|
active: bool | None = None
|
|
status: BillStatus
|
|
amount: float
|
|
type: BillType
|
|
created_at: datetime
|
|
currency_in: Currency
|
|
ttl: int | None = None
|
|
|
|
|
|
class BillSearchResponse(BasePallyModel):
|
|
success: bool
|
|
data: list[Bill]
|
|
links: PaginationLinks
|
|
meta: PaginationMeta
|
|
|
|
|
|
# --- Payment Models ---
|
|
class Payment(BasePallyModel):
|
|
id: str
|
|
bill_id: str
|
|
status: PaymentStatus
|
|
amount: float
|
|
commission: float
|
|
account_amount: float
|
|
account_currency_code: str
|
|
refunded_amount: float
|
|
from_card: str | None = None
|
|
account_bank: str | None = None
|
|
currency_in: Currency
|
|
created_at: datetime
|
|
payer_phone: str | None = None
|
|
payer_email: str | None = None
|
|
payer_name: str | None = None
|
|
payer_comment: str | None = None
|
|
error_code: int | None = None
|
|
error_message: str | None = None
|
|
description: str | None = None
|
|
|
|
|
|
class PaymentSearchResponse(BasePallyModel):
|
|
success: bool
|
|
data: list[Payment]
|
|
links: PaginationLinks
|
|
meta: PaginationMeta
|
|
|
|
|
|
# --- Balance Models ---
|
|
class Balance(BasePallyModel):
|
|
currency: Currency
|
|
balance_available: float
|
|
balance_locked: float
|
|
balance_hold: float
|
|
|
|
|
|
class BalanceResponse(BasePallyModel):
|
|
success: bool
|
|
balances: list[Balance]
|
|
|
|
|
|
# ==========================================
|
|
# 4. CORE SERVICES
|
|
# ==========================================
|
|
|
|
|
|
class BaseService:
|
|
"""Base class for all API services handling aiohttp operations."""
|
|
|
|
def __init__(self, session: aiohttp.ClientSession):
|
|
self._session = session
|
|
|
|
async def _post(self, endpoint: str, data: dict[str, Any], response_model: type[Any]) -> Any:
|
|
"""Helper for making POST requests with application/x-www-form-urlencoded data."""
|
|
# Clean None values to avoid sending them in the payload
|
|
cleaned_data = {k: str(v) for k, v in data.items() if v is not None}
|
|
|
|
async with self._session.post(endpoint, data=cleaned_data) as response:
|
|
await self._handle_errors(response)
|
|
json_data = await response.json()
|
|
return response_model.model_validate(json_data)
|
|
|
|
async def _get(self, endpoint: str, params: dict[str, Any], response_model: type[Any]) -> Any:
|
|
"""Helper for making GET requests."""
|
|
cleaned_params = {k: str(v) for k, v in params.items() if v is not None}
|
|
|
|
async with self._session.get(endpoint, params=cleaned_params) as response:
|
|
await self._handle_errors(response)
|
|
json_data = await response.json()
|
|
return response_model.model_validate(json_data)
|
|
|
|
@staticmethod
|
|
async def _handle_errors(response: aiohttp.ClientResponse) -> None:
|
|
"""Raises exceptions based on HTTP error codes from aiohttp."""
|
|
if not response.ok:
|
|
try:
|
|
error_data = await response.json()
|
|
message = (
|
|
error_data.get("message")
|
|
or error_data.get("error_key")
|
|
or await response.text()
|
|
)
|
|
except Exception:
|
|
message = await response.text()
|
|
raise PallyAPIError(status_code=response.status, message=message)
|
|
|
|
|
|
class BillService(BaseService):
|
|
"""Service to handle all Bill-related operations."""
|
|
|
|
def __init__(self, session: aiohttp.ClientSession, payer_pays_commission: bool | None = None):
|
|
super().__init__(session)
|
|
self._payer_pays_commission = payer_pays_commission
|
|
|
|
async def create(
|
|
self,
|
|
amount: float,
|
|
shop_id: str,
|
|
order_id: str | None = None,
|
|
description: str | None = None,
|
|
bill_type: BillType = BillType.NORMAL,
|
|
currency_in: Currency | None = None,
|
|
ttl: int | None = None,
|
|
payer_pays_commission: bool | None = None,
|
|
**kwargs: Any,
|
|
) -> BillCreateResponse:
|
|
"""
|
|
Creates a new bill for payment.
|
|
|
|
Args:
|
|
amount: The payment amount.
|
|
shop_id: Unique shop identifier.
|
|
payer_pays_commission: If True, the payer pays the commission.
|
|
Overrides the client-level default.
|
|
"""
|
|
# Определяем итоговое значение: переданное в метод или значение по умолчанию из сервиса
|
|
final_ppc = (
|
|
payer_pays_commission
|
|
if payer_pays_commission is not None
|
|
else self._payer_pays_commission
|
|
)
|
|
|
|
payload = {
|
|
"amount": amount,
|
|
"shop_id": shop_id,
|
|
"order_id": order_id,
|
|
"description": description,
|
|
"type": bill_type.value,
|
|
"currency_in": currency_in.value if currency_in else None,
|
|
"ttl": ttl,
|
|
"payer_pays_commission": int(final_ppc) if final_ppc is not None else None,
|
|
**kwargs,
|
|
}
|
|
return await self._post("bill/create", payload, BillCreateResponse)
|
|
|
|
async def toggle_activity(self, bill_id: str, active: bool) -> Bill:
|
|
"""Activates or deactivates a bill."""
|
|
payload = {"id": bill_id, "active": int(active)}
|
|
return await self._post("bill/toggle_activity", payload, Bill)
|
|
|
|
async def get_payments(
|
|
self, bill_id: str, per_page: int | None = None, cursor: str | None = None
|
|
) -> PaymentSearchResponse:
|
|
"""Gets all payments related to a specific bill."""
|
|
params = {"id": bill_id, "per_page": per_page, "cursor": cursor}
|
|
return await self._get("bill/payments", params, PaymentSearchResponse)
|
|
|
|
async def search(
|
|
self,
|
|
start_date: datetime | None = None,
|
|
finish_date: datetime | None = None,
|
|
shop_id: str | None = None,
|
|
per_page: int | None = None,
|
|
cursor: str | None = None,
|
|
) -> BillSearchResponse:
|
|
"""Searches for bills based on parameters."""
|
|
params = {
|
|
"start_date": start_date.strftime("%Y-%m-%d %H:%M:%S") if start_date else None,
|
|
"finish_date": finish_date.strftime("%Y-%m-%d %H:%M:%S") if finish_date else None,
|
|
"shop_id": shop_id,
|
|
"per_page": per_page,
|
|
"cursor": cursor,
|
|
}
|
|
return await self._get("bill/search", params, BillSearchResponse)
|
|
|
|
async def status(self, bill_id: str) -> Bill:
|
|
"""Gets the status of a specific bill."""
|
|
return await self._get("bill/status", {"id": bill_id}, Bill)
|
|
|
|
|
|
class PaymentService(BaseService):
|
|
"""Service to handle all Payment-related operations."""
|
|
|
|
async def search(
|
|
self,
|
|
start_date: datetime | None = None,
|
|
finish_date: datetime | None = None,
|
|
shop_id: str | None = None,
|
|
per_page: int | None = None,
|
|
cursor: str | None = None,
|
|
) -> PaymentSearchResponse:
|
|
"""Searches for payments."""
|
|
params = {
|
|
"start_date": start_date.strftime("%Y-%m-%d %H:%M:%S") if start_date else None,
|
|
"finish_date": finish_date.strftime("%Y-%m-%d %H:%M:%S") if finish_date else None,
|
|
"shop_id": shop_id,
|
|
"per_page": per_page,
|
|
"cursor": cursor,
|
|
}
|
|
return await self._get("payment/search", params, PaymentSearchResponse)
|
|
|
|
async def status(
|
|
self, payment_id: str, refunds: bool = False, chargeback: bool = False
|
|
) -> Payment:
|
|
"""Gets detailed status of a specific payment."""
|
|
params = {"id": payment_id, "refunds": int(refunds), "chargeback": int(chargeback)}
|
|
return await self._get("payment/status", params, Payment)
|
|
|
|
|
|
class BalanceService(BaseService):
|
|
"""Service to handle Balance-related operations."""
|
|
|
|
async def get(self) -> BalanceResponse:
|
|
"""Retrieves merchant balance."""
|
|
return await self._get("merchant/balance", {}, BalanceResponse)
|
|
|
|
|
|
# ==========================================
|
|
# 5. MAIN CLIENT
|
|
# ==========================================
|
|
|
|
|
|
class PallyClient:
|
|
"""
|
|
Main asynchronous client for the Pally API.
|
|
|
|
Usage:
|
|
async with PallyClient(api_token="your_token", payer_pays_commission=True) as client:
|
|
bill = await client.bills.create(amount=100.0, shop_id="my_shop")
|
|
print(bill.link_url)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
api_token: str,
|
|
base_url: str = "https://pal24.pro/api/v1/",
|
|
payer_pays_commission: bool | None = None,
|
|
):
|
|
"""
|
|
Initializes the Pally API client.
|
|
|
|
Args:
|
|
api_token (str): The bearer token provided by Pally.
|
|
base_url (str): The base URL for the API.
|
|
payer_pays_commission (bool, optional): Global default for whether the payer pays the commission.
|
|
"""
|
|
self.api_token = api_token
|
|
self.base_url = base_url
|
|
self.payer_pays_commission = payer_pays_commission
|
|
self._session: aiohttp.ClientSession | None = None
|
|
|
|
@property
|
|
def session(self) -> aiohttp.ClientSession:
|
|
"""
|
|
Lazy-loads the aiohttp ClientSession.
|
|
"""
|
|
if self._session is None or self._session.closed:
|
|
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
|
|
timeout = aiohttp.ClientTimeout(total=10.0)
|
|
self._session = aiohttp.ClientSession(
|
|
base_url=self.base_url, headers=headers, timeout=timeout
|
|
)
|
|
return self._session
|
|
|
|
@property
|
|
def bills(self) -> BillService:
|
|
# Передаем параметр плательщика комиссии в сервис счетов
|
|
return BillService(self.session, self.payer_pays_commission)
|
|
|
|
@property
|
|
def payments(self) -> PaymentService:
|
|
return PaymentService(self.session)
|
|
|
|
@property
|
|
def balance(self) -> BalanceService:
|
|
return BalanceService(self.session)
|
|
|
|
async def close(self) -> None:
|
|
"""Closes the underlying aiohttp client session safely."""
|
|
if self._session and not self._session.closed:
|
|
await self._session.close()
|
|
|
|
async def __aenter__(self) -> "PallyClient":
|
|
_ = self.session
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
await self.close()
|