""" 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.""" 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, **kwargs: Any, ) -> BillCreateResponse: """Creates a new bill for payment.""" 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, **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") as client: balance = await client.balance.get() print(balance) """ def __init__(self, api_token: str, base_url: str = "https://pal24.pro/api/v1/"): """ Initializes the Pally API client. Args: api_token (str): The bearer token provided by Pally. base_url (str): The base URL for the API. """ self.api_token = api_token self.base_url = base_url self._session: aiohttp.ClientSession | None = None @property def session(self) -> aiohttp.ClientSession: """ Lazy-loads the aiohttp ClientSession. This prevents creating a session outside of the asyncio event loop. """ 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 # Using properties ensures that services always receive the active session context @property def bills(self) -> BillService: return BillService(self.session) @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": # Accessing the property ensures the session is instantiated # properly within the async context block. _ = self.session return self async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: await self.close()