Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Asynchronous payment processing service."""
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_api_key
|
||||
from app.db import get_session
|
||||
from app.payments import create_payment, get_payment
|
||||
from app.schemas import PaymentAccepted, PaymentCreate, PaymentDetails
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/payments",
|
||||
tags=["payments"],
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PaymentAccepted, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def post_payment(
|
||||
request: PaymentCreate,
|
||||
idempotency_key: Annotated[
|
||||
str,
|
||||
Header(alias="Idempotency-Key", min_length=1, max_length=255),
|
||||
],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> PaymentAccepted:
|
||||
payment = await create_payment(session, request, idempotency_key)
|
||||
return PaymentAccepted.model_validate(payment)
|
||||
|
||||
|
||||
@router.get("/{payment_id}", response_model=PaymentDetails)
|
||||
async def read_payment(
|
||||
payment_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> PaymentDetails:
|
||||
payment = await get_payment(session, payment_id)
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found")
|
||||
return PaymentDetails.model_validate(payment)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def require_api_key(
|
||||
supplied_key: Annotated[str | None, Depends(api_key_header)],
|
||||
current_settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> None:
|
||||
if supplied_key is None or not secrets.compare_digest(supplied_key, current_settings.api_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API key",
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "Payment Processing Service"
|
||||
api_key: str = Field(default="change-me", min_length=1)
|
||||
database_url: str = "postgresql+asyncpg://payments:payments@localhost:5432/payments"
|
||||
rabbitmq_url: str = "amqp://guest:guest@localhost:5672/"
|
||||
outbox_poll_interval: float = Field(default=1.0, gt=0)
|
||||
outbox_batch_size: int = Field(default=100, gt=0)
|
||||
outbox_publish_timeout: float = Field(default=10.0, gt=0)
|
||||
processing_min_seconds: float = Field(default=2.0, ge=0)
|
||||
processing_max_seconds: float = Field(default=5.0, ge=0)
|
||||
processing_success_rate: float = Field(default=0.9, ge=0, le=1)
|
||||
processing_max_attempts: int = Field(default=3, ge=1)
|
||||
retry_base_delay: float = Field(default=1.0, ge=0)
|
||||
webhook_timeout: float = Field(default=10.0, gt=0)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
|
||||
from faststream import FastStream
|
||||
from faststream.exceptions import RejectMessage
|
||||
from faststream.rabbit import RabbitBroker
|
||||
|
||||
from app.config import settings
|
||||
from app.db import close_database, session_factory
|
||||
from app.messaging import PAYMENTS_EXCHANGE, PAYMENTS_QUEUE, declare_topology
|
||||
from app.processing import PaymentGateway, PaymentProcessor, WebhookClient
|
||||
from app.schemas import PaymentCreatedEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
broker = RabbitBroker(settings.rabbitmq_url, graceful_timeout=30.0)
|
||||
app = FastStream(broker)
|
||||
processor = PaymentProcessor(
|
||||
session_factory,
|
||||
PaymentGateway(settings),
|
||||
WebhookClient(settings.webhook_timeout),
|
||||
settings,
|
||||
)
|
||||
|
||||
|
||||
@broker.subscriber(PAYMENTS_QUEUE, PAYMENTS_EXCHANGE)
|
||||
async def handle_payment(event: PaymentCreatedEvent) -> None:
|
||||
try:
|
||||
await processor.process(event)
|
||||
except Exception as exc:
|
||||
logger.exception("Payment %s exhausted all processing attempts", event.payment_id)
|
||||
raise RejectMessage() from exc
|
||||
|
||||
|
||||
@app.after_startup
|
||||
async def setup_topology() -> None:
|
||||
await declare_topology(broker)
|
||||
|
||||
|
||||
@app.after_shutdown
|
||||
async def shutdown_database() -> None:
|
||||
await close_database()
|
||||
@@ -0,0 +1,23 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def close_database() -> None:
|
||||
await engine.dispose()
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from faststream.rabbit import Channel, RabbitBroker
|
||||
|
||||
from app.api import router
|
||||
from app.config import settings
|
||||
from app.db import close_database, session_factory
|
||||
from app.messaging import declare_topology
|
||||
from app.outbox import OutboxPublisher
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
broker = RabbitBroker(
|
||||
settings.rabbitmq_url,
|
||||
default_channel=Channel(publisher_confirms=True, on_return_raises=True),
|
||||
)
|
||||
await broker.start()
|
||||
await declare_topology(broker)
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
publisher = OutboxPublisher(
|
||||
session_factory,
|
||||
broker,
|
||||
batch_size=settings.outbox_batch_size,
|
||||
poll_interval=settings.outbox_poll_interval,
|
||||
publish_timeout=settings.outbox_publish_timeout,
|
||||
)
|
||||
publisher_task = asyncio.create_task(publisher.run(stop_event))
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_event.set()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
publisher_task,
|
||||
timeout=settings.outbox_publish_timeout + settings.outbox_poll_interval,
|
||||
)
|
||||
except TimeoutError:
|
||||
publisher_task.cancel()
|
||||
await asyncio.gather(publisher_task, return_exceptions=True)
|
||||
await broker.stop()
|
||||
await close_database()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
|
||||
app.include_router(router)
|
||||
@@ -0,0 +1,34 @@
|
||||
from faststream.rabbit import ExchangeType, RabbitBroker, RabbitExchange, RabbitQueue
|
||||
|
||||
PAYMENTS_ROUTING_KEY = "payments.new"
|
||||
DEAD_ROUTING_KEY = "payments.dead"
|
||||
|
||||
PAYMENTS_EXCHANGE = RabbitExchange(
|
||||
"payments",
|
||||
type=ExchangeType.DIRECT,
|
||||
durable=True,
|
||||
)
|
||||
DEAD_EXCHANGE = RabbitExchange(
|
||||
"payments.dlx",
|
||||
type=ExchangeType.DIRECT,
|
||||
durable=True,
|
||||
)
|
||||
PAYMENTS_QUEUE = RabbitQueue(
|
||||
"payments.new",
|
||||
durable=True,
|
||||
arguments={
|
||||
"x-dead-letter-exchange": DEAD_EXCHANGE.name,
|
||||
"x-dead-letter-routing-key": DEAD_ROUTING_KEY,
|
||||
},
|
||||
)
|
||||
DEAD_QUEUE = RabbitQueue("payments.dead", durable=True)
|
||||
|
||||
|
||||
async def declare_topology(broker: RabbitBroker) -> None:
|
||||
payments_exchange = await broker.declare_exchange(PAYMENTS_EXCHANGE)
|
||||
dead_exchange = await broker.declare_exchange(DEAD_EXCHANGE)
|
||||
payments_queue = await broker.declare_queue(PAYMENTS_QUEUE)
|
||||
dead_queue = await broker.declare_queue(DEAD_QUEUE)
|
||||
|
||||
await payments_queue.bind(payments_exchange, routing_key=PAYMENTS_ROUTING_KEY)
|
||||
await dead_queue.bind(dead_exchange, routing_key=DEAD_ROUTING_KEY)
|
||||
@@ -0,0 +1,69 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class Currency(StrEnum):
|
||||
RUB = "RUB"
|
||||
USD = "USD"
|
||||
EUR = "EUR"
|
||||
|
||||
|
||||
class PaymentStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
|
||||
currency: Mapped[Currency] = mapped_column(
|
||||
Enum(Currency, name="currency", values_callable=lambda values: [v.value for v in values]),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSONB, default=dict, nullable=False
|
||||
)
|
||||
status: Mapped[PaymentStatus] = mapped_column(
|
||||
Enum(
|
||||
PaymentStatus,
|
||||
name="payment_status",
|
||||
values_callable=lambda values: [v.value for v in values],
|
||||
),
|
||||
default=PaymentStatus.PENDING,
|
||||
nullable=False,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
webhook_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
webhook_delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class OutboxEvent(Base):
|
||||
__tablename__ = "outbox"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
aggregate_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from faststream.rabbit import RabbitBroker
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.messaging import PAYMENTS_EXCHANGE, PAYMENTS_ROUTING_KEY
|
||||
from app.models import OutboxEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutboxPublisher:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker,
|
||||
broker: RabbitBroker,
|
||||
*,
|
||||
batch_size: int,
|
||||
poll_interval: float,
|
||||
publish_timeout: float,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._broker = broker
|
||||
self._batch_size = batch_size
|
||||
self._poll_interval = poll_interval
|
||||
self._publish_timeout = publish_timeout
|
||||
|
||||
async def publish_batch(self, stop_event: asyncio.Event | None = None) -> int:
|
||||
async with self._session_factory() as session:
|
||||
event_ids = list(
|
||||
await session.scalars(
|
||||
select(OutboxEvent.id)
|
||||
.where(OutboxEvent.published_at.is_(None))
|
||||
.order_by(OutboxEvent.created_at)
|
||||
.limit(self._batch_size)
|
||||
)
|
||||
)
|
||||
|
||||
published = 0
|
||||
for event_id in event_ids:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
break
|
||||
|
||||
async with self._session_factory() as session:
|
||||
event = await session.scalar(
|
||||
select(OutboxEvent)
|
||||
.where(
|
||||
OutboxEvent.id == event_id,
|
||||
OutboxEvent.published_at.is_(None),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
if event is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._broker.publish(
|
||||
event.payload,
|
||||
queue=PAYMENTS_ROUTING_KEY,
|
||||
exchange=PAYMENTS_EXCHANGE,
|
||||
persist=True,
|
||||
message_id=str(event.id),
|
||||
message_type=event.event_type,
|
||||
timeout=self._publish_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
event.attempts += 1
|
||||
event.last_error = str(exc)[:2000]
|
||||
logger.exception("Failed to publish outbox event %s", event.id)
|
||||
else:
|
||||
event.published_at = datetime.now(UTC)
|
||||
event.attempts += 1
|
||||
event.last_error = None
|
||||
published += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
return published
|
||||
|
||||
async def run(self, stop_event: asyncio.Event) -> None:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await self.publish_batch(stop_event)
|
||||
except Exception:
|
||||
logger.exception("Outbox polling iteration failed")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=self._poll_interval)
|
||||
except TimeoutError:
|
||||
pass
|
||||
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import OutboxEvent, Payment
|
||||
from app.schemas import PaymentCreate, PaymentCreatedEvent
|
||||
|
||||
|
||||
async def create_payment(
|
||||
session: AsyncSession,
|
||||
request: PaymentCreate,
|
||||
idempotency_key: str,
|
||||
) -> Payment:
|
||||
payment_id = uuid.uuid4()
|
||||
payment = Payment(
|
||||
id=payment_id,
|
||||
amount=request.amount,
|
||||
currency=request.currency,
|
||||
description=request.description,
|
||||
metadata_=request.metadata,
|
||||
idempotency_key=idempotency_key,
|
||||
webhook_url=str(request.webhook_url),
|
||||
)
|
||||
event = PaymentCreatedEvent(payment_id=payment_id)
|
||||
outbox_event = OutboxEvent(
|
||||
aggregate_id=payment_id,
|
||||
event_type="payment.created",
|
||||
payload=event.model_dump(mode="json"),
|
||||
)
|
||||
session.add_all((payment, outbox_event))
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
existing = await session.scalar(
|
||||
select(Payment).where(Payment.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is None:
|
||||
raise
|
||||
return existing
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_payment(session: AsyncSession, payment_id: uuid.UUID) -> Payment | None:
|
||||
return await session.get(Payment, payment_id)
|
||||
@@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Payment, PaymentStatus
|
||||
from app.schemas import PaymentCreatedEvent, PaymentWebhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PaymentGateway:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._min_seconds = settings.processing_min_seconds
|
||||
self._max_seconds = settings.processing_max_seconds
|
||||
self._success_rate = settings.processing_success_rate
|
||||
|
||||
async def charge(self) -> PaymentStatus:
|
||||
await asyncio.sleep(random.uniform(self._min_seconds, self._max_seconds))
|
||||
if random.random() < self._success_rate:
|
||||
return PaymentStatus.SUCCEEDED
|
||||
return PaymentStatus.FAILED
|
||||
|
||||
|
||||
class WebhookClient:
|
||||
def __init__(self, timeout: float) -> None:
|
||||
self._timeout = timeout
|
||||
|
||||
async def send(self, url: str, payload: PaymentWebhook) -> None:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
response = await client.post(url, json=payload.model_dump(mode="json"))
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def retry_async[T](
|
||||
operation: Callable[[], Awaitable[T]],
|
||||
*,
|
||||
attempts: int,
|
||||
base_delay: float,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
) -> T:
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
return await operation()
|
||||
except Exception:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
delay = base_delay * (2**attempt)
|
||||
logger.warning(
|
||||
"Payment handling attempt %d/%d failed; retrying in %.2fs",
|
||||
attempt + 1,
|
||||
attempts,
|
||||
delay,
|
||||
exc_info=True,
|
||||
)
|
||||
await sleep(delay)
|
||||
raise RuntimeError("Retry loop completed without a result")
|
||||
|
||||
|
||||
class PaymentProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker,
|
||||
gateway: PaymentGateway,
|
||||
webhook_client: WebhookClient,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._gateway = gateway
|
||||
self._webhook_client = webhook_client
|
||||
self._attempts = settings.processing_max_attempts
|
||||
self._base_delay = settings.retry_base_delay
|
||||
|
||||
async def process(self, event: PaymentCreatedEvent) -> None:
|
||||
await retry_async(
|
||||
lambda: self._process_once(event),
|
||||
attempts=self._attempts,
|
||||
base_delay=self._base_delay,
|
||||
)
|
||||
|
||||
async def _process_once(self, event: PaymentCreatedEvent) -> None:
|
||||
async with self._session_factory() as session:
|
||||
payment = await session.scalar(
|
||||
select(Payment).where(Payment.id == event.payment_id).with_for_update()
|
||||
)
|
||||
if payment is None:
|
||||
raise PaymentNotFoundError(str(event.payment_id))
|
||||
|
||||
if payment.status is PaymentStatus.PENDING:
|
||||
payment.status = await self._gateway.charge()
|
||||
payment.processed_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
if payment.webhook_delivered_at is not None:
|
||||
return
|
||||
if payment.processed_at is None:
|
||||
raise RuntimeError("A terminal payment has no processing timestamp")
|
||||
|
||||
webhook_url = payment.webhook_url
|
||||
webhook = PaymentWebhook(
|
||||
payment_id=payment.id,
|
||||
amount=payment.amount,
|
||||
currency=payment.currency,
|
||||
status=payment.status,
|
||||
processed_at=payment.processed_at,
|
||||
metadata=payment.metadata_,
|
||||
)
|
||||
|
||||
await self._webhook_client.send(webhook_url, webhook)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
payment = await session.scalar(
|
||||
select(Payment).where(Payment.id == event.payment_id).with_for_update()
|
||||
)
|
||||
if payment is None:
|
||||
raise PaymentNotFoundError(str(event.payment_id))
|
||||
if payment.webhook_delivered_at is None:
|
||||
payment.webhook_delivered_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,54 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
|
||||
|
||||
from app.models import Currency, PaymentStatus
|
||||
|
||||
Amount = Annotated[Decimal, Field(gt=0, max_digits=18, decimal_places=2)]
|
||||
|
||||
|
||||
class PaymentCreate(BaseModel):
|
||||
amount: Amount
|
||||
currency: Currency
|
||||
description: str = Field(min_length=1, max_length=500)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
webhook_url: HttpUrl
|
||||
|
||||
|
||||
class PaymentAccepted(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
payment_id: uuid.UUID = Field(validation_alias="id")
|
||||
status: PaymentStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PaymentDetails(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
payment_id: uuid.UUID = Field(validation_alias="id")
|
||||
amount: Decimal
|
||||
currency: Currency
|
||||
description: str
|
||||
metadata: dict[str, Any] = Field(validation_alias="metadata_")
|
||||
status: PaymentStatus
|
||||
idempotency_key: str
|
||||
webhook_url: str
|
||||
created_at: datetime
|
||||
processed_at: datetime | None
|
||||
|
||||
|
||||
class PaymentCreatedEvent(BaseModel):
|
||||
payment_id: uuid.UUID
|
||||
|
||||
|
||||
class PaymentWebhook(BaseModel):
|
||||
payment_id: uuid.UUID
|
||||
amount: Decimal
|
||||
currency: Currency
|
||||
status: PaymentStatus
|
||||
processed_at: datetime
|
||||
metadata: dict[str, Any]
|
||||
Reference in New Issue
Block a user