Async payment-processing service for the test assignment.
- FastAPI + Pydantic v2
- SQLAlchemy 2.0 async + PostgreSQL
- RabbitMQ + FastStream
- Alembic
- Docker Compose
docker compose up --buildThe API is available at http://payments.localhost/api/v1 through Traefik
The Traefik dashboard is available at http://traefik.payments.localhost.
PostgreSQL, RabbitMQ, and the API container ports are not published by the
default compose file.
Local development:
uv sync
docker compose -f docker-compose.dev.yml up -d
alembic upgrade head
python -m payments --app
python -m payments --scheduler
python -m payments --consumerThe dev compose file starts only PostgreSQL and RabbitMQ. Their ports are bound
to localhost: PostgreSQL at 127.0.0.1:5432, RabbitMQ at 127.0.0.1:5672,
and RabbitMQ management UI at http://127.0.0.1:15672 with guest / guest.
API_KEY: static API key forX-API-Key; local default isdev-api-key.POSTGRES_HOST,POSTGRES_PORT,POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB: PostgreSQL connection settings.RABBITMQ_URL: RabbitMQ URL.OUTBOX_SCHEDULER_INTERVAL_SECONDS: outbox polling interval.OUTBOX_SCHEDULER_BATCH_SIZE: outbox batch size.WEBHOOK_TIMEOUT_SECONDS: timeout for webhook calls.
Create payment:
curl -i -X POST http://payments.localhost/api/v1/payments \
-H "Content-Type: application/json" \
-H "X-API-Key: dev-api-key" \
-H "Idempotency-Key: order-42" \
-d '{
"amount": "100.50",
"currency": "RUB",
"description": "Order 42",
"metadata": {"order_id": "42"},
"webhook_url": "https://example.com/payments/webhook"
}'Response:
{
"payment_id": "00000000-0000-0000-0000-000000000000",
"status": "pending",
"created_at": "2026-06-15T12:00:00Z"
}Read payment:
curl -i http://payments.localhost/api/v1/payments/00000000-0000-0000-0000-000000000000 \
-H "X-API-Key: dev-api-key"The service uses the transactional outbox pattern between the HTTP API and RabbitMQ.
POST /api/v1/paymentsvalidates the request and callsCreatePaymentInteractor.- The interactor creates a
Paymentwith statuspendingand anOutboxEventwith event typepayments.newin one database transaction. The outbox payload is{"payment_id": "<payment_id>"}. - The outbox scheduler periodically reads pending or retry-ready outbox rows and publishes them to RabbitMQ through FastStream.
- When publishing succeeds, the outbox row is marked as published. If publishing fails, the outbox row is marked as failed and scheduled for retry with exponential backoff. After the configured retry limit it is marked as dead-lettered in the database.
- The RabbitMQ consumer reads messages from the
payments.newqueue and callsProcessPaymentInteractor. - The interactor loads the payment, skips already processed payments, calls the external payment gateway emulator, updates the payment status, sends the webhook, and commits the transaction.
RabbitMQ topology:
paymentsexchange: main exchange for payment events.payments.newqueue: durable quorum queue for new payment processing.payments.dlxexchange: dead-letter exchange.payments.new.dlqqueue: dead-letter queue for messages that RabbitMQ can no longer deliver successfully.
Consumer retries are handled by RabbitMQ. The payments.new queue has
delivery limit 3, and the consumer uses NACK_ON_ERROR. If the consumer
raises an exception, FastStream nacks the message. RabbitMQ redelivers it
until the delivery limit is exhausted, then routes it to payments.new.dlq
through payments.dlx.
Business failures and infrastructure failures are intentionally different:
- If the external gateway returns a business decline, the payment is marked
as
failed, the message is acknowledged, and it is not sent to the DLQ. - If the external gateway call, database work, or webhook call raises an
exception, the message is nacked and retried by RabbitMQ. After 3 failed
deliveries it lands in
payments.new.dlq. - Outbox dead-lettering is separate from RabbitMQ DLQ. It means the service could not publish the event to RabbitMQ after its own retry attempts.
Webhook payload:
{
"payment_id": "00000000-0000-0000-0000-000000000000",
"status": "succeeded",
"amount": "100.50",
"currency": "RUB",
"description": "Order 42",
"metadata": {"order_id": "42"},
"created_at": "2026-06-15T12:00:00Z",
"processed_at": "2026-06-15T12:00:04Z"
}Request 1.
- Public URL:
POST /api/v1/payments - Request body:
{
"amount": "100.50",
"currency": "RUB",
"description": "Order 42",
"metadata": {"order_id": "42"},
"webhook_url": "https://example.com/payments/webhook"
}- Required headers:
X-API-Key,Idempotency-Key - Response body:
{
"payment_id": "00000000-0000-0000-0000-000000000000",
"status": "pending",
"created_at": "2026-06-15T12:00:00Z"
}- Side effects:
paymentsinserts one row with statuspending;outbox_eventsinserts one event with typepayments.newand payload{"payment_id": "<payment_id>"}. The outbox scheduler publishes the event to RabbitMQ. Thepaymentsconsumer processes it, updates the payment status tosucceededorfailed, setsprocessed_at, and sends an HTTP webhook towebhook_url. No SMTP integration is used.
Request 1.
- Public URL:
POST /api/v1/payments - Request body: same as the original request
- Required headers: same
X-API-Keyand sameIdempotency-Key - Response body: same
payment_id, current status, and originalcreated_at - Side effects: no new
paymentsrow and no newoutbox_eventsrow are created. No new consuming-service call or webhook is scheduled by this duplicate request.
Request 1.
- Public URL:
GET /api/v1/payments/{payment_id} - Request body: none
- Required headers:
X-API-Key - Response body:
{
"payment_id": "00000000-0000-0000-0000-000000000000",
"amount": "100.50",
"currency": "RUB",
"description": "Order 42",
"metadata": {"order_id": "42"},
"status": "succeeded",
"idempotency_key": "order-42",
"webhook_url": "https://example.com/payments/webhook",
"created_at": "2026-06-15T12:00:00Z",
"processed_at": "2026-06-15T12:00:04Z"
}- Side effects: no database changes, no outbox event, no consuming-service call, and no external integration call.
uv run ruff check src tests
uv run mypy src tests
uv run pytest -q tests