feat: implement billing system with Pally integration
- Added billing models and repository for handling bills. - Introduced BillingService to manage payment initiation and bill creation. - Updated user service to remove ticket-related functionality. - Refactored settings to include new billing-related fields. - Removed ticket-related handlers and schemas. - Added new billing handlers for processing payments. - Updated database schema with new bills table and status enum. - Enhanced utility functions to calculate prices considering discounts. - Introduced prehandling for non-private messages. - Updated main application to initialize new billing services and repositories.
This commit is contained in:
385
misc/pally.py
Normal file
385
misc/pally.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user