feat(orders): integrate Pally payment and order management

This commit is contained in:
2026-07-31 13:42:14 +07:00
parent 2547bcfcba
commit 8fbd739697
20 changed files with 661 additions and 36 deletions

View File

@@ -0,0 +1,42 @@
"""+invoice table
Revision ID: e237f7c5cb9a
Revises: bb542353b092
Create Date: 2026-07-31 11:45:00.530489
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e237f7c5cb9a'
down_revision: Union[str, Sequence[str], None] = 'bb542353b092'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('invoices',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('creator_id', sa.BIGINT(), nullable=False),
sa.Column('amount', sa.FLOAT(), nullable=False),
sa.Column('status', sa.Enum('EXPIRED', 'ACTIVE', 'PAID', name='invoicestatus'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_unique_constraint(None, 'addons', ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'addons', type_='unique')
op.drop_table('invoices')
# ### end Alembic commands ###

View File

@@ -18,6 +18,9 @@ class Settings(BaseSettings):
access_token_ttl: int = Field(description="Access token TTL (minutes)") access_token_ttl: int = Field(description="Access token TTL (minutes)")
pally_shop_id: str = Field()
pally_token: str = Field()
@computed_field @computed_field
@property @property
def db_url(self) -> str: def db_url(self) -> str:

0
core/auth/__init__.py Normal file
View File

30
core/auth/jwt.py Normal file
View File

@@ -0,0 +1,30 @@
from datetime import UTC, datetime
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from core.secrets import decode_jwt
from repositories.users import UserRepository
from schemas.dto import AuthContext
from schemas.jwt import JWTPayload
async def authorize(token: str, session: AsyncSession, service: str | None = None) -> AuthContext:
content = decode_jwt(token)
try:
payload = JWTPayload.model_validate(content)
except ValidationError:
raise HTTPException(status_code=401, detail="Invalid credentials") from None
if payload.exp < datetime.now(UTC).timestamp():
raise HTTPException(status_code=401, detail="Access token expired")
repo = UserRepository(session)
user = await repo.get_user_by_id(int(payload.sub))
if not user:
raise HTTPException(status_code=401, detail="User not found")
return AuthContext(user, auth_method="jwt", service=service)

View File

@@ -1,38 +1,25 @@
from datetime import UTC, datetime from fastapi import Depends, HTTPException, Request
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2AuthorizationCodeBearer
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.secrets import decode_jwt from config import cfg
from db.models import User from core.auth import jwt
from db.session import get_db from db.session import get_db
from repositories.users import UserRepository from external.pally import PallyClient
from schemas.jwt import JWTPayload from schemas.dto import AuthContext
oauth_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="/auth/login", tokenUrl="/auth/refresh"
)
async def get_current_user( async def get_auth_context(
session: AsyncSession = Depends(get_db), token: str = Depends(oauth_scheme) request: Request, session: AsyncSession = Depends(get_db)
) -> User | None: ) -> AuthContext | None:
content = decode_jwt(token) auth = request.headers.get("Authorization")
try: if not auth:
payload = JWTPayload.model_validate(content) raise HTTPException(status_code=403, detail="No authorization provided.")
except ValidationError:
raise HTTPException(status_code=401, detail="Invalid credentials") from None
if payload.exp < datetime.now(UTC).timestamp(): if auth.startswith("Bearer"):
raise HTTPException(status_code=401, detail="Access token expired") token = auth.removeprefix("Bearer ").strip()
return await jwt.authorize(token, session)
repo = UserRepository(session)
user = await repo.get_user_by_id(int(payload.sub))
if not user: def get_pally_client() -> PallyClient:
raise HTTPException(status_code=401, detail="User not found") return PallyClient(api_token=cfg.pally_token, payer_pays_commission=True)
return user

View File

@@ -32,12 +32,12 @@ def verify_password(hashed_password: str, plain_password: str) -> bool:
def generate_jwt(payload: dict[str, Any]) -> str: def generate_jwt(payload: dict[str, Any]) -> str:
return jwt.encode(payload, cfg.private_key, "RS256") return jwt.encode(payload, cfg.private_key, "EdDSA")
def decode_jwt(token: str) -> dict[str, Any] | None: def decode_jwt(token: str) -> dict[str, Any] | None:
try: try:
return jwt.decode(token, cfg.public_key, "RS256") return jwt.decode(token, cfg.public_key, "EdDSA")
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
return return

View File

@@ -1,6 +1,7 @@
from .addons import Addon from .addons import Addon
from .invoice import Invoice
from .pricing import PricingConfig from .pricing import PricingConfig
from .sessions import Session from .sessions import Session
from .users import User from .users import User
__all__ = ["Addon", "PricingConfig", "Session", "User"] __all__ = ["Addon", "Invoice", "PricingConfig", "Session", "User"]

25
db/models/invoice.py Normal file
View File

@@ -0,0 +1,25 @@
from typing import TYPE_CHECKING
from sqlalchemy import FLOAT, INTEGER, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
from schemas.invoices import InvoiceStatus
if TYPE_CHECKING:
from db.models import User
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[int] = mapped_column(
INTEGER, autoincrement=True, unique=True, nullable=False, primary_key=True
)
creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
amount: Mapped[float] = mapped_column(FLOAT, nullable=False)
status: Mapped[InvoiceStatus] = mapped_column(
Enum(InvoiceStatus, name="invoicestatus"), nullable=False, default=InvoiceStatus.ACTIVE
)
user: Mapped["User"] = relationship("User")

0
external/__init__.py vendored Normal file
View File

410
external/pally.py vendored Normal file
View File

@@ -0,0 +1,410 @@
"""
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()

View File

