feat(orders): integrate Pally payment and order management
This commit is contained in:
42
alembic/versions/e237f7c5cb9a_invoice_table.py
Normal file
42
alembic/versions/e237f7c5cb9a_invoice_table.py
Normal 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 ###
|
||||
@@ -18,6 +18,9 @@ class Settings(BaseSettings):
|
||||
|
||||
access_token_ttl: int = Field(description="Access token TTL (minutes)")
|
||||
|
||||
pally_shop_id: str = Field()
|
||||
pally_token: str = Field()
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def db_url(self) -> str:
|
||||
|
||||
0
core/auth/__init__.py
Normal file
0
core/auth/__init__.py
Normal file
30
core/auth/jwt.py
Normal file
30
core/auth/jwt.py
Normal 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)
|
||||
45
core/deps.py
45
core/deps.py
@@ -1,38 +1,25 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||
from pydantic import ValidationError
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.secrets import decode_jwt
|
||||
from db.models import User
|
||||
from config import cfg
|
||||
from core.auth import jwt
|
||||
from db.session import get_db
|
||||
from repositories.users import UserRepository
|
||||
from schemas.jwt import JWTPayload
|
||||
|
||||
oauth_scheme = OAuth2AuthorizationCodeBearer(
|
||||
authorizationUrl="/auth/login", tokenUrl="/auth/refresh"
|
||||
)
|
||||
from external.pally import PallyClient
|
||||
from schemas.dto import AuthContext
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
session: AsyncSession = Depends(get_db), token: str = Depends(oauth_scheme)
|
||||
) -> User | None:
|
||||
content = decode_jwt(token)
|
||||
async def get_auth_context(
|
||||
request: Request, session: AsyncSession = Depends(get_db)
|
||||
) -> AuthContext | None:
|
||||
auth = request.headers.get("Authorization")
|
||||
|
||||
try:
|
||||
payload = JWTPayload.model_validate(content)
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials") from None
|
||||
if not auth:
|
||||
raise HTTPException(status_code=403, detail="No authorization provided.")
|
||||
|
||||
if payload.exp < datetime.now(UTC).timestamp():
|
||||
raise HTTPException(status_code=401, detail="Access token expired")
|
||||
if auth.startswith("Bearer"):
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return user
|
||||
def get_pally_client() -> PallyClient:
|
||||
return PallyClient(api_token=cfg.pally_token, payer_pays_commission=True)
|
||||
|
||||
@@ -32,12 +32,12 @@ def verify_password(hashed_password: str, plain_password: str) -> bool:
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
return jwt.decode(token, cfg.public_key, "RS256")
|
||||
return jwt.decode(token, cfg.public_key, "EdDSA")
|
||||
except jwt.ExpiredSignatureError:
|
||||
return
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .addons import Addon
|
||||
from .invoice import Invoice
|
||||
from .pricing import PricingConfig
|
||||
from .sessions import Session
|
||||
from .users import User
|
||||
|
||||
__all__ = ["Addon", "PricingConfig", "Session", "User"]
|
||||
__all__ = ["Addon", "Invoice", "PricingConfig", "Session", "User"]
|
||||
|
||||
25
db/models/invoice.py
Normal file
25
db/models/invoice.py
Normal 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
0
external/__init__.py
vendored
Normal file
410
external/pally.py
vendored
Normal file
410
external/pally.py
vendored
Normal 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()
|
||||
@@ -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"]
|
||||
|
||||
@@ -13,3 +13,9 @@ class AddonsRepository:
|
||||
r = await self.session.execute(stmt)
|
||||
|
||||
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
34
repositories/invoices.py
Normal 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
|
||||
@@ -5,4 +5,5 @@ sqlalchemy[asyncio]>=2.0.50
|
||||
argon2-cffi>=25.1.0
|
||||
pydantic-settings>=2.14.0
|
||||
asyncpg>=0.31.0
|
||||
alembic>=1.18.0
|
||||
alembic>=1.18.0
|
||||
aiohttp>=3.14.0
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .orders import router as orders_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
41
routes/orders.py
Normal 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))
|
||||
@@ -1,4 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from db.models.users import User
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -6,3 +9,10 @@ class KeyPair:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
user: User
|
||||
auth_method: Literal["jwt"]
|
||||
service: str | None = None
|
||||
|
||||
15
schemas/invoices.py
Normal file
15
schemas/invoices.py
Normal 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
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AddonData(BaseModel):
|
||||
@@ -11,3 +11,9 @@ class AddonData(BaseModel):
|
||||
class PricingPlans(BaseModel):
|
||||
device_price: int
|
||||
addons: list[AddonData]
|
||||
|
||||
|
||||
class OrderDetails(BaseModel):
|
||||
devices: int = Field()
|
||||
addons: list[str] = Field(description="List of addons ids")
|
||||
duration_days: int
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from repositories.addons import AddonsRepository
|
||||
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):
|
||||
@@ -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)
|
||||
|
||||
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user