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:
2026-04-23 19:55:51 +07:00
parent 8290d18235
commit d98383285e
29 changed files with 705 additions and 305 deletions

385
misc/pally.py Normal file
View 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()