@@ -0,0 +1,6 @@
from .addons import AddonsRepository
from .pricing import PricingRepository
from .sessions import SessionsRepository
from .users import UserRepository
__all__ = ["AddonsRepository", "PricingRepository", "SessionsRepository", "UserRepository"]

View File

@@ -13,3 +13,9 @@ class AddonsRepository:
r = await self.session.execute(stmt) r = await self.session.execute(stmt)
return list(r.scalars().all()) return list(r.scalars().all())
async def get_by_id(self, id: str) -> Addon | None:
stmt = select(Addon).where(Addon.id == id)
r = await self.session.execute(stmt)
return r.scalar_one_or_none()

34
repositories/invoices.py Normal file
View File

@@ -0,0 +1,34 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.invoice import Invoice
from schemas.invoices import InvoiceStatus
class InvoiceRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_by_id(self, id: int) -> Invoice | None:
stmt = select(Invoice).where(Invoice.id == id)
r = await self.session.execute(stmt)
return r.scalar_one_or_none()
async def get_by_user_id(self, user_id: int) -> list[Invoice]:
stmt = select(Invoice).where(Invoice.creator_id == user_id)
r = await self.session.execute(stmt)
return list(r.scalars().all())
async def create(self, creator_id: int, amount: int | float, status: InvoiceStatus) -> Invoice:
obj = Invoice(
creator_id=creator_id,
amount=amount,
status=status,
)
self.session.add(obj)
await self.session.commit()
return obj

View File

@@ -5,4 +5,5 @@ sqlalchemy[asyncio]>=2.0.50
argon2-cffi>=25.1.0 argon2-cffi>=25.1.0
pydantic-settings>=2.14.0 pydantic-settings>=2.14.0
asyncpg>=0.31.0 asyncpg>=0.31.0
alembic>=1.18.0 alembic>=1.18.0
aiohttp>=3.14.0

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from .auth import router as auth_router from .auth import router as auth_router
from .orders import router as orders_router
from .plans import router as plans_router from .plans import router as plans_router
routers: list[APIRouter] = [auth_router, plans_router] routers: list[APIRouter] = [auth_router, plans_router, orders_router]

41
routes/orders.py Normal file
View File

@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from config import cfg
from core.deps import get_auth_context, get_pally_client
from db.session import get_db
from external.pally import PallyClient
from repositories import AddonsRepository, PricingRepository
from repositories.invoices import InvoiceRepository
from schemas.dto import AuthContext
from schemas.invoices import InvoiceResponse, InvoiceStatus
from schemas.plans import OrderDetails
from services.plans import calculate_price, get_pricing_model
router = APIRouter(prefix="/orders")
@router.post("/checkout", response_model=InvoiceResponse, status_code=201)
async def checkout(
order: OrderDetails,
ctx: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_db),
pally: PallyClient = Depends(get_pally_client),
):
addons_repo = AddonsRepository(session)
pricing_repo = PricingRepository(session)
invoices_repo = InvoiceRepository(session)
pricing = await get_pricing_model(addons_repo, pricing_repo)
price = await calculate_price(addons_repo=addons_repo, order=order, pricing=pricing)
invoice = await invoices_repo.create(
creator_id=ctx.user.id, amount=price, status=InvoiceStatus.ACTIVE
)
bill = await pally.bills.create(price, cfg.pally_shop_id, order_id=invoice.id)
if not (bill.success and bill.link_page_url):
raise HTTPException(500, detail="Failed to create an invoice.")
return InvoiceResponse(success=True, payment_link=bill.link_page_url, amount=float(price))

View File

@@ -1,4 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal
from db.models.users import User
@dataclass @dataclass
@@ -6,3 +9,10 @@ class KeyPair:
access_token: str access_token: str
refresh_token: str refresh_token: str
expires_at: float expires_at: float
@dataclass
class AuthContext:
user: User
auth_method: Literal["jwt"]
service: str | None = None

15
schemas/invoices.py Normal file
View File

@@ -0,0 +1,15 @@
from enum import StrEnum
from pydantic import BaseModel
class InvoiceStatus(StrEnum):
EXPIRED = "expired"
ACTIVE = "active"
PAID = "paid"
class InvoiceResponse(BaseModel):
success: bool
amount: float
payment_link: str

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
class AddonData(BaseModel): class AddonData(BaseModel):
@@ -11,3 +11,9 @@ class AddonData(BaseModel):
class PricingPlans(BaseModel): class PricingPlans(BaseModel):
device_price: int device_price: int
addons: list[AddonData] addons: list[AddonData]
class OrderDetails(BaseModel):
devices: int = Field()
addons: list[str] = Field(description="List of addons ids")
duration_days: int

View File

@@ -1,6 +1,6 @@
from repositories.addons import AddonsRepository from repositories.addons import AddonsRepository
from repositories.pricing import PricingRepository from repositories.pricing import PricingRepository
from schemas.plans import AddonData, PricingPlans from schemas.plans import AddonData, OrderDetails, PricingPlans
async def get_pricing_model(addons_repo: AddonsRepository, pricing_repo: PricingRepository): async def get_pricing_model(addons_repo: AddonsRepository, pricing_repo: PricingRepository):
@@ -15,3 +15,10 @@ async def get_pricing_model(addons_repo: AddonsRepository, pricing_repo: Pricing
] ]
return PricingPlans(device_price=pricing.device_price, addons=addons_data) return PricingPlans(device_price=pricing.device_price, addons=addons_data)
async def calculate_price(
*, addons_repo: AddonsRepository, order: OrderDetails, pricing: PricingPlans
) -> float:
addons = [await addons_repo.get_by_id(a) for a in order.addons]
return order.devices * pricing.device_price + sum([a.price for a in addons])