From 8fbd739697c72238992dd0a643749ec7dc34c333 Mon Sep 17 00:00:00 2001 From: hexdev Date: Fri, 31 Jul 2026 13:42:14 +0700 Subject: [PATCH] feat(orders): integrate Pally payment and order management --- .../versions/e237f7c5cb9a_invoice_table.py | 42 ++ config.py | 3 + core/auth/__init__.py | 0 core/auth/jwt.py | 30 ++ core/deps.py | 45 +- core/secrets.py | 4 +- db/models/__init__.py | 3 +- db/models/invoice.py | 25 ++ external/__init__.py | 0 external/pally.py | 410 ++++++++++++++++++ repositories/__init__.py | 6 + repositories/addons.py | 6 + repositories/invoices.py | 34 ++ requirements.txt | 3 +- routes/__init__.py | 3 +- routes/orders.py | 41 ++ schemas/dto.py | 10 + schemas/invoices.py | 15 + schemas/plans.py | 8 +- services/plans.py | 9 +- 20 files changed, 661 insertions(+), 36 deletions(-) create mode 100644 alembic/versions/e237f7c5cb9a_invoice_table.py create mode 100644 core/auth/__init__.py create mode 100644 core/auth/jwt.py create mode 100644 db/models/invoice.py create mode 100644 external/__init__.py create mode 100644 external/pally.py create mode 100644 repositories/invoices.py create mode 100644 routes/orders.py create mode 100644 schemas/invoices.py diff --git a/alembic/versions/e237f7c5cb9a_invoice_table.py b/alembic/versions/e237f7c5cb9a_invoice_table.py new file mode 100644 index 0000000..57f9a0d --- /dev/null +++ b/alembic/versions/e237f7c5cb9a_invoice_table.py @@ -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 ### diff --git a/config.py b/config.py index 7345e29..8b6f9bd 100644 --- a/config.py +++ b/config.py @@ -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: diff --git a/core/auth/__init__.py b/core/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/auth/jwt.py b/core/auth/jwt.py new file mode 100644 index 0000000..206b0f9 --- /dev/null +++ b/core/auth/jwt.py @@ -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) diff --git a/core/deps.py b/core/deps.py index 788ea88..97cdee4 100644 --- a/core/deps.py +++ b/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) diff --git a/core/secrets.py b/core/secrets.py index 100607a..c8e22c1 100644 --- a/core/secrets.py +++ b/core/secrets.py @@ -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 diff --git a/db/models/__init__.py b/db/models/__init__.py index 35b4bb8..134986c 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -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"] diff --git a/db/models/invoice.py b/db/models/invoice.py new file mode 100644 index 0000000..55cd6e8 --- /dev/null +++ b/db/models/invoice.py @@ -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") diff --git a/external/__init__.py b/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/external/pally.py b/external/pally.py new file mode 100644 index 0000000..b673594 --- /dev/null +++ b/external/pally.py @@ -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() diff --git a/repositories/__init__.py b/repositories/__init__.py index e69de29..96c9e3a 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -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"] diff --git a/repositories/addons.py b/repositories/addons.py index df452eb..0ebaad2 100644 --- a/repositories/addons.py +++ b/repositories/addons.py @@ -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() diff --git a/repositories/invoices.py b/repositories/invoices.py new file mode 100644 index 0000000..5635c64 --- /dev/null +++ b/repositories/invoices.py @@ -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 diff --git a/requirements.txt b/requirements.txt index e039c1f..0924785 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +alembic>=1.18.0 +aiohttp>=3.14.0 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py index dcdb7e2..adeec58 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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] diff --git a/routes/orders.py b/routes/orders.py new file mode 100644 index 0000000..3e26fb8 --- /dev/null +++ b/routes/orders.py @@ -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)) diff --git a/schemas/dto.py b/schemas/dto.py index cb09e30..be79831 100644 --- a/schemas/dto.py +++ b/schemas/dto.py @@ -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 diff --git a/schemas/invoices.py b/schemas/invoices.py new file mode 100644 index 0000000..12af095 --- /dev/null +++ b/schemas/invoices.py @@ -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 diff --git a/schemas/plans.py b/schemas/plans.py index 37b2566..d38cb8b 100644 --- a/schemas/plans.py +++ b/schemas/plans.py @@ -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 diff --git a/services/plans.py b/services/plans.py index 271235d..ed778ee 100644 --- a/services/plans.py +++ b/services/plans.py @@ -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